Skip to content

Commit 1cf8cc6

Browse files
authored
Legends (new CircleLegend and GeoLegend) and value indicator (tooltip, explicit) (#818)
* feat(CircleLegend): New component for visualizing radius (`rScale`) values as nested circles * update catalog and screenshots * feat(GeoLegend): New scale-bar legend showing real-world distance for the current `Chart` projection * update catalog and screenshots * Update bubble-map to use integrated cScale and rScale, and use data-driven Circle * feat(Legend, CircleLegend): Show an indicator of the current tooltip value on the legend
1 parent 4dad826 commit 1cf8cc6

99 files changed

Lines changed: 1878 additions & 92 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(CircleLegend): New component for visualizing radius (`rScale`) values as nested circles
6+
7+
`CircleLegend` displays a set of bottom-aligned nested circles representing values from a radius scale, useful alongside bubble maps and scatter charts that encode magnitude via circle area. By default it reads `rScale` from the chart context, but a `scale` prop can also be passed to render standalone.
8+
9+
Supports `tickValues` / `ticks` / `tickFormat` for value selection and formatting, a `title` rendered centered above the circles, and `labelPlacement="right" | "left" | "inline"` to render tick labels with a leader line on either side of the circles or centered inside each circle near the top.

.changeset/geo-legend-component.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(GeoLegend): New scale-bar legend showing real-world distance for the current `Chart` projection
6+
7+
`GeoLegend` reads the active geo projection from the chart context and renders a labeled scale bar with tick subdivisions. By default it picks a "nice" round distance that covers ~25% of the chart width, but `distance` can be passed for an explicit value. Supports `units="km" | "mi"`, configurable `ticks`, `tickFormat`, `title`, and the standard `placement` props. Inspired by Harry Stevens' [d3-geo-scale-bar](https://observablehq.com/@harrystevens/introducing-d3-geo-scale-bar).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(Legend, CircleLegend): Show an indicator of the current tooltip value on the legend
6+
7+
`Legend` (ramp variant) now draws a small upward-pointing arrow below the color ramp at the position of the currently hovered value, and `CircleLegend` draws a 50%-opacity filled circle at the corresponding radius. Both auto-read the hovered data from `ctx.tooltip.data` and pipe it through the chart's color (`ctx.c`) / radius (`ctx.r`) accessors, so wiring is automatic for charts that configure `c` / `r` / `cScale` / `rScale` via `Chart` props.
8+
9+
A new `value` prop on both components allows explicitly setting the indicator value (overriding the auto-detection), useful when the tooltip data shape doesn't match the chart's accessor.
10+
11+
For `scaleThreshold` / `scaleQuantize` / `scaleQuantile` scales, the `Legend` indicator centers on the matching bucket swatch.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
description: Nested-circle legend used to communicate values encoded by circle radius (typically `Chart` `rScale`). Inspired by Harry Stevens' Observable circle legend.
3+
category: common
4+
layers: [html]
5+
related: [Legend, GeoLegend, Chart, ScatterChart, GeoPath]
6+
---
7+
8+
## Usage
9+
10+
:example{ name="basic" showCode }
11+
12+
### Scale types
13+
14+
`CircleLegend` works with any continuous d3 scale. `scaleSqrt` is the most common choice since it makes circle _area_ proportional to the value, but `scaleLinear`, `scaleLog` (strictly positive domains), and `scalePow` are also supported.
15+
16+
:example{ name="scale-types" showCode }
17+
18+
### Label placement
19+
20+
Use `labelPlacement` to control where tick labels are rendered. `'right'` (default) and `'left'` render labels outside the circles with leader lines, while `'inline'` centers each label inside its circle near the top.
21+
22+
:example{ name="label-placement" showCode }
23+
24+
### Tooltip indicator
25+
26+
When the chart has an active tooltip, `CircleLegend` draws a 50%-opacity filled circle at the current hovered value's radius, overlaid on the nested circles. By default it reads `ctx.tooltip.data` and pipes it through the chart's radius accessor (`ctx.r`), so charts that configure `r` / `rScale` on `<Chart>` get the indicator automatically.
27+
28+
Pass an explicit `value` prop to override the auto-detection when the tooltip data shape doesn't match the chart's radius accessor:
29+
30+
```svelte
31+
<CircleLegend
32+
scale={rScale}
33+
value={context.tooltip.data?.properties.data?.population}
34+
/>
35+
```
36+
37+
See the [bubble-map](/docs/components/GeoPath/bubble-map) example.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
description: Geographic scale bar showing real-world distance for the current `Chart` projection. Inspired by Harry Stevens' d3-geo-scale-bar.
3+
category: geo
4+
layers: [html]
5+
related: [Legend, CircleLegend]
6+
---
7+
8+
## Usage
9+
10+
`GeoLegend` reads the active geo projection from the chart context and computes a "nice" round distance that fits within the chart, drawing a labeled scale bar with tick subdivisions. Pass `units="mi"` to display miles instead of the default kilometers, or set `distance` for an explicit value.
11+
12+
The bar reactively updates as the user pans/zooms — both `transform.mode: 'canvas'` and `transform.mode: 'projection'` are supported.
13+
14+
### Projection transform mode
15+
16+
:example{ name="basic" showCode }
17+
18+
### Canvas transform mode
19+
20+
:example{ name="canvas-mode" showCode }
21+
22+
### Variants
23+
24+
`variant` controls the visual style of the bar: `'bracket'` (default — top rule with downward tick brackets) or `'alternating'` (filled/unfilled segments between ticks).
25+
26+
:example{ name="variants" showCode }
27+
28+
### Ticks
29+
30+
Use `ticks` to control the number of subdivisions. Set `ticks={1}` to show only the endpoints (`0` and the final value).
31+
32+
:example{ name="ticks" showCode }
33+
34+
### Tick format
35+
36+
`tickFormat` accepts any [`@layerstack/utils`](https://github.com/techniq/layerstack/tree/main/packages/utils) format key (e.g. `"metric"`, `"integer"`), a `FormatConfig` object, or a custom `(value) => string` function. The default appends the unit on the last tick only.
37+
38+
:example{ name="tick-format" showCode }
39+
40+
### Stacked units
41+
42+
Use `labelPlacement="top"` to place labels above the bar so two legends with different units (e.g. kilometers and miles) can be stacked tightly with their bars touching.
43+
44+
:example{ name="stacked-units" showCode }
45+
46+
## Reference point
47+
48+
The pixels-per-unit ratio is measured at a single reference point on the projection — by default, the center of the chart's plot area (`[width / 2, height / 2]`). For conformal projections like Mercator where scale varies with latitude, the legend is only accurate at that reference point. Pass an explicit `referencePoint` in chart pixel coordinates to anchor the measurement elsewhere (e.g. near the legend itself, or over your region of interest).

docs/src/content/components/Legend.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
---
2-
description: Commonly used component which explains the symbols, colors, or patterns used in a chart, helping interpret the represented data categories. Filtering and toggling visibility of data series can be enabled for interactivity.
2+
description: Commonly used component which explains the symbols, colors, or patterns used in a chart, helping interpret the represented data categories. Typically paired with `Chart` `cScale` (color scale). Filtering and toggling visibility of data series can be enabled for interactivity.
33
category: common
44
layers: [html]
5-
related: []
5+
related: [CircleLegend, GeoLegend]
66
---
77

88
## Usage
99

1010
:example{ name="sequential" showCode }
1111

12+
### Tooltip indicator
13+
14+
When the chart has an active tooltip, `Legend` draws a small arrow below the color ramp at the position of the currently hovered value. By default it reads `ctx.tooltip.data` and pipes it through the chart's color accessor (`ctx.c`), so charts that configure `c` / `cScale` on `<Chart>` get the indicator automatically.
15+
16+
For `scaleThreshold` / `scaleQuantize` / `scaleQuantile` scales, the arrow centers on the matching bucket swatch.
17+
18+
Pass an explicit `value` prop to override the auto-detection — useful when the tooltip data shape doesn't match the chart's color accessor:
19+
20+
```svelte
21+
<Legend
22+
scale={colorScale}
23+
value={context.tooltip.data?.properties.data?.percentUnder18}
24+
/>
25+
```
26+
27+
See the [choropleth](/docs/components/GeoPath/choropleth) and [bubble-map](/docs/components/GeoPath/bubble-map) examples.
28+
1229
<!-- ## Examples
1330
1431
### scaleSequential

docs/src/examples/catalog/Chart.json

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,6 +1769,13 @@
17691769
"lineNumber": 5,
17701770
"line": "<Chart"
17711771
},
1772+
{
1773+
"example": "basic",
1774+
"component": "CircleLegend",
1775+
"path": "/docs/components/CircleLegend/basic",
1776+
"lineNumber": 6,
1777+
"line": "<Chart data={[{ value: 1 }, { value: 100 }]} r=\"value\" rRange={[2, 40]} height={120}>"
1778+
},
17721779
{
17731780
"example": "playground",
17741781
"component": "Connector",
@@ -2056,6 +2063,48 @@
20562063
"lineNumber": 97,
20572064
"line": "<Chart"
20582065
},
2066+
{
2067+
"example": "basic",
2068+
"component": "GeoLegend",
2069+
"path": "/docs/components/GeoLegend/basic",
2070+
"lineNumber": 12,
2071+
"line": "<Chart"
2072+
},
2073+
{
2074+
"example": "canvas-mode",
2075+
"component": "GeoLegend",
2076+
"path": "/docs/components/GeoLegend/canvas-mode",
2077+
"lineNumber": 12,
2078+
"line": "<Chart"
2079+
},
2080+
{
2081+
"example": "stacked-units",
2082+
"component": "GeoLegend",
2083+
"path": "/docs/components/GeoLegend/stacked-units",
2084+
"lineNumber": 11,
2085+
"line": "<Chart"
2086+
},
2087+
{
2088+
"example": "tick-format",
2089+
"component": "GeoLegend",
2090+
"path": "/docs/components/GeoLegend/tick-format",
2091+
"lineNumber": 11,
2092+
"line": "<Chart"
2093+
},
2094+
{
2095+
"example": "ticks",
2096+
"component": "GeoLegend",
2097+
"path": "/docs/components/GeoLegend/ticks",
2098+
"lineNumber": 11,
2099+
"line": "<Chart"
2100+
},
2101+
{
2102+
"example": "variants",
2103+
"component": "GeoLegend",
2104+
"path": "/docs/components/GeoLegend/variants",
2105+
"lineNumber": 15,
2106+
"line": "<Chart"
2107+
},
20592108
{
20602109
"example": "animated-globe",
20612110
"component": "GeoPath",
@@ -2067,7 +2116,7 @@
20672116
"example": "bubble-map",
20682117
"component": "GeoPath",
20692118
"path": "/docs/components/GeoPath/bubble-map",
2070-
"lineNumber": 72,
2119+
"lineNumber": 82,
20712120
"line": "<Chart"
20722121
},
20732122
{
@@ -4066,5 +4115,5 @@
40664115
"line": "<Chart {data} x=\"x\" y=\"y\" height={400}>"
40674116
}
40684117
],
4069-
"updatedAt": "2026-04-08T04:31:40.633Z"
4118+
"updatedAt": "2026-04-08T20:33:54.649Z"
40704119
}

docs/src/examples/catalog/Circle.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@
461461
"example": "bubble-map",
462462
"component": "GeoPath",
463463
"path": "/docs/components/GeoPath/bubble-map",
464-
"lineNumber": 96,
464+
"lineNumber": 107,
465465
"line": "<Circle"
466466
},
467467
{
@@ -920,5 +920,5 @@
920920
"line": "<Circle cx={point.x} cy={point.y} r={4} class=\"fill-primary\" />"
921921
}
922922
],
923-
"updatedAt": "2026-04-01T13:16:17.703Z"
923+
"updatedAt": "2026-04-08T15:58:16.677Z"
924924
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
{
2+
"component": "CircleLegend",
3+
"examples": [
4+
{
5+
"name": "basic",
6+
"title": "basic",
7+
"path": "/docs/components/CircleLegend/basic",
8+
"components": [
9+
{
10+
"component": "Chart",
11+
"lineNumber": 6,
12+
"line": "<Chart data={[{ value: 1 }, { value: 100 }]} r=\"value\" rRange={[2, 40]} height={120}>"
13+
},
14+
{
15+
"component": "CircleLegend",
16+
"lineNumber": 7,
17+
"line": "<CircleLegend title=\"Population\" />"
18+
}
19+
]
20+
},
21+
{
22+
"name": "label-placement",
23+
"title": "label placement",
24+
"path": "/docs/components/CircleLegend/label-placement",
25+
"components": [
26+
{
27+
"component": "CircleLegend",
28+
"lineNumber": 9,
29+
"line": "<CircleLegend {scale} title=\"Population\" tickFormat=\"metric\" labelPlacement=\"right\" />"
30+
}
31+
]
32+
},
33+
{
34+
"name": "scale-types",
35+
"title": "scale types",
36+
"path": "/docs/components/CircleLegend/scale-types",
37+
"components": [
38+
{
39+
"component": "CircleLegend",
40+
"lineNumber": 12,
41+
"line": "<CircleLegend scale={sqrt} title=\"Sqrt\" tickFormat=\"metric\" />"
42+
}
43+
]
44+
}
45+
],
46+
"usage": [
47+
{
48+
"example": "basic",
49+
"component": "CircleLegend",
50+
"path": "/docs/components/CircleLegend/basic",
51+
"lineNumber": 7,
52+
"line": "<CircleLegend title=\"Population\" />"
53+
},
54+
{
55+
"example": "label-placement",
56+
"component": "CircleLegend",
57+
"path": "/docs/components/CircleLegend/label-placement",
58+
"lineNumber": 9,
59+
"line": "<CircleLegend {scale} title=\"Population\" tickFormat=\"metric\" labelPlacement=\"right\" />"
60+
},
61+
{
62+
"example": "label-placement",
63+
"component": "CircleLegend",
64+
"path": "/docs/components/CircleLegend/label-placement",
65+
"lineNumber": 10,
66+
"line": "<CircleLegend {scale} title=\"Population\" tickFormat=\"metric\" labelPlacement=\"left\" />"
67+
},
68+
{
69+
"example": "label-placement",
70+
"component": "CircleLegend",
71+
"path": "/docs/components/CircleLegend/label-placement",
72+
"lineNumber": 11,
73+
"line": "<CircleLegend {scale} title=\"Population\" tickFormat=\"metric\" labelPlacement=\"inline\" />"
74+
},
75+
{
76+
"example": "scale-types",
77+
"component": "CircleLegend",
78+
"path": "/docs/components/CircleLegend/scale-types",
79+
"lineNumber": 12,
80+
"line": "<CircleLegend scale={sqrt} title=\"Sqrt\" tickFormat=\"metric\" />"
81+
},
82+
{
83+
"example": "scale-types",
84+
"component": "CircleLegend",
85+
"path": "/docs/components/CircleLegend/scale-types",
86+
"lineNumber": 13,
87+
"line": "<CircleLegend scale={linear} title=\"Linear\" tickFormat=\"metric\" />"
88+
},
89+
{
90+
"example": "scale-types",
91+
"component": "CircleLegend",
92+
"path": "/docs/components/CircleLegend/scale-types",
93+
"lineNumber": 14,
94+
"line": "<CircleLegend scale={log} title=\"Log\" tickFormat=\"metric\" />"
95+
},
96+
{
97+
"example": "scale-types",
98+
"component": "CircleLegend",
99+
"path": "/docs/components/CircleLegend/scale-types",
100+
"lineNumber": 15,
101+
"line": "<CircleLegend scale={pow} title=\"Pow (0.3)\" tickFormat=\"metric\" />"
102+
},
103+
{
104+
"example": "bubble-map",
105+
"component": "GeoPath",
106+
"path": "/docs/components/GeoPath/bubble-map",
107+
"lineNumber": 149,
108+
"line": "<CircleLegend scale={rScale} title=\"Population\" tickFormat=\"metric\" placement=\"bottom-right\" />"
109+
},
110+
{
111+
"example": "zoomable-bubble",
112+
"component": "ScatterChart",
113+
"path": "/docs/components/ScatterChart/zoomable-bubble",
114+
"lineNumber": 60,
115+
"line": "<CircleLegend"
116+
}
117+
],
118+
"updatedAt": "2026-04-08T15:58:16.875Z"
119+
}

0 commit comments

Comments
 (0)