diff --git a/demo/large_data_range.html b/demo/large_data_range.html
index 2ff64d8..47575e7 100644
--- a/demo/large_data_range.html
+++ b/demo/large_data_range.html
@@ -8,6 +8,7 @@
Test if we have precision issue with very large X
+ Segment 1 starts from 3685 June 13th 9AM
@@ -40,7 +41,7 @@
const chart = new TimeChart(el, {
baseTime: Date.now(),
series: [
- { name: 'Line 1', data: data, color: 'blue' },
+ { name: 'Line 1', data: data, color: 'blue', xStep: timeStep },
],
zoom: { x: { autoRange: true } },
tooltip: { enabled: true },
diff --git a/src/core/index.ts b/src/core/index.ts
index ba0f758..7e57101 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -38,6 +38,9 @@ const defaultSeriesOptions = {
visible: true,
lineType: LineType.Line,
stepLocation: 1.,
+
+ xStep: 0,
+ xStepCorrection: true,
} as const;
type TPluginStates = { [P in keyof TPlugins]: TPlugins[P] extends TimeChartPlugin ? TState : never };
diff --git a/src/options.ts b/src/options.ts
index 8716b4b..39d86ee 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -106,6 +106,18 @@ export interface TimeChartSeriesOptions {
visible: boolean;
lineType: LineType;
stepLocation: number;
+
+ /**
+ * The expected interval of adjacent x values. Set this for higher WebGL rendering accuracy.
+ * @default 0
+ */
+ xStep: number;
+ /**
+ * Whether to correct xStep to match the actual interval when a segment is fully filled.
+ * @default true
+ * @see xStep
+ */
+ xStepCorrection: boolean;
}
export function resolveColorRGBA(color: ColorSpecifier): [number, number, number, number] {
diff --git a/src/plugins/lineChart.ts b/src/plugins/lineChart.ts
index 22f6c36..8ddd8ee 100644
--- a/src/plugins/lineChart.ts
+++ b/src/plugins/lineChart.ts
@@ -25,11 +25,11 @@ class ShaderUniformData {
get modelScale() {
return new Float32Array(this.data, 0, 2);
}
- get modelTranslate() {
+ get projectionScale() {
return new Float32Array(this.data, 2 * 4, 2);
}
- get projectionScale() {
- return new Float32Array(this.data, 4 * 4, 2);
+ get modelTranslateY() {
+ return new Float32Array(this.data, 4 * 4, 1);
}
upload(index = 0) {
@@ -41,9 +41,13 @@ class ShaderUniformData {
const VS_HEADER = `#version 300 es
layout (std140) uniform proj {
vec2 modelScale;
- vec2 modelTranslate;
vec2 projectionScale;
+ float modelTranslateY;
};
+uniform highp float modelTranslateX;
+uniform highp float modelTranslateXStep;
+uniform int startIndex;
+
uniform highp sampler2D uDataPoints;
uniform int uLineType;
uniform float uStepLocation;
@@ -72,7 +76,10 @@ class NativeLineProgram extends LinkedWebGLProgram {
uniform float uPointSize;
void main() {
- vec2 pos2d = projectionScale * modelScale * (dataPoint(gl_VertexID) + modelTranslate);
+ vec2 dp = dataPoint(gl_VertexID);
+ dp.x += modelTranslateXStep * float(gl_VertexID - startIndex);
+ vec2 modelTranslate = vec2(modelTranslateX, modelTranslateY);
+ vec2 pos2d = projectionScale * modelScale * (dp + modelTranslate);
gl_Position = vec4(pos2d, 0, 1);
gl_PointSize = uPointSize;
}
@@ -83,6 +90,9 @@ void main() {
this.link();
this.locations = {
+ modelTranslateX: this.getUniformLocation('modelTranslateX'),
+ modelTranslateXStep: this.getUniformLocation('modelTranslateXStep'),
+ startIndex: this.getUniformLocation('startIndex'),
uDataPoints: this.getUniformLocation('uDataPoints'),
uPointSize: this.getUniformLocation('uPointSize'),
uColor: this.getUniformLocation('uColor'),
@@ -105,6 +115,8 @@ void main() {
int index = gl_VertexID >> 2;
vec2 dp[2] = vec2[2](dataPoint(index), dataPoint(index + 1));
+ dp[0].x += modelTranslateXStep * float(index - startIndex);
+ dp[1].x += modelTranslateXStep * float(index + 1 - startIndex);
vec2 base;
vec2 off;
@@ -114,13 +126,14 @@ void main() {
dir = normalize(modelScale * dir);
off = vec2(-dir.y, dir.x) * uLineWidth;
} else if (uLineType == ${LineType.Step}) {
- base = vec2(dp[0].x * (1. - uStepLocation) + dp[1].x * uStepLocation, dp[di].y);
+ base = vec2(mix(dp[0].x, dp[1].x, uStepLocation), dp[di].y);
float up = sign(dp[0].y - dp[1].y);
off = vec2(uLineWidth * up, uLineWidth);
}
if (side == 1)
off = -off;
+ vec2 modelTranslate = vec2(modelTranslateX, modelTranslateY);
vec2 cssPose = modelScale * (base + modelTranslate);
vec2 pos2d = projectionScale * (cssPose + off);
gl_Position = vec4(pos2d, 0, 1);
@@ -132,6 +145,9 @@ void main() {
this.link();
this.locations = {
+ modelTranslateX: this.getUniformLocation('modelTranslateX'),
+ modelTranslateXStep: this.getUniformLocation('modelTranslateXStep'),
+ startIndex: this.getUniformLocation('startIndex'),
uDataPoints: this.getUniformLocation('uDataPoints'),
uLineType: this.getUniformLocation('uLineType'),
uStepLocation: this.getUniformLocation('uStepLocation'),
@@ -147,11 +163,16 @@ void main() {
}
class SeriesSegmentVertexArray {
+ // X data stored in dataBuffer is offset from prediction by x0 and xStep
dataBuffer;
+ // all the data in dataBuffer is invalidated when changing these two variables
+ x0 = 0.;
+ xStep = -1.;
+
constructor(
private gl: WebGL2RenderingContext,
- private dataPoints: DataPointsBuffer,
+ private series: TimeChartSeriesOptions,
) {
this.dataBuffer = throwIfFalsy(gl.createTexture());
gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer);
@@ -165,8 +186,54 @@ class SeriesSegmentVertexArray {
this.gl.deleteTexture(this.dataBuffer);
}
- syncPoints(start: number, n: number, bufferPos: number) {
- const dps = this.dataPoints;
+ /**
+ * @param i index into `this.series.data`
+ */
+ predictX(i: number) {
+ return this.x0 + this.xStep * i;
+ }
+
+ /** Sync n dataPoints at this.dataPoints[start] to this.series.data[bufferPos]
+ * @param resync request re-sync all vaild data covered by the buffer, update X prediction
+ */
+ syncPoints(start: number, n: number, bufferPos: number, resync = false) {
+ const dps = this.series.data;
+ if (resync) {
+ // TODO: skip if the prediction is already accurate enough?
+ const s = Math.max(start - bufferPos, 0);
+ start -= s;
+ bufferPos -= s;
+ const e = Math.min(start - bufferPos + BUFFER_POINT_CAPACITY, dps.length);
+ n = e - start;
+
+ let step = n - 1;
+ let step1 = 0;
+ let x1 = dps[start].x;
+ let xn = dps[start + n - 1].x;
+ // if the values at the edge are valid, use the average of the overlapping values
+ // to ensure the predictX of adjacent segments are consistent.
+ if (bufferPos === 0) {
+ step -= 0.5;
+ step1 += 0.5;
+ x1 = (dps[start].x + dps[start + 1].x) / 2
+ }
+ if (e === BUFFER_POINT_CAPACITY) {
+ step -= 0.5;
+ xn = (dps[start + n - 2].x + dps[start + n - 1].x) / 2
+ }
+ this.xStep = (xn - this.x0) / step;
+ this.x0 = x1 - this.xStep * step1;
+ } else if (this.xStep < 0) {
+ // first sync, set x0 to match either the first or last data point, whichever is valid,
+ // to ensure the overlapping part with the previous segment is consistent.
+ // Do not guess xDelta as it may be very inaccurate.
+ this.xStep = this.series.xStep;
+ if (bufferPos <= 1)
+ this.x0 = (dps[start].x + dps[start + 1].x) / 2 - this.xStep * (bufferPos + 0.5);
+ else
+ this.x0 = (dps[start + n - 2].x + dps[start + n - 1].x) / 2 - this.xStep * (bufferPos + n - 1.5);
+ }
+
let rowStart = Math.floor(bufferPos / BUFFER_TEXTURE_WIDTH);
let rowEnd = Math.ceil((bufferPos + n) / BUFFER_TEXTURE_WIDTH);
// Ensure we have some padding at both ends of data.
@@ -182,7 +249,7 @@ class SeriesSegmentVertexArray {
const i = Math.max(Math.min(start + p - bufferPos, dps.length - 1), 0);
const dp = dps[i];
const bufferIdx = ((r - rowStart) * BUFFER_TEXTURE_WIDTH + c) * 2;
- buffer[bufferIdx] = dp.x;
+ buffer[bufferIdx] = dp.x - this.predictX(p);
buffer[bufferIdx + 1] = dp.y;
}
}
@@ -194,7 +261,7 @@ class SeriesSegmentVertexArray {
/**
* @param renderInterval [start, end) interval of data points, start from 0
*/
- draw(renderInterval: { start: number, end: number }, type: LineType) {
+ draw(renderInterval: { start: number, end: number }, prog: lineProgram, type: LineType, translateX: number) {
const first = Math.max(0, renderInterval.start);
const last = Math.min(BUFFER_INTERVAL_CAPACITY, renderInterval.end)
const count = last - first
@@ -202,6 +269,10 @@ class SeriesSegmentVertexArray {
const gl = this.gl;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer);
+ gl.uniform1i(prog.locations.startIndex, first);
+ gl.uniform1f(prog.locations.modelTranslateX, translateX + this.predictX(first));
+ gl.uniform1f(prog.locations.modelTranslateXStep, this.xStep);
+
if (type === LineType.Line) {
gl.drawArrays(gl.TRIANGLE_STRIP, first * 4, count * 4 + (last !== renderInterval.end ? 2 : 0));
} else if (type === LineType.Step) {
@@ -226,8 +297,13 @@ class SeriesSegmentVertexArray {
class SeriesVertexArray {
private segments = [] as SeriesSegmentVertexArray[];
// each segment has at least 2 points
- private validStart = 0; // start position of the first segment. (0, BUFFER_INTERVAL_CAPACITY]
- private validEnd = 0; // end position of the last segment. [2, BUFFER_POINT_CAPACITY)
+ // both ends have at least 1 empty slot, potentially filled with repeated values
+ // adjacent segments overlap by 2 point
+
+ /** start position (inclusive) of the first segment. (0, BUFFER_INTERVAL_CAPACITY] */
+ private validStart = 0;
+ /** end position (exclusive) of the last segment. [2, BUFFER_POINT_CAPACITY) */
+ private validEnd = 0;
constructor(
private gl: WebGL2RenderingContext,
@@ -267,7 +343,7 @@ class SeriesVertexArray {
}
private newArray() {
- return new SeriesSegmentVertexArray(this.gl, this.series.data);
+ return new SeriesSegmentVertexArray(this.gl, this.series);
}
private pushFront() {
let numDPtoAdd = this.series.data.pushed_front;
@@ -287,9 +363,12 @@ class SeriesVertexArray {
while (true) {
const activeArray = this.segments[0];
const n = Math.min(this.validStart, numDPtoAdd);
- activeArray.syncPoints(numDPtoAdd - n, n, this.validStart - n);
- numDPtoAdd -= this.validStart - (BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY);
+ const start = numDPtoAdd - n;
+ numDPtoAdd -= this.validStart;
+ numDPtoAdd += (BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY); // each segment overlaps with the previous one
this.validStart -= n;
+ const resync = this.validStart === 0 && this.series.xStepCorrection;
+ activeArray.syncPoints(start, n, this.validStart, resync);
if (this.validStart > 0)
break;
newArray();
@@ -314,7 +393,8 @@ class SeriesVertexArray {
while (true) {
const activeArray = this.segments[this.segments.length - 1];
const n = Math.min(BUFFER_POINT_CAPACITY - this.validEnd, numDPtoAdd);
- activeArray.syncPoints(this.series.data.length - numDPtoAdd, n, this.validEnd);
+ const resync = this.validEnd + n === BUFFER_POINT_CAPACITY && this.series.xStepCorrection;
+ activeArray.syncPoints(this.series.data.length - numDPtoAdd, n, this.validEnd, resync);
// Note that each segment overlaps with the previous one.
// numDPtoAdd can increase here, indicating the overlapping part should be synced again to the next segment
numDPtoAdd -= BUFFER_INTERVAL_CAPACITY - this.validEnd;
@@ -356,7 +436,7 @@ class SeriesVertexArray {
this.pushBack();
}
- draw(renderDomain: { min: number, max: number }) {
+ draw(renderDomain: { min: number, max: number }, prog: lineProgram, translateX: number) {
const data = this.series.data;
if (this.segments.length === 0 || data[0].x > renderDomain.max || data[data.length - 1].x < renderDomain.min)
return;
@@ -374,11 +454,13 @@ class SeriesVertexArray {
this.segments[i].draw({
start: startInterval - arrOffset,
end: endInterval - arrOffset,
- }, this.series.lineType);
+ }, prog, this.series.lineType, translateX);
}
}
}
+type lineProgram = NativeLineProgram | LineProgram;
+
export class LineChartRenderer {
private lineProgram = new LineProgram(this.gl, this.options.debugWebGL);
private nativeLineProgram = new NativeLineProgram(this.gl, this.options.debugWebGL);
@@ -428,7 +510,7 @@ export class LineChartRenderer {
drawFrame() {
this.syncBuffer();
- this.syncDomain();
+ const transX = this.syncDomain();
this.uniformBuffer.upload();
const gl = this.gl;
for (const [ds, arr] of this.arrays) {
@@ -458,7 +540,7 @@ export class LineChartRenderer {
min: this.model.xScale.invert(this.options.renderPaddingLeft - lineWidth / 2),
max: this.model.xScale.invert(this.width - this.options.renderPaddingRight + lineWidth / 2),
};
- arr.draw(renderDomain);
+ arr.draw(renderDomain, prog, transX);
}
if (this.options.debugWebGL) {
const err = gl.getError();
@@ -468,6 +550,7 @@ export class LineChartRenderer {
}
}
+ // returns modelTranslateX
syncDomain() {
this.syncViewport();
const m = this.model;
@@ -492,7 +575,9 @@ export class LineChartRenderer {
];
this.uniformBuffer.modelScale.set(s);
- this.uniformBuffer.modelTranslate.set(t);
+ this.uniformBuffer.modelTranslateY.set(t.slice(1));
+
+ return t[0];
}
}