From 059da9611ab6fdbcfb5ae34deaa81a3589ada029 Mon Sep 17 00:00:00 2001 From: robertclaus Date: Thu, 17 Sep 2026 17:32:22 -0500 Subject: [PATCH 1/3] Memoize Color Translations --- src/components/color/index.js | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/color/index.js b/src/components/color/index.js index 8460b966980..d14f9a5bd48 100644 --- a/src/components/color/index.js +++ b/src/components/color/index.js @@ -90,6 +90,31 @@ const parse = (cstr, silent) => { return c; }; +// `stroke` and `fill` below run once per data point, and every point of a trace +// normally repeats the same specifier, so parsing it each time is pure overhead. +// Memoize the two values they derive from it, keyed on the specifier itself. +// Only strings are cached, since they are the only specifiers we can key on. +const MAX_MEMO_SIZE = 1000; + +const memoize = (fn) => { + const cache = new Map(); + + return (cstr) => { + if (typeof cstr !== 'string') return fn(cstr); + + let value = cache.get(cstr); + if (value === undefined) { + value = fn(cstr); + // Stop growing rather than evicting: a graph only ever uses a + // handful of distinct colors, so a full cache means array-valued + // colors, which repeat too little to be worth tracking. + if (cache.size < MAX_MEMO_SIZE) cache.set(cstr, value); + } + + return value; + }; +}; + // TODO: rename to `rgbString` to better describe return value /** * Convert any color specifier to a normalized `rgb(r, g, b)` string. @@ -98,7 +123,11 @@ const parse = (cstr, silent) => { * @param {*} cstr - Color specifier * @return {String} */ -const rgb = (cstr) => formatRgb({ ...parse(cstr), alpha: 1 }); +const rgb = memoize((cstr) => formatRgb({ ...parse(cstr), alpha: 1 })); + +// The alpha channel of a specifier, memoized for the same reason as `rgb`. +// Unlike `opacity` this keeps `parse`'s treatment of missing colors (alpha 1). +const alphaOf = memoize((cstr) => parse(cstr).alpha); /** * Return the alpha channel of a color (0 if falsy). @@ -278,7 +307,7 @@ const contrast = (cstr, lightAmount, darkAmount) => { * @param {*} cstr - Color specifier */ const stroke = (s, cstr) => { - s.style({ stroke: rgb(cstr), 'stroke-opacity': parse(cstr).alpha }); + s.style({ stroke: rgb(cstr), 'stroke-opacity': alphaOf(cstr) }); }; /** @@ -288,7 +317,7 @@ const stroke = (s, cstr) => { * @param {*} cstr - Color specifier */ const fill = (s, cstr) => { - s.style({ fill: rgb(cstr), 'fill-opacity': parse(cstr).alpha }); + s.style({ fill: rgb(cstr), 'fill-opacity': alphaOf(cstr) }); }; /** From 8d67e49cd7c74e25656b12ba31a833b906fd8f56 Mon Sep 17 00:00:00 2001 From: robertclaus Date: Thu, 17 Sep 2026 17:40:10 -0500 Subject: [PATCH 2/3] Draftlog --- draftlogs/8055_change.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/8055_change.md diff --git a/draftlogs/8055_change.md b/draftlogs/8055_change.md new file mode 100644 index 00000000000..671ceabf5ed --- /dev/null +++ b/draftlogs/8055_change.md @@ -0,0 +1 @@ + - Memoize color specifier parsing, reducing the time to draw marker-heavy SVG `scatter` traces by roughly a third [[#8055](https://github.com/plotly/plotly.js/pull/8055)] \ No newline at end of file From 98419a8af457210b308f6d1ccc8fb0dd548c58d0 Mon Sep 17 00:00:00 2001 From: robertclaus Date: Mon, 21 Sep 2026 09:48:41 -0400 Subject: [PATCH 3/3] Cache both stroke/fill styles in one lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stroke` and `fill` each asked the memo twice — once for the rgb string and once for the alpha — so a cache miss paid for two guarded lookups and two full `parse` calls. With a distinct color per point nothing ever hits, and that overhead showed up as a small regression against main. Cache the pair instead, derived from a single `parse`, and leave the exported `rgb` unmemoized so its many cold callers stop paying for the wrapper. Colors that repeat still cost one lookup, and colors that don't now parse once per point instead of twice. Co-Authored-By: Claude Opus 5 --- src/components/color/index.js | 68 ++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/components/color/index.js b/src/components/color/index.js index d14f9a5bd48..bd7921cc74d 100644 --- a/src/components/color/index.js +++ b/src/components/color/index.js @@ -90,31 +90,6 @@ const parse = (cstr, silent) => { return c; }; -// `stroke` and `fill` below run once per data point, and every point of a trace -// normally repeats the same specifier, so parsing it each time is pure overhead. -// Memoize the two values they derive from it, keyed on the specifier itself. -// Only strings are cached, since they are the only specifiers we can key on. -const MAX_MEMO_SIZE = 1000; - -const memoize = (fn) => { - const cache = new Map(); - - return (cstr) => { - if (typeof cstr !== 'string') return fn(cstr); - - let value = cache.get(cstr); - if (value === undefined) { - value = fn(cstr); - // Stop growing rather than evicting: a graph only ever uses a - // handful of distinct colors, so a full cache means array-valued - // colors, which repeat too little to be worth tracking. - if (cache.size < MAX_MEMO_SIZE) cache.set(cstr, value); - } - - return value; - }; -}; - // TODO: rename to `rgbString` to better describe return value /** * Convert any color specifier to a normalized `rgb(r, g, b)` string. @@ -123,11 +98,7 @@ const memoize = (fn) => { * @param {*} cstr - Color specifier * @return {String} */ -const rgb = memoize((cstr) => formatRgb({ ...parse(cstr), alpha: 1 })); - -// The alpha channel of a specifier, memoized for the same reason as `rgb`. -// Unlike `opacity` this keeps `parse`'s treatment of missing colors (alpha 1). -const alphaOf = memoize((cstr) => parse(cstr).alpha); +const rgb = (cstr) => formatRgb({ ...parse(cstr), alpha: 1 }); /** * Return the alpha channel of a color (0 if falsy). @@ -300,6 +271,37 @@ const contrast = (cstr, lightAmount, darkAmount) => { } }; +// `stroke` and `fill` below run once per data point, and every point of a trace +// normally repeats the same specifier, so re-deriving the styles each time is +// pure overhead. Cache both values a specifier yields, keyed on the specifier +// itself, and take them from a single `parse` so that a miss costs no more than +// it has to. Only strings are cached, since they are the only specifiers that +// repeat by value rather than by identity. +const MAX_MEMO_SIZE = 1000; + +const styleCache = new Map(); + +const computeStyle = (cstr) => { + const c = parse(cstr); + // Force alpha to 1 in the color so that it gets dropped from the string. + return [formatRgb({ ...c, alpha: 1 }), c.alpha]; +}; + +const styleOf = (cstr) => { + if (typeof cstr !== 'string') return computeStyle(cstr); + + let value = styleCache.get(cstr); + if (value === undefined) { + value = computeStyle(cstr); + // Stop growing rather than evicting: a graph only ever uses a handful + // of distinct colors, so a full cache means array-valued colors, which + // repeat too little to be worth tracking. + if (styleCache.size < MAX_MEMO_SIZE) styleCache.set(cstr, value); + } + + return value; +}; + /** * Apply `stroke` and `stroke-opacity` styles to a D3 selection. * @@ -307,7 +309,8 @@ const contrast = (cstr, lightAmount, darkAmount) => { * @param {*} cstr - Color specifier */ const stroke = (s, cstr) => { - s.style({ stroke: rgb(cstr), 'stroke-opacity': alphaOf(cstr) }); + const style = styleOf(cstr); + s.style({ stroke: style[0], 'stroke-opacity': style[1] }); }; /** @@ -317,7 +320,8 @@ const stroke = (s, cstr) => { * @param {*} cstr - Color specifier */ const fill = (s, cstr) => { - s.style({ fill: rgb(cstr), 'fill-opacity': alphaOf(cstr) }); + const style = styleOf(cstr); + s.style({ fill: style[0], 'fill-opacity': style[1] }); }; /**