Skip to content

Commit d5f7ab1

Browse files
committed
feat(AnnotationPoint): Add geo support
1 parent 77315dd commit d5f7ab1

5 files changed

Lines changed: 247 additions & 4 deletions

File tree

.changeset/annotation-point-link-callouts.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22
'layerchart': minor
33
---
44

5-
feat(AnnotationPoint): Add `link` prop for ring-note style callouts
5+
feat(AnnotationPoint): Add `link` prop for ring-note style callouts, plus geo projection support
6+
7+
- Pass `link={true}` or `link={{ type: 'beveled', radius: 20, ... }}` etc. to draw a `<Link>` from the ring edge to the label. Any `Link` prop (`type`, `curve`, `sweep`, `radius`, `bend`, `class`, ...) can be passed through.
8+
- Inside a geo `<Chart>`, `x`/`y` are now interpreted as `[lon, lat]` and projected directly, so `AnnotationPoint` can be used on maps.

docs/src/content/components/AnnotationPoint.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ Pass `link` (or `link={{ type: 'swoop', ... }}` etc.) to draw a `<Link>` from
1515
the ring edge to the label.
1616
:example{ name="link-callouts" showCode }
1717

18+
## Geo
19+
20+
Inside a geo `<Chart>`, `x`/`y` are interpreted as `[lon, lat]` and projected directly.
21+
22+
:example{ name="us-cities" showCode }
23+
24+
:example{ name="world-landmarks" showCode }
25+
1826
## Playground
1927

2028
:example{ name="playground" }
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script module lang="ts">
2+
import { getUsStatesTopology } from '$lib/geo.remote';
3+
const topology = await getUsStatesTopology();
4+
</script>
5+
6+
<script lang="ts">
7+
import { geoAlbersUsa } from 'd3-geo';
8+
import { feature } from 'topojson-client';
9+
10+
import { AnnotationPoint, Chart, GeoPath, Layer, type Placement } from 'layerchart';
11+
import type { ComponentProps } from 'svelte';
12+
13+
const states = feature(topology, topology.objects.states);
14+
15+
const annotations: Array<
16+
{
17+
lon: number;
18+
lat: number;
19+
} & ComponentProps<typeof AnnotationPoint>
20+
> = [
21+
{
22+
label: 'Seattle',
23+
lon: -122.3321,
24+
lat: 47.6062,
25+
labelPlacement: 'top-left',
26+
labelXOffset: 10,
27+
labelYOffset: 10,
28+
props: {
29+
circle: { class: 'fill-secondary stroke-surface-100' },
30+
label: { class: 'fill-surface-content text-xs font-bold' }
31+
}
32+
},
33+
{
34+
label: 'Los Angeles',
35+
lon: -118.2437,
36+
lat: 34.0522,
37+
labelPlacement: 'bottom-left',
38+
labelXOffset: 10,
39+
labelYOffset: 10,
40+
props: {
41+
circle: { class: 'fill-secondary stroke-surface-100' },
42+
label: { class: 'fill-surface-content text-xs font-bold' }
43+
}
44+
},
45+
{
46+
label: 'Houston',
47+
lon: -95.3698,
48+
lat: 29.7604,
49+
labelPlacement: 'bottom',
50+
labelYOffset: 10,
51+
props: {
52+
circle: { class: 'fill-secondary stroke-surface-100' },
53+
label: { class: 'fill-surface-content text-xs font-bold' }
54+
}
55+
},
56+
{
57+
label: 'Chicago',
58+
lon: -87.6298,
59+
lat: 41.8781,
60+
labelPlacement: 'top',
61+
labelYOffset: 10,
62+
props: {
63+
circle: { class: 'fill-secondary stroke-surface-100' },
64+
label: { class: 'fill-surface-content text-xs font-bold' }
65+
}
66+
},
67+
{
68+
label: 'New York',
69+
lon: -74.006,
70+
lat: 40.7128,
71+
labelPlacement: 'bottom-right',
72+
labelXOffset: 10,
73+
labelYOffset: 10,
74+
props: {
75+
circle: { class: 'fill-secondary stroke-surface-100' },
76+
label: { class: 'fill-surface-content text-xs font-bold' }
77+
}
78+
},
79+
{
80+
label: 'Miami',
81+
lon: -80.1918,
82+
lat: 25.7617,
83+
labelPlacement: 'top-right',
84+
labelXOffset: 10,
85+
labelYOffset: 10,
86+
props: {
87+
circle: { class: 'fill-secondary stroke-surface-100' },
88+
label: { class: 'fill-surface-content text-xs font-bold' }
89+
}
90+
}
91+
];
92+
93+
const data = { topology, states };
94+
export { data };
95+
</script>
96+
97+
<Chart geo={{ projection: geoAlbersUsa, fitGeojson: states }} height={500} padding={{ right: 40 }}>
98+
<Layer>
99+
<GeoPath geojson={states} class="fill-surface-content/10 stroke-surface-100" />
100+
101+
{#each annotations as annotation (annotation.label)}
102+
<AnnotationPoint {...annotation} x={annotation.lon} y={annotation.lat} r={4} link />
103+
{/each}
104+
</Layer>
105+
</Chart>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<script module lang="ts">
2+
import { getCountriesTopology } from '$lib/geo.remote';
3+
const topology = await getCountriesTopology();
4+
</script>
5+
6+
<script lang="ts">
7+
import { geoNaturalEarth1 } from 'd3-geo';
8+
import { feature } from 'topojson-client';
9+
10+
import { AnnotationPoint, Chart, GeoPath, Layer } from 'layerchart';
11+
import type { ComponentProps } from 'svelte';
12+
13+
const countries = feature(topology, topology.objects.countries);
14+
15+
const annotations: Array<
16+
{
17+
lon: number;
18+
lat: number;
19+
} & ComponentProps<typeof AnnotationPoint>
20+
> = [
21+
{
22+
label: 'Statue of Liberty',
23+
lon: -74.0445,
24+
lat: 40.6892,
25+
labelPlacement: 'left',
26+
labelXOffset: 10,
27+
props: {
28+
circle: { class: 'fill-secondary stroke-surface-100' },
29+
label: { class: 'fill-surface-content text-xs font-bold' }
30+
}
31+
},
32+
{
33+
label: 'Machu Picchu',
34+
lon: -72.545,
35+
lat: -13.1631,
36+
labelPlacement: 'bottom-left',
37+
labelXOffset: 10,
38+
labelYOffset: 10,
39+
props: {
40+
circle: { class: 'fill-secondary stroke-surface-100' },
41+
label: { class: 'fill-surface-content text-xs font-bold' }
42+
}
43+
},
44+
{
45+
label: 'Eiffel Tower',
46+
lon: 2.2945,
47+
lat: 48.8584,
48+
labelPlacement: 'top-right',
49+
labelXOffset: 10,
50+
labelYOffset: 10,
51+
props: {
52+
circle: { class: 'fill-secondary stroke-surface-100' },
53+
label: { class: 'fill-surface-content text-xs font-bold' }
54+
}
55+
},
56+
{
57+
label: 'Pyramids of Giza',
58+
lon: 31.1342,
59+
lat: 29.9792,
60+
labelPlacement: 'bottom-left',
61+
labelXOffset: 10,
62+
labelYOffset: 10,
63+
props: {
64+
circle: { class: 'fill-secondary stroke-surface-100' },
65+
label: { class: 'fill-surface-content text-xs font-bold' }
66+
}
67+
},
68+
{
69+
label: 'Mt. Everest',
70+
lon: 86.925,
71+
lat: 27.9881,
72+
labelPlacement: 'top',
73+
labelYOffset: 10,
74+
props: {
75+
circle: { class: 'fill-secondary stroke-surface-100' },
76+
label: { class: 'fill-surface-content text-xs font-bold' }
77+
}
78+
},
79+
{
80+
label: 'Great Wall',
81+
lon: 117.2381,
82+
lat: 40.3587,
83+
labelPlacement: 'top-right',
84+
labelXOffset: 10,
85+
labelYOffset: 10,
86+
props: {
87+
circle: { class: 'fill-secondary stroke-surface-100' },
88+
label: { class: 'fill-surface-content text-xs font-bold' }
89+
}
90+
},
91+
{
92+
label: 'Sydney Opera House',
93+
lon: 151.2153,
94+
lat: -33.8568,
95+
labelPlacement: 'bottom',
96+
labelYOffset: 10,
97+
props: {
98+
circle: { class: 'fill-secondary stroke-surface-100' },
99+
label: { class: 'fill-surface-content text-xs font-bold' }
100+
}
101+
}
102+
];
103+
104+
const data = { topology, countries };
105+
export { data };
106+
</script>
107+
108+
<Chart geo={{ projection: geoNaturalEarth1, fitGeojson: countries }} height={500}>
109+
<Layer>
110+
{#each countries.features as f, i (i)}
111+
<GeoPath geojson={f} class="fill-surface-content/10 stroke-surface-100" />
112+
{/each}
113+
114+
{#each annotations as annotation (annotation.label)}
115+
<AnnotationPoint {...annotation} x={annotation.lon} y={annotation.lat} r={4} link />
116+
{/each}
117+
</Layer>
118+
</Chart>

packages/layerchart/src/lib/components/AnnotationPoint.svelte

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
<script lang="ts">
5151
import { getChartContext } from '$lib/contexts/chart.js';
52+
import { getGeoContext } from '$lib/contexts/geo.js';
5253
import Circle from './Circle.svelte';
5354
import Link from './Link.svelte';
5455
import Text from './Text.svelte';
@@ -70,10 +71,18 @@
7071
}: AnnotationPointProps = $props();
7172
7273
const ctx = getChartContext();
74+
const geo = getGeoContext();
7375
74-
const point = $derived({
75-
x: x ? ctx.xScale(x) + (isScaleBand(ctx.xScale) ? ctx.xScale.bandwidth() / 2 : 0) : 0,
76-
y: y ? ctx.yScale(y) + (isScaleBand(ctx.yScale) ? ctx.yScale.bandwidth() / 2 : 0) : ctx.height,
76+
const point = $derived.by(() => {
77+
// Inside a geo chart, interpret `x`/`y` as `[lon, lat]` and project directly.
78+
if (geo.projection && typeof x === 'number' && typeof y === 'number') {
79+
const [px, py] = geo.projection([x, y]) ?? [0, 0];
80+
return { x: px, y: py };
81+
}
82+
return {
83+
x: x ? ctx.xScale(x) + (isScaleBand(ctx.xScale) ? ctx.xScale.bandwidth() / 2 : 0) : 0,
84+
y: y ? ctx.yScale(y) + (isScaleBand(ctx.yScale) ? ctx.yScale.bandwidth() / 2 : 0) : ctx.height,
85+
};
7786
});
7887
7988
const labelProps = $derived.by<ComponentProps<typeof Text>>(() => {

0 commit comments

Comments
 (0)