Skip to content

Commit 279de67

Browse files
ryan-williamsclaude
andcommitted
feat: add legendsymbol.path trace attribute for custom legend icons
Allows traces to specify a custom SVG path as their legend symbol, replacing the default colored square/line. This lets consumer apps use plotly's native legend (with all its interaction handling) instead of building separate React legend UIs just for custom icons. The custom path is rendered centered in the legend item, filled with the trace color. Runs after all default style functions and removes their output when active. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent de928ee commit 279de67

File tree

5 files changed

+111
-70
lines changed

5 files changed

+111
-70
lines changed

specs/done/legend-icon-symbols.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Custom legend symbols (icons)
2+
3+
## Problem
4+
5+
Consumer apps build entirely separate "custom" React legend UIs just to show SVG icons (pedestrian, cyclist, driver, etc.) instead of plotly's default colored squares. This duplicates 95% of the legend interaction logic (hover, pin, fade, click-to-solo) and creates a parallel code path that's harder to maintain and misses plotly-level fixes (like flush toggle rects).
6+
7+
## Solution
8+
9+
Added `legendsymbol.path` trace attribute — a custom SVG path string that replaces the default legend symbol.
10+
11+
### Usage
12+
13+
```js
14+
{
15+
type: 'bar',
16+
name: 'Pedestrians',
17+
legendsymbol: {
18+
path: 'M63.3,68.2l-7,11.2l5.4,14.7...',
19+
},
20+
}
21+
```
22+
23+
The path is rendered centered in the legend item area (using the same `centerTransform` as built-in symbols like bars), filled with the trace color (`marker.color` or `line.color`), with no stroke.
24+
25+
### Architecture notes
26+
27+
**Legend symbol rendering pipeline** (`src/components/legend/style.js`):
28+
29+
The main `style()` function creates three sibling `<g>` layers inside each trace's legend item:
30+
- `g.legendfill` — fill areas (beneath)
31+
- `g.legendlines` — line strokes
32+
- `g.legendsymbols > g.legendpoints` — marker symbols (on top)
33+
34+
Then 11 type-specific style functions run in sequence (`.each(styleBars)`, `.each(styleLines)`, etc.), each conditionally adding SVG elements to these layers based on trace type.
35+
36+
**Custom symbol strategy**: `styleCustomSymbol` runs *last* in the chain. When `legendsymbol.path` is set, it:
37+
1. Removes all elements created by the default style functions (from all three layers)
38+
2. Renders the custom SVG `<path>` in `legendpoints` with `centerTransform`
39+
3. Fills with the trace color
40+
41+
Running last is simpler than modifying every existing style function to check for custom symbols.
42+
43+
**Key coordinate details**:
44+
- `centerPos = (itemWidth + itemGap * 2) / 2` ≈ 20px (default `itemWidth=30`, `itemGap=5`)
45+
- `centerTransform = translate(centerPos, 0)` — centers the symbol in the legend item area
46+
- Built-in symbols use coordinates in a ~12×12 box centered at origin (e.g. bars: `M6,6H-6V-6H6Z`)
47+
- Custom paths should use a similar scale, or consumer apps can adjust via `legend.itemwidth`
48+
49+
### Files changed
50+
51+
| File | Change |
52+
|------|--------|
53+
| `src/plots/attributes.js` | Added `legendsymbol.path` trace attribute |
54+
| `src/components/legend/defaults.js` | Coerce `legendsymbol.path` |
55+
| `src/components/legend/style.js` | `styleCustomSymbol` — renders custom path, removes defaults |
56+
57+
### Interaction
58+
59+
No changes needed — the existing toggle rect, hover detection, and click handling all work on the `<g class="traces">` container, not on the symbol specifically. Custom symbols are purely a rendering change.
60+
61+
### Future work
62+
63+
- `legendsymbol.src` — URL to an SVG file (not implemented yet)
64+
- Auto-scaling via `viewBox` for paths in arbitrary coordinate systems
65+
- `legendsymbol.color` override (currently always uses trace color)

specs/legend-icon-symbols.md

Lines changed: 0 additions & 69 deletions
This file was deleted.

src/components/legend/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData) {
112112
}
113113

114114
Lib.coerceFont(traceCoerce, 'legendgrouptitle.font', grouptitlefont);
115+
traceCoerce('legendsymbol.path');
115116
}
116117
if((!isShape && Registry.traceIs(trace, 'bar') && layoutOut.barmode === 'stack') ||
117118
['tonextx', 'tonexty'].indexOf(trace.fill) !== -1) {

src/components/legend/style.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,37 @@ module.exports = function style(s, gd, legend) {
9393
.each(styleLines)
9494
.each(stylePoints)
9595
.each(styleCandles)
96-
.each(styleOHLC);
96+
.each(styleOHLC)
97+
.each(styleCustomSymbol);
98+
99+
function styleCustomSymbol(d) {
100+
var trace = d[0].trace;
101+
var legendsymbol = trace.legendsymbol;
102+
var customPath = legendsymbol && legendsymbol.path;
103+
if(!customPath) return;
104+
105+
var thisGroup = d3.select(this);
106+
107+
// Remove all default symbol elements created by prior style functions
108+
thisGroup.select('.legendfill').selectAll('*').remove();
109+
thisGroup.select('.legendlines').selectAll('*').remove();
110+
var ptgroup = thisGroup.select('g.legendpoints');
111+
ptgroup.selectAll(':not(.legendcustomsymbol)').remove();
112+
113+
// Render custom SVG path
114+
var pts = ptgroup.selectAll('path.legendcustomsymbol')
115+
.data([d]);
116+
pts.enter().append('path')
117+
.classed('legendcustomsymbol', true)
118+
.attr('transform', centerTransform);
119+
pts.exit().remove();
120+
121+
var fillColor = (trace.marker && trace.marker.color) ||
122+
(trace.line && trace.line.color) || null;
123+
pts.attr('d', customPath)
124+
.style('fill', fillColor)
125+
.style('stroke', 'none');
126+
}
97127

98128
function styleLines(d) {
99129
var styleGuide = getStyleGuide(d);

src/plots/attributes.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ module.exports = {
9292
editType: 'style',
9393
description: 'Sets the width (in px or fraction) of the legend for this trace.',
9494
},
95+
legendsymbol: {
96+
path: {
97+
valType: 'string',
98+
dflt: '',
99+
editType: 'style',
100+
description: [
101+
'Sets a custom SVG path to use as the legend symbol for this trace,',
102+
'replacing the default colored square/line.',
103+
'The path is scaled to fit the legend item dimensions',
104+
'and filled with the trace color.'
105+
].join(' ')
106+
},
107+
editType: 'style',
108+
},
95109
opacity: {
96110
valType: 'number',
97111
min: 0,

0 commit comments

Comments
 (0)