diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a906a8f..13c9c5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ in the README). `memory_report`), and `Chart.figure()` remains as an advanced escape hatch to the internal engine object. +### Fixed +- **Streaming appends no longer draw a stroke-width step after browser zoom.** + The tail-only GPU upload path bakes `stroke_width` in device pixels, and a + dpr change (browser zoom, monitor swap) updates `dpr` without rebuilding + traces — so points appended after the change rendered their outline at a + different scale than the points already on screen. Such an append now falls + back to the full rebuild, which renormalizes every row. + ### Changed - **Colored huge-scatter builds are peak-memory-bounded (LOD doc §4.4).** The mean-color feature's one-time costs no longer scale peak RSS with N × diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 440d0c5f..f90fa717 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2200,6 +2200,10 @@ export class ChartView { copy(widthName, 2, this.dpr); copy("symbol", 3); g.styleBuf = this._upload(values); + // Width rows are baked at the dpr in force right now. Record it so the + // streaming-append fast path can tell whether a later tail upload would + // write rows at a different scale than the prefix already holds (§4). + g._styleDpr = this.dpr; } const radius = channel("corner_radius"); if (radius) { diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 7ecb5142..eb3fffdc 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -502,6 +502,23 @@ Object.assign(ChartView.prototype, { const style = t.style || {}; if (style.curve === "smooth" || style.step) return false; // expanded vertices if (JSON.stringify(oldT.style || {}) !== JSON.stringify(style)) return false; + // Only the scatter branch below knows how to extend per-point buffers, so + // any other mark carrying one must rebuild. Today a line has none; this + // keeps that a checked fact rather than an assumption a future per-point + // line channel would silently break. + if ( + t.kind !== "scatter" && + (g.cBuf || g.rgbaBuf || g.sBuf || g.styleBuf || g.strokeBuf || g.radiusBuf) + ) { + return false; + } + // stroke_width rows are baked with the dpr in force when they were written + // (`_buildInstanceStyleChannels`). `_resize` updates `this.dpr` on browser + // zoom or a monitor swap WITHOUT rebuilding traces, so a tail upload after + // such a change would scale appended rows differently from the prefix — a + // visible outline-width step inside one trace. Rebuild instead, which + // renormalizes every row at the current dpr. + if (g.styleBuf && g._styleDpr !== this.dpr) return false; const oldCols = oldSpec.columns; const newCols = this.spec.columns; @@ -708,6 +725,13 @@ Object.assign(ChartView.prototype, { gl.bufferData(gl.ARRAY_BUFFER, st ? st.x : src.x, gl.STATIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, g.yBuf); gl.bufferData(gl.ARRAY_BUFFER, st ? st.y : src.y, gl.STATIC_DRAW); + // These reallocate the data store, so the append fast path's capacity + // bookkeeping no longer describes it. Unreachable for a direct trace + // today (`decimate_view` skips exactly the traces the fast path + // accepts), but leaving a stale cap here would mean a tail + // bufferSubData past the end of the store if that ever drifts. + g.xBuf._fcCapBytes = 0; + g.yBuf._fcCapBytes = 0; g.xMeta = { ...g.xMeta, offset: upd.x.offset, scale: upd.x.scale }; g.yMeta = { ...g.yMeta, offset: upd.y.offset, scale: upd.y.scale }; g._dashX = st ? st.x : src.x; @@ -715,6 +739,7 @@ Object.assign(ChartView.prototype, { if (bArr) { gl.bindBuffer(gl.ARRAY_BUFFER, g.baseBuf); gl.bufferData(gl.ARRAY_BUFFER, sm ? sm.extra : bArr, gl.STATIC_DRAW); + g.baseBuf._fcCapBytes = 0; g.baseMeta = { ...g.baseMeta, offset: upd.base.offset, scale: upd.base.scale }; } g.n = st ? st.n : src.n; diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 81f3030f..fe9881c7 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -74,7 +74,7 @@ def ship(self, values: np.ndarray, col: "Column", *, scale: str | None = None) - # shipped offset and re-center only when it leaves the safe span. offset = ( lod.geometry_offset(scale, col.min, col.max) - if scale in ("log", "symlog") + if lod.pins_offset_to_zero(scale) else col.suggest_offset() ) encoded = lod.encode_f32_values( diff --git a/python/xy/lod.py b/python/xy/lod.py index 35d0f981..38b52a5a 100644 --- a/python/xy/lod.py +++ b/python/xy/lod.py @@ -871,6 +871,17 @@ def encode_f32_values( return EncodedColumn(meta=meta, values=enc) +#: Axis scales whose geometry must be encoded around a zero origin. Callers +#: that choose an offset themselves (the sticky append offset in `_payload`) +#: must branch on this, not on their own copy of the scale names. +LOG_FAMILY_SCALES = ("log", "symlog") + + +def pins_offset_to_zero(scale: str | None) -> bool: + """Whether `scale` requires the zero origin `geometry_offset` gives it.""" + return scale in LOG_FAMILY_SCALES + + def geometry_offset(scale: str | None, lo: float, hi: float) -> float: """Precision center for offset-encoded geometry (§4/§16). @@ -882,7 +893,7 @@ def geometry_offset(scale: str | None, lo: float, hi: float) -> float: decades). With offset 0 the encode error is a ~2⁻²⁴ *relative* error, which the log-family transform maps to a bounded sub-pixel coordinate error at every magnitude.""" - if scale in ("log", "symlog") or not (np.isfinite(lo) and np.isfinite(hi)): + if pins_offset_to_zero(scale) or not (np.isfinite(lo) and np.isfinite(hi)): return 0.0 return (lo + hi) / 2.0 diff --git a/scripts/append_stream_smoke.py b/scripts/append_stream_smoke.py index 50bc317a..f1317688 100644 --- a/scripts/append_stream_smoke.py +++ b/scripts/append_stream_smoke.py @@ -56,7 +56,11 @@ M = 200 # rows appended per tick TICKS = 5 DECIMATED_SHIPPED = 500 -DECIMATED_PX = 2048 +# Just above the ~620 px plot this spec produces (700 wide minus margins), so +# the at-home skip predicate `decimation_px >= plot.w` is actually load-bearing: +# a unit error or a dpr-scaled comparison on either side flips it and the +# viewSendsHome assertion fails. A generous 2048 would mask all of those. +DECIMATED_PX = 640 def find_chromium() -> str: @@ -104,10 +108,15 @@ def series(n: int): return xs, ys, cs, ss -def build_payload(n: int, x_off: float, y_off: float): +def build_payload(n: int, x_off: float, y_off: float, *, stroke_width: bool = False): """One full payload for `n` rows per direct trace. Offsets are the caller's (sticky across ticks, like `Column.suggest_offset`), so every - payload's columns are byte-prefixes of the next one's.""" + payload's columns are byte-prefixes of the next one's. + + `stroke_width=True` adds a per-point stroke-width channel to the scatter, + which is what makes the client build an interleaved `styleBuf` — the one + buffer whose rows are baked with the dpr in force when they were written. + """ xs, ys, cs, ss = series(n) w = Writer() hi_x = float(n) # home range follows the data @@ -134,6 +143,11 @@ def build_payload(n: int, x_off: float, y_off: float): "domain": [0.0, 1.0], "buf": w.ship_scalar(ss), }, + **( + {"channels": {"stroke_width": {"buf": w.ship_scalar([1.0] * n)}}} + if stroke_width + else {} + ), }, { "id": 1, @@ -193,18 +207,35 @@ def main() -> None: spec, blob = build_payload(N0 + k * M, x_off, y_off) ticks.append({"spec": spec, "blob": base64.b64encode(blob).decode()}) + # Phase B continues the same monotonically growing stream. Replaying the + # phase-A payloads instead would ship *fewer* rows than are already + # resident, which fails the `len >= old_len` prefix check and silently + # measures rebuild-path coalescing rather than fast-path coalescing. + zoom_ticks = [] + for k in range(TICKS + 1, TICKS + 7): + spec, blob = build_payload(N0 + k * M, x_off, y_off) + zoom_ticks.append({"spec": spec, "blob": base64.b64encode(blob).decode()}) + + # Phase C runs a small separate chart carrying a per-point stroke_width + # channel, so the client builds an interleaved styleBuf for it. + dpr_ticks = [] + for k in range(3): + spec, blob = build_payload(2_000 + k * M, 1_000.0, 0.0, stroke_width=True) + dpr_ticks.append({"spec": spec, "blob": base64.b64encode(blob).decode()}) + # Expected steady-state tail bytes per tick: 6 f32 columns × M rows # (scatter x/y/color/size + line x/y); the decimated trace is unaffected. tail_bytes = 6 * M * 4 full_bytes = 6 * (N0 + TICKS * M) * 4 # what a rebuild re-uploads at the end page = f"""