diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index b929ca37..35adbb6e 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3031,6 +3031,18 @@ export class ChartView { // Per-mark GPU setup is dispatched through MARK_KINDS (55_marks.js) so a // new chart kind is an entry in that registry, not another branch here. markOf(t.kind).build(this, g, t, buffer); + if (t.tier === "decimated") { + // T1 covering representation for M4 traces. Density has its texture + // cache; decimated line/area traces need the same guarantee while a + // window-specific re-decimation is in flight. Keep the initial (home) + // buffers in a separate drawable so refined replies never overwrite + // the only geometry that covers the full initial domain. + g._homeDecimated = { + ...g, _vaos: null, _homeDecimated: null, _decimatedWindow: null, + }; + g._decimatedWindow = [...this._axisRange(g.xAxis)]; + g._decimatedRefined = false; + } if (t.keys && Number.isInteger(t.keys.lo) && Number.isInteger(t.keys.hi)) { const lo = this._columnView(buffer, this.spec.columns[t.keys.lo]); const hi = this._columnView(buffer, this.spec.columns[t.keys.hi]); @@ -4046,6 +4058,23 @@ export class ChartView { lodDrawDensityTier(this, g, gx0, gx1, gy0, gy1); return; } + if (g.tier === "decimated" && g._decimatedRefined && g._homeDecimated) { + const r = g._decimatedWindow; + // _axisRange preserves axis direction (a reversed axis yields + // hi-before-lo) while the served window arrives normalized, so the + // coverage compare runs on normalized bounds. + const [vx0, vx1] = this._axisRange(g.xAxis); + const viewLo = Math.min(vx0, vx1); + const viewHi = Math.max(vx0, vx1); + const eps = r ? Math.abs(r[1] - r[0]) * 1e-9 + 1e-300 : 0; + // A prior refined window is not a truthful covering representation + // after a pan/zoom leaves it. Draw the retained overview until the + // pending reply installs a buffer covering this view (T1/T8). + if (!r || viewLo < r[0] - eps || viewHi > r[1] + eps) { + markOf(g.trace.kind).draw(this, g._homeDecimated, x0, x1, y0, y1); + return; + } + } markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1); }; for (const g of this._transitionOldTraces || []) drawTrace(g); @@ -6194,6 +6223,7 @@ export class ChartView { this._destroyDensitySample(g); lodDropPointCache(this, g); // retired point windows die with the trace (T13) this._deleteVaos(g); + this._deleteVaos(g._homeDecimated); this._deleteVaos(g.drill); this._deleteBuffers(g, [ "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", @@ -6202,6 +6232,12 @@ export class ChartView { "_transitionPrevXBuf", "_transitionPrevYBuf", "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf", ]); + // Only geometry is owned independently by the retained M4 overview; + // style/channel buffers are shared with the live trace and were deleted + // above exactly once. + if (g._decimatedRefined) { + this._deleteBuffers(g._homeDecimated, ["xBuf", "yBuf", "baseBuf"]); + } this._deleteBuffers(g.drill, [ "xBuf", "yBuf", "cBuf", "rgbaBuf", "sBuf", "styleBuf", "strokeBuf", "selBuf", "dBuf", ]); @@ -6228,6 +6264,7 @@ export class ChartView { g.densityCache = []; g.heatmap = null; g._cpu = null; + g._homeDecimated = null; } _destroyGlResources() { diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 64b1075f..638bc502 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -744,6 +744,16 @@ Object.assign(ChartView.prototype, { const sm = this._smoothArrays(g.trace, xArr, yArr, bArr, n); const src = sm || { x: xArr, y: yArr, n }; const st = this._stepArrays(g.trace, src.x, src.y, src.n); + if (g.tier === "decimated" && !g._decimatedRefined) { + // The initial buffers belong to the retained home drawable (T1). + // Give refinements their own stores rather than destroying that + // covering representation with the first window reply. + this._deleteVaos(g); + g.xBuf = gl.createBuffer(); + g.yBuf = gl.createBuffer(); + if (bArr && g.baseBuf) g.baseBuf = gl.createBuffer(); + g._decimatedRefined = true; + } gl.bindBuffer(gl.ARRAY_BUFFER, g.xBuf); gl.bufferData(gl.ARRAY_BUFFER, st ? st.x : src.x, gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, g.yBuf); @@ -766,6 +776,9 @@ Object.assign(ChartView.prototype, { g.baseMeta = { ...g.baseMeta, offset: upd.base.offset, scale: upd.base.scale }; } g.n = st ? st.n : src.n; + if (Array.isArray(upd.x_range) && upd.x_range.length === 2) { + g._decimatedWindow = [Number(upd.x_range[0]), Number(upd.x_range[1])]; + } } this.draw(); } else if (msg.type === "density_update") { diff --git a/python/xy/interaction.py b/python/xy/interaction.py index d61cccbb..ffffc036 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -471,6 +471,10 @@ def decimate_view( ) update = { "id": t.id, + # The client retains the home M4 buffer as T1's covering + # representation. Record the exact window served by this reply + # so it can fall back to home while a later pan is in flight. + "x_range": [lo_x, hi_x], "x": writer.add_encoded(x_col), "y": writer.add_encoded(y_col), } diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 7deebdf7..a6ff8fef 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -353,7 +353,16 @@ The transition system was built and debugged in this repo; codifying the invariants so future kinds don't regress them: - **T1 — never blank:** a coarser covering representation draws until the - finer one arrives (density-under-points; broadest-cache fallback). + finer one arrives. Density uses density-under-points and its broadest-cache + fallback; an M4-decimated line/area retains its initial home/overview buffer + and draws that whenever the current refined window does not cover the view. + Each tier reply records its served `x_range` (small JSON metadata per the + Transport Matrix — the served window is a scalar control descriptor, not + point data, and stays f64 so deep-zoom coverage compares stay exact); the + overview hold ends only when that reply has installed covering geometry + (T8). Coverage compares normalized `[lo, hi]` bounds on both sides, so a + direction-preserving (reversed) axis range still resolves coverage + correctly. - **T2 — never hard-cut:** representation changes crossfade (entry fade on the aggregate→marks transition only — restarting per refresh reads as flashing; exit fade with the "dying" state so buffers outlive the fade). diff --git a/tests/test_channel.py b/tests/test_channel.py index d7a7e2dc..491e26cf 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -41,6 +41,7 @@ def test_valid_view_returns_tier_update(): assert reply is not None msg, buffers = reply assert msg["type"] == "tier_update" + assert msg["traces"][0]["x_range"] == [100.0, 5_000.0] assert msg["seq"] == 3 assert msg["traces"] assert buffers