Skip to content

Commit 8f8f898

Browse files
authored
feat(chart): add ChoroplethMap (#610)
* fix(chart): preserve BubbleMap view on data refresh * feat(chart): add ChoroplethMap * feat(chart): add d3-geo projection + aspect-ratio sizing to maps - Default both maps to a latitude-clamped Mercator projection (d3-geo); add `projection` prop (custom d3-geo projection or `null` for raw plotting) - Size the chart container by the projected window's aspect ratio so maps fill the frame without letterboxing; `height` still overrides - Make ChoroplethMap borderless (minimalist, matching BubbleMap); drop the now-unused MapColors.line seam colour - Add a Cloudflare network hero example (major locations in brand orange with city + IATA tooltips) as the first thing on the Maps docs page * docs(chart): address map review feedback * fix(chart): export MapProjection type
1 parent 3aa0d9a commit 8f8f898

12 files changed

Lines changed: 908 additions & 61 deletions

File tree

.changeset/choropleth-map.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cloudflare/kumo": major
3+
---
4+
5+
Add `ChoroplethMap`, a GeoJSON region choropleth chart component that joins data rows to features by `name`/`nameProperty` and shades regions with a continuous `visualMap` scale. Includes Kumo light/dark map colours, tooltip formatting, optional legend, hover/click callbacks, roam controls, docs, and demos.
6+
7+
Both `BubbleMap` and `ChoroplethMap` now apply a d3-geo `projection` (latitude-clamped Mercator by default; pass another d3-geo projection or `null` for raw plotting) and size by `aspectRatio` so the map fills its container without letterboxing. The container height now derives from the projected window's aspect ratio by default; pass an explicit `height` to opt back into a fixed pixel height.
8+
9+
Update `BubbleMap` viewport behavior to avoid resetting user pan and zoom while refreshing bubble data.
10+
11+
Breaking interaction change: `BubbleMap` now defaults `roam` to `false`, so maps no longer allow drag-to-pan or scroll-to-zoom unless consumers pass `roam={true}`.

packages/kumo-docs-astro/src/components/demos/Chart/BubbleMapDemo.tsx

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ interface Colo {
2222
}
2323

2424
const colos: Colo[] = [
25-
{ iata: "SFO", city: "San Francisco", country: "US", lat: 37.77, lon: -122.42, requests: 1200 }, // prettier-ignore
25+
{ iata: "SFO", city: "San Francisco", country: "US", lat: 37.77, lon: -122.42, requests: 1600 }, // prettier-ignore
2626
{ iata: "EWR", city: "New York", country: "US", lat: 40.71, lon: -74.0, requests: 980 }, // prettier-ignore
2727
{ iata: "GRU", city: "São Paulo", country: "BR", lat: -23.55, lon: -46.63, requests: 540 }, // prettier-ignore
2828
{ iata: "LHR", city: "London", country: "GB", lat: 51.5, lon: -0.12, requests: 1500 }, // prettier-ignore
@@ -37,6 +37,84 @@ const colos: Colo[] = [
3737
const fmt = (n: number) =>
3838
`${n > 1000 ? `${(n / 1000).toLocaleString()}k` : n.toString()} requests`;
3939

40+
/** A Cloudflare network location (city, IATA code + coordinates). */
41+
interface CfLocation {
42+
city: string;
43+
iata: string;
44+
lat: number;
45+
lon: number;
46+
}
47+
48+
/** Major Cloudflare data-center cities across the global network. */
49+
const cloudflareLocations: CfLocation[] = [
50+
{ city: "San Francisco", iata: "SFO", lat: 37.77, lon: -122.42 },
51+
{ city: "Los Angeles", iata: "LAX", lat: 34.05, lon: -118.24 },
52+
{ city: "Seattle", iata: "SEA", lat: 47.61, lon: -122.33 },
53+
{ city: "Dallas", iata: "DFW", lat: 32.78, lon: -96.8 },
54+
{ city: "Chicago", iata: "ORD", lat: 41.88, lon: -87.63 },
55+
{ city: "Atlanta", iata: "ATL", lat: 33.75, lon: -84.39 },
56+
{ city: "Miami", iata: "MIA", lat: 25.76, lon: -80.19 },
57+
{ city: "Ashburn", iata: "IAD", lat: 39.04, lon: -77.49 },
58+
{ city: "New York", iata: "EWR", lat: 40.71, lon: -74.01 },
59+
{ city: "Toronto", iata: "YYZ", lat: 43.65, lon: -79.38 },
60+
{ city: "Mexico City", iata: "MEX", lat: 19.43, lon: -99.13 },
61+
{ city: "São Paulo", iata: "GRU", lat: -23.55, lon: -46.63 },
62+
{ city: "Buenos Aires", iata: "EZE", lat: -34.6, lon: -58.38 },
63+
{ city: "London", iata: "LHR", lat: 51.51, lon: -0.13 },
64+
{ city: "Amsterdam", iata: "AMS", lat: 52.37, lon: 4.9 },
65+
{ city: "Paris", iata: "CDG", lat: 48.86, lon: 2.35 },
66+
{ city: "Frankfurt", iata: "FRA", lat: 50.11, lon: 8.68 },
67+
{ city: "Madrid", iata: "MAD", lat: 40.42, lon: -3.7 },
68+
{ city: "Milan", iata: "MXP", lat: 45.46, lon: 9.19 },
69+
{ city: "Stockholm", iata: "ARN", lat: 59.33, lon: 18.06 },
70+
{ city: "Istanbul", iata: "IST", lat: 41.01, lon: 28.98 },
71+
{ city: "Dubai", iata: "DXB", lat: 25.2, lon: 55.27 },
72+
{ city: "Cairo", iata: "CAI", lat: 30.04, lon: 31.24 },
73+
{ city: "Lagos", iata: "LOS", lat: 6.52, lon: 3.38 },
74+
{ city: "Johannesburg", iata: "JNB", lat: -26.2, lon: 28.05 },
75+
{ city: "Mumbai", iata: "BOM", lat: 19.08, lon: 72.88 },
76+
{ city: "Delhi", iata: "DEL", lat: 28.61, lon: 77.21 },
77+
{ city: "Singapore", iata: "SIN", lat: 1.35, lon: 103.82 },
78+
{ city: "Jakarta", iata: "CGK", lat: -6.21, lon: 106.85 },
79+
{ city: "Hong Kong", iata: "HKG", lat: 22.32, lon: 114.17 },
80+
{ city: "Tokyo", iata: "NRT", lat: 35.68, lon: 139.69 },
81+
{ city: "Seoul", iata: "ICN", lat: 37.57, lon: 126.98 },
82+
{ city: "Sydney", iata: "SYD", lat: -33.87, lon: 151.21 },
83+
];
84+
85+
/**
86+
* Major Cloudflare network locations plotted as uniform markers in Cloudflare
87+
* orange. Pass a constant `bubbleColor` (or `(row) => color`) to override the
88+
* default chart blue, and equal `minRadius`/`maxRadius` for uniform pins. The
89+
* tooltip shows each city with its IATA code.
90+
*/
91+
export function BubbleMapCloudflareLocationsDemo({
92+
geoJson,
93+
}: BubbleMapDemoProps) {
94+
const isDarkMode = useIsDarkMode();
95+
96+
if (!geoJson) return null;
97+
98+
return (
99+
<BubbleMap<CfLocation>
100+
echarts={echarts}
101+
geoJson={geoJson}
102+
data={cloudflareLocations}
103+
lng="lon"
104+
lat="lat"
105+
name="city"
106+
value={() => 1}
107+
bubbleColor="#F6821F"
108+
minRadius={8}
109+
maxRadius={8}
110+
tooltipFormatter={(row) =>
111+
`<span style="font-size:12px"><strong>${row.city}</strong><span style="color:var(--text-color-kumo-subtle);margin-left:8px">${row.iata}</span></span>`
112+
}
113+
isDarkMode={isDarkMode}
114+
/>
115+
);
116+
}
117+
40118
/**
41119
* Bubble map — raw rows + dimension accessors, radius scaling, a tooltip
42120
*/
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ChoroplethMap, type MapGeoJson } from "@cloudflare/kumo";
2+
import * as echarts from "echarts/core";
3+
import { MapChart } from "echarts/charts";
4+
import { VisualMapComponent, TooltipComponent } from "echarts/components";
5+
import { CanvasRenderer } from "echarts/renderers";
6+
import { useIsDarkMode } from "~/lib/use-is-dark-mode";
7+
8+
echarts.use([MapChart, VisualMapComponent, TooltipComponent, CanvasRenderer]);
9+
10+
interface ChoroplethMapDemoProps {
11+
geoJson: MapGeoJson | null;
12+
}
13+
14+
/** A country-level traffic row — joined to GeoJSON features by `country`. */
15+
interface CountryTraffic {
16+
/** Must match the GeoJSON feature's `name` property. */
17+
country: string;
18+
requests: number;
19+
}
20+
21+
// Country-level traffic. The colour scale is continuous; if your data is
22+
// skewed, shape it in the parent (e.g. a transformed value) before passing it in.
23+
const countries: CountryTraffic[] = [
24+
{ country: "United States of America", requests: 4200 },
25+
{ country: "Germany", requests: 3100 },
26+
{ country: "United Kingdom", requests: 2800 },
27+
{ country: "Japan", requests: 2500 },
28+
{ country: "France", requests: 2200 },
29+
{ country: "Brazil", requests: 1700 },
30+
{ country: "India", requests: 1500 },
31+
{ country: "Canada", requests: 1300 },
32+
{ country: "Australia", requests: 1100 },
33+
{ country: "Spain", requests: 900 },
34+
{ country: "Netherlands", requests: 700 },
35+
{ country: "Mexico", requests: 600 },
36+
{ country: "Argentina", requests: 420 },
37+
{ country: "Nigeria", requests: 300 },
38+
{ country: "South Africa", requests: 220 },
39+
];
40+
41+
const fmt = (n: number) =>
42+
`${n >= 1000 ? `${(n / 1000).toLocaleString()}k` : n.toString()} requests`;
43+
44+
/**
45+
* Choropleth map — regions shaded by value, joined to GeoJSON features by name.
46+
* Uses the default continuous linear colour scale.
47+
*/
48+
export function ChoroplethMapBasicDemo({ geoJson }: ChoroplethMapDemoProps) {
49+
const isDarkMode = useIsDarkMode();
50+
51+
if (!geoJson) return null;
52+
53+
return (
54+
<ChoroplethMap<CountryTraffic>
55+
echarts={echarts}
56+
geoJson={geoJson}
57+
data={countries}
58+
name="country"
59+
value="requests"
60+
valueFormat={fmt}
61+
isDarkMode={isDarkMode}
62+
/>
63+
);
64+
}

packages/kumo-docs-astro/src/pages/charts/maps.astro

Lines changed: 78 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import ComponentSection from "../../components/docs/ComponentSection.astro";
55
import ComponentExample from "../../components/docs/ComponentExample.astro";
66
import CodeBlock from "../../components/docs/CodeBlock.astro";
77
import PropsTable from "../../components/docs/PropsTable.astro";
8-
import { BubbleMapBasicDemo } from "~/components/demos/Chart/BubbleMapDemo";
8+
import {
9+
BubbleMapBasicDemo,
10+
BubbleMapCloudflareLocationsDemo,
11+
} from "~/components/demos/Chart/BubbleMapDemo";
12+
import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo";
913
import type { MapGeoJson } from "@cloudflare/kumo";
1014
1115
const WORLD_GEO_JSON_URL =
@@ -34,65 +38,99 @@ try {
3438
sourceFile="components/chart"
3539
>
3640
<ComponentSection>
37-
<ComponentExample code={`<BubbleMap
41+
<ComponentExample code={`const cloudflareLocations = [
42+
{ city: "San Francisco", iata: "SFO", lat: 37.77, lon: -122.42 },
43+
{ city: "London", iata: "LHR", lat: 51.51, lon: -0.13 },
44+
{ city: "Singapore", iata: "SIN", lat: 1.35, lon: 103.82 },
45+
// ...more locations
46+
];
47+
48+
<BubbleMap
3849
echarts={echarts}
3950
geoJson={geoJson}
40-
data={colos}
51+
data={cloudflareLocations}
4152
lng="lon"
4253
lat="lat"
4354
name="city"
44-
value="requests"
55+
value={() => 1}
56+
bubbleColor="#F6821F"
57+
minRadius={8}
58+
maxRadius={8}
59+
tooltipFormatter={(row) =>
60+
"<strong>" + row.city + "</strong> " + row.iata
61+
}
4562
/>`}>
46-
<BubbleMapBasicDemo geoJson={geoJson} client:visible />
63+
<BubbleMapCloudflareLocationsDemo geoJson={geoJson} client:visible />
4764
</ComponentExample>
4865
</ComponentSection>
4966

5067
<ComponentSection>
5168
<Heading level={2}>Installation</Heading>
5269
<p class="mb-4 text-kumo-subtle">
53-
<code class="text-kumo-default">BubbleMap</code> requires <code class="text-kumo-default">echarts</code> as a peer dependency. Consumers provide the GeoJSON feature collection; the component does not fetch map data or use map tiles.
70+
<code class="text-kumo-default">BubbleMap</code> and <code class="text-kumo-default">ChoroplethMap</code> require <code class="text-kumo-default">echarts</code> as a peer dependency. Consumers provide the GeoJSON feature collection; map components do not fetch map data or use map tiles.
5471
</p>
5572
<CodeBlock code={`npm install echarts`} lang="bash" />
5673

5774
<Heading level={3} class="mb-2 mt-6 text-lg">Barrel</Heading>
58-
<CodeBlock code={`import { BubbleMap } from "@cloudflare/kumo";`} lang="tsx" />
75+
<CodeBlock code={`import { BubbleMap, ChoroplethMap } from "@cloudflare/kumo";`} lang="tsx" />
5976
<Heading level={3} class="mb-2 mt-4 text-lg">Granular</Heading>
6077
<CodeBlock
61-
code={`import { BubbleMap } from "@cloudflare/kumo/components/chart";`}
78+
code={`import { BubbleMap, ChoroplethMap } from "@cloudflare/kumo/components/chart";`}
6279
lang="tsx"
6380
/>
6481
</ComponentSection>
6582

6683
<ComponentSection>
6784
<Heading level={2}>Usage</Heading>
6885
<CodeBlock
69-
code={`import { BubbleMap, type MapGeoJson } from "@cloudflare/kumo";
86+
code={`import { BubbleMap, ChoroplethMap, type MapGeoJson } from "@cloudflare/kumo";
7087
import * as echarts from "echarts/core";
7188
import { MapChart, ScatterChart } from "echarts/charts";
72-
import { TooltipComponent } from "echarts/components";
89+
import { TooltipComponent, VisualMapComponent } from "echarts/components";
7390
import { CanvasRenderer } from "echarts/renderers";
7491
75-
echarts.use([MapChart, ScatterChart, TooltipComponent, CanvasRenderer]);
92+
echarts.use([
93+
MapChart,
94+
ScatterChart,
95+
TooltipComponent,
96+
VisualMapComponent,
97+
CanvasRenderer,
98+
]);
7699
77-
// Load GeoJSON in your app and pass it to BubbleMap.
100+
// Load GeoJSON in your app and pass it to map components.
78101
const geoJson = world as MapGeoJson;
79102
80103
const colos = [
81104
{ iata: "SFO", city: "San Francisco", lat: 37.77, lon: -122.42, requests: 1200 },
82105
{ iata: "LHR", city: "London", lat: 51.5, lon: -0.12, requests: 1500 },
83106
];
84107
108+
const countries = [
109+
{ country: "United States of America", requests: 4200 },
110+
{ country: "Germany", requests: 3100 },
111+
];
112+
85113
export default function Example() {
86114
return (
87-
<BubbleMap
88-
echarts={echarts}
89-
geoJson={geoJson}
90-
data={colos}
91-
lng="lon"
92-
lat="lat"
93-
name="city"
94-
value="requests"
95-
/>
115+
<>
116+
<BubbleMap
117+
echarts={echarts}
118+
geoJson={geoJson}
119+
data={colos}
120+
lng="lon"
121+
lat="lat"
122+
name="city"
123+
value="requests"
124+
/>
125+
126+
<ChoroplethMap
127+
echarts={echarts}
128+
geoJson={geoJson}
129+
data={countries}
130+
name="country"
131+
value="requests"
132+
/>
133+
</>
96134
);
97135
}`}
98136
lang="tsx"
@@ -122,6 +160,22 @@ export default function Example() {
122160
</ComponentExample>
123161
</div>
124162

163+
<div>
164+
<Heading level={3}>Choropleth Map</Heading>
165+
<p class="mb-4 text-kumo-subtle">
166+
Shade regions by value. Data rows are joined to GeoJSON features by <code class="text-kumo-default">name</code> (matched against the feature's <code class="text-kumo-default">nameProperty</code>, default <code class="text-kumo-default">"name"</code>).
167+
</p>
168+
<ComponentExample code={`<ChoroplethMap
169+
echarts={echarts}
170+
geoJson={geoJson}
171+
data={countries}
172+
name="country"
173+
value="requests"
174+
/>`}>
175+
<ChoroplethMapBasicDemo geoJson={geoJson} client:visible />
176+
</ComponentExample>
177+
</div>
178+
125179
<div>
126180
<Heading level={3}>Custom Tooltips</Heading>
127181
<p class="mb-4 text-kumo-subtle">
@@ -148,6 +202,9 @@ export default function Example() {
148202

149203
<ComponentSection>
150204
<Heading level={2}>API Reference</Heading>
205+
<Heading level={3} class="mb-2 mt-4 text-lg">BubbleMap</Heading>
151206
<PropsTable component="BubbleMap" />
207+
<Heading level={3} class="mb-2 mt-6 text-lg">ChoroplethMap</Heading>
208+
<PropsTable component="ChoroplethMap" />
152209
</ComponentSection>
153210
</DocLayout>

packages/kumo-docs-astro/src/pages/tests/map.astro

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BubbleMapBasicDemo,
55
BubbleMapManyPointsDemo,
66
} from "../../components/demos/Chart/BubbleMapDemo";
7+
import { ChoroplethMapBasicDemo } from "../../components/demos/Chart/ChoroplethMapDemo";
78
import type { MapGeoJson } from "@cloudflare/kumo";
89
910
const WORLD_GEO_JSON_URL =
@@ -36,29 +37,22 @@ try {
3637
</p>
3738
</div>
3839

39-
<section class="space-y-4">
40-
<div>
41-
<h2 class="text-xl font-semibold text-kumo-default">BubbleMapBasicDemo</h2>
42-
<p class="text-kumo-subtle">
43-
Bubble map using raw colo rows, dimension accessors, radius scaling,
44-
and formatted request values.
45-
</p>
46-
</div>
40+
<section>
4741
<div class="p-4 rounded-lg border border-kumo-hairline bg-kumo-base">
4842
<BubbleMapBasicDemo geoJson={geoJson} client:visible />
4943
</div>
5044
</section>
5145

52-
<section class="space-y-4">
53-
<div>
54-
<h2 class="text-xl font-semibold text-kumo-default">BubbleMapManyPointsDemo</h2>
55-
<p class="text-kumo-subtle">
56-
Larger point dataset with request values across global locations.
57-
</p>
58-
</div>
46+
<section>
5947
<div class="p-4 rounded-lg border border-kumo-hairline bg-kumo-base">
6048
<BubbleMapManyPointsDemo geoJson={geoJson} client:visible />
6149
</div>
6250
</section>
51+
52+
<section >
53+
<div class="p-4 rounded-lg border border-kumo-hairline bg-kumo-base">
54+
<ChoroplethMapBasicDemo geoJson={geoJson} client:visible />
55+
</div>
56+
</section>
6357
</div>
6458
</BaseLayout>

packages/kumo/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@
473473
"@shikijs/langs": "^4.0.0",
474474
"@shikijs/themes": "^4.0.0",
475475
"cnfast": "^0.0.8",
476+
"d3-geo": "^3.1.1",
476477
"motion": "^12.34.1",
477478
"react-day-picker": "^9.13.2",
478479
"shiki": "^4.0.0"
@@ -482,6 +483,7 @@
482483
"@tailwindcss/vite": "catalog:",
483484
"@testing-library/react": "16.3.1",
484485
"@testing-library/user-event": "14.6.1",
486+
"@types/d3-geo": "^3.1.0",
485487
"@types/glob": "9.0.0",
486488
"@types/node": "catalog:",
487489
"@types/react": "catalog:",

0 commit comments

Comments
 (0)