From f79d0c81a92b5b5e941a44b091c39fedc2dd3a8e Mon Sep 17 00:00:00 2001 From: Soumya Snigdha Kundu Date: Tue, 28 Jul 2026 19:23:32 +0100 Subject: [PATCH] Resolve categorical palettes once per trace in authored-marker scatters The authored-marker Canvas2D path resolved each point's palette color through parseColor inside the per-point loop. For non-hex palette entries (named colors, rgb(), var(), oklch()) parseColor probes the DOM (createElement + getComputedStyle + removeChild), forcing a style recalc per point per frame: ~19 ms/frame at 5,000 points in headless Chromium, the entire 60 fps budget spent before drawing a mark. Hex palettes were unaffected (pure-JS hexColor path). Resolve the palette to rgba once per trace per draw, next to the existing per-trace continuous LUT, and index it per point. Measured ~0.2 ms/frame at 5,000 points after the change (~100x); point colors are unchanged, including the modulo cycling and the g.color fallback for empty palettes. Verification: node js/build.mjs clean; render_smoke_nonumpy.py OK (lit 96.586%, all probes pass); 111 focused png-export, authored-style parity, categorical gallery, color pipeline and legend fidelity tests pass; ruff check/format hooks pass (docs-app codespell hook fails on clean main in this environment: codespell binary unavailable). --- js/src/51_annotations.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index 71c7a204..4329eb71 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -285,20 +285,13 @@ function xyTaperPolygon(points, w0, w1) { } Object.assign(ChartView.prototype, { - _authoredScatterRgba(g, index, continuousLut = null) { + _authoredScatterRgba(g, index, continuousLut = null, paletteRgba = null) { if (g.colorMode === 3 && g._cpu.rgba) { const offset = index * 4; return Array.from(g._cpu.rgba.slice(offset, offset + 4), (value: number) => value / 255); } - if (g.colorMode === 2 && g._cpu.color) { - const palette = g.trace.color?.palette || []; - if (palette.length) { - return parseColor( - this.root, - palette[Math.round(g._cpu.color[index]) % palette.length], - g.color, - ); - } + if (g.colorMode === 2 && g._cpu.color && paletteRgba && paletteRgba.length) { + return paletteRgba[Math.round(g._cpu.color[index]) % paletteRgba.length]; } if (g.colorMode === 1 && g._cpu.color) { // The caller prepares this once per trace/redraw. Falling back to a @@ -335,6 +328,10 @@ Object.assign(ChartView.prototype, { g._authoredLut = buildLutData(colormap); } const continuousLut = g.colorMode === 1 ? g._authoredLut : null; + const palette = g.trace.color?.palette || []; + const paletteRgba = g.colorMode === 2 && palette.length + ? palette.map((c) => parseColor(this.root, c, g.color)) + : null; for (let index = 0; index < g.n; index++) { const sourceIndex = g._visMap ? g._visMap[index] : index; const x = this._decodeValue(g._cpu.x, g.xMeta, sourceIndex); @@ -349,7 +346,7 @@ Object.assign(ChartView.prototype, { : g._cpu.size[sourceIndex]) : g.size; const size = Math.max(0, Number(sizeValue) * zoomStyle.sizeFactor); - const rgba = this._authoredScatterRgba(g, sourceIndex, continuousLut); + const rgba = this._authoredScatterRgba(g, sourceIndex, continuousLut, paletteRgba); const styleOffset = sourceIndex * 4; const itemStyle = g._cpuStyle && g._cpuStyle.length >= styleOffset + 4 ? g._cpuStyle.subarray(styleOffset, styleOffset + 4)