Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/8055_change.md
Original file line number Diff line number Diff line change
@@ -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)]
35 changes: 32 additions & 3 deletions src/components/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When MAX_MEMO_SIZE reached one could evict first half of the cache.

You could split the cache into two separate Maps: currentCache and oldCache.

  • Lookups check currentCache first, then oldCache (and move the item to currentCache if found).
  • Writes only go to currentCache.
  • When currentCache reaches the maximum capacity limit, you simply wipe oldCache entirely
oldCache.clear();

and swap the references:

oldCache = currentCache; 
currentCache = new Map();

This provides a near-instantaneous O(1) bulk eviction without any loops.

}

return value;
};
};

// TODO: rename to `rgbString` to better describe return value
/**
* Convert any color specifier to a normalized `rgb(r, g, b)` string.
Expand All @@ -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).
Expand Down Expand Up @@ -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) });
};

/**
Expand All @@ -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) });

@archmoj archmoj Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively instead of the cash we may benchmark this option of by passing previous stroke and previous fill. Something like this:

var prevStrokeSTR;
var prevStrokeRGB;
var prevStrokeAlpha;

function getStroke(cstr) {
  if(prevStrokeSTR !== cstr) {
    prevStrokeSTR = cstr;
    prevStrokeRGB = rgb(cstr);
    prevStrokeAlpha = parse(cstr).alpha;
  }
  return [prevStrokeRGB, prevStrokeAlpha];
}

const stroke = (s, cstr) => {
    const v = getStroke(cstr);
    s.style({ stroke: v[0], 'stroke-opacity': v[1] });
};

var prevFillSTR;
var prevFillRGB;
var prevFillAlpha;

function getFill(cstr) {
  if(prevFillSTR !== cstr) {
    prevFillSTR = cstr;
    prevFillRGB = rgb(cstr);
    prevFillAlpha = parse(cstr).alpha;
  }
  return [prevFillRGB, prevFillAlpha];
}

const fill = (s, cstr) => {
    const v = getFill(cstr);
    s.style({ fill: v[0], 'fill-opacity': v[1] });
};

};

/**
Expand Down
Loading