From b0e13c6d76d91903ad5055a1cf7cbb95ea4fb920 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Tue, 1 Sep 2026 15:55:54 -0400 Subject: [PATCH 01/15] feat(chart): add interactive globe map --- .changeset/warm-globes-drift.md | 5 + .../components/demos/Chart/GlobeMapDemo.tsx | 131 ++++ .../src/pages/charts/maps.astro | 47 +- .../kumo/src/components/chart/Maps.test.tsx | 58 +- packages/kumo/src/components/chart/Maps.tsx | 561 +++++++++++++++++- packages/kumo/src/components/chart/index.ts | 3 + packages/kumo/src/index.ts | 3 + 7 files changed, 802 insertions(+), 6 deletions(-) create mode 100644 .changeset/warm-globes-drift.md create mode 100644 packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md new file mode 100644 index 0000000000..481a0f8312 --- /dev/null +++ b/.changeset/warm-globes-drift.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add `GlobeMap`, an SVG orthographic map with choropleth regions or clipped geographic markers, solid and dotted land styles, optional geographic guides, drag-to-rotate interaction, Kumo-themed tooltips, and no WebGL or ECharts requirement. diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx new file mode 100644 index 0000000000..1d6a21be12 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -0,0 +1,131 @@ +import { + GlobeMap, + type GlobeMapMarker, + type MapGeoJson, +} from "@cloudflare/kumo"; +import { useIsDarkMode } from "~/lib/use-is-dark-mode"; + +interface GlobeMapDemoProps { + geoJson: MapGeoJson | null; +} + +interface CountryTraffic { + country: string; + requests: number; +} + +const countries: CountryTraffic[] = [ + { country: "United States of America", requests: 4200 }, + { country: "Germany", requests: 3100 }, + { country: "United Kingdom", requests: 2800 }, + { country: "Japan", requests: 2500 }, + { country: "France", requests: 2200 }, + { country: "Brazil", requests: 1700 }, + { country: "India", requests: 1500 }, + { country: "Canada", requests: 1300 }, + { country: "Australia", requests: 1100 }, + { country: "Spain", requests: 900 }, + { country: "Netherlands", requests: 700 }, + { country: "Mexico", requests: 600 }, + { country: "Argentina", requests: 420 }, + { country: "Nigeria", requests: 300 }, + { country: "South Africa", requests: 220 }, +]; + +const formatRequests = (value: number) => `${value.toLocaleString()} requests`; + +const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ + { + name: "SFO", + description: "San Francisco", + latitude: 37.77, + longitude: -122.42, + }, + { + name: "LAX", + description: "Los Angeles", + latitude: 34.05, + longitude: -118.24, + }, + { name: "SEA", description: "Seattle", latitude: 47.61, longitude: -122.33 }, + { name: "DFW", description: "Dallas", latitude: 32.78, longitude: -96.8 }, + { name: "ORD", description: "Chicago", latitude: 41.88, longitude: -87.63 }, + { name: "IAD", description: "Ashburn", latitude: 39.04, longitude: -77.49 }, + { name: "EWR", description: "New York", latitude: 40.71, longitude: -74.01 }, + { + name: "GRU", + description: "São Paulo", + latitude: -23.55, + longitude: -46.63, + }, + { + name: "EZE", + description: "Buenos Aires", + latitude: -34.6, + longitude: -58.38, + }, + { name: "LHR", description: "London", latitude: 51.51, longitude: -0.13 }, + { name: "AMS", description: "Amsterdam", latitude: 52.37, longitude: 4.9 }, + { name: "CDG", description: "Paris", latitude: 48.86, longitude: 2.35 }, + { name: "FRA", description: "Frankfurt", latitude: 50.11, longitude: 8.68 }, + { name: "MAD", description: "Madrid", latitude: 40.42, longitude: -3.7 }, + { name: "DXB", description: "Dubai", latitude: 25.2, longitude: 55.27 }, + { name: "LOS", description: "Lagos", latitude: 6.52, longitude: 3.38 }, + { + name: "JNB", + description: "Johannesburg", + latitude: -26.2, + longitude: 28.05, + }, + { name: "BOM", description: "Mumbai", latitude: 19.08, longitude: 72.88 }, + { name: "SIN", description: "Singapore", latitude: 1.35, longitude: 103.82 }, + { name: "HKG", description: "Hong Kong", latitude: 22.32, longitude: 114.17 }, + { name: "NRT", description: "Tokyo", latitude: 35.68, longitude: 139.69 }, + { name: "ICN", description: "Seoul", latitude: 37.57, longitude: 126.98 }, + { name: "SYD", description: "Sydney", latitude: -33.87, longitude: 151.21 }, +]; + +/** Illustrative Cloudflare network locations on a draggable SVG globe. */ +export function GlobeMapAvailabilityZonesDemo({ geoJson }: GlobeMapDemoProps) { + const isDarkMode = useIsDarkMode(); + + if (!geoJson) return null; + + return ( +
+ +
+ ); +} + +/** An SVG globe with drag-to-rotate interaction and no WebGL dependency. */ +export function GlobeMapBasicDemo({ geoJson }: GlobeMapDemoProps) { + const isDarkMode = useIsDarkMode(); + + if (!geoJson) return null; + + return ( +
+ + geoJson={geoJson} + data={countries} + name="country" + value="requests" + valueFormat={formatRequests} + isDarkMode={isDarkMode} + /> +
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index ffff8c77d9..d047758f14 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -10,6 +10,10 @@ import { BubbleMapCloudflareLocationsDemo, } from "~/components/demos/Chart/BubbleMapDemo"; import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo"; +import { + GlobeMapAvailabilityZonesDemo, + GlobeMapBasicDemo, +} from "~/components/demos/Chart/GlobeMapDemo"; import type { MapGeoJson } from "@cloudflare/kumo"; const WORLD_GEO_JSON_URL = @@ -67,15 +71,15 @@ try { Installation

- BubbleMap and ChoroplethMap require echarts as a peer dependency. Consumers provide the GeoJSON feature collection; map components do not fetch map data or use map tiles. + BubbleMap and ChoroplethMap require echarts as a peer dependency. GlobeMap renders directly to SVG and does not use WebGL or require ECharts. Consumers provide the GeoJSON feature collection; map components do not fetch map data or use map tiles.

Barrel - + Granular
@@ -176,6 +180,41 @@ export default function Example() { +
+ Cloudflare Availability Locations +

+ Plot geographic markers without WebGL. Dotted neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe to reveal points on the hidden hemisphere. +

+ `}> + + +
+ +
+ Globe Map +

+ Render the same GeoJSON as a clipped orthographic SVG globe. Drag to rotate it; land on the hidden hemisphere is clipped without WebGL. +

+ `}> + + +
+
Custom Tooltips

@@ -206,5 +245,7 @@ export default function Example() { ChoroplethMap + GlobeMap + diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index 52ec321e2f..4eaeca8563 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -1,7 +1,7 @@ import { createRef } from "react"; import { render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { BubbleMap, type MapGeoJson } from "./Maps"; +import { BubbleMap, GlobeMap, type MapGeoJson } from "./Maps"; const createMockChart = () => ({ setOption: vi.fn(), @@ -33,6 +33,62 @@ const data = [ { city: "London", lat: 51.5, lon: -0.12, requests: 20 }, ]; +describe("GlobeMap", () => { + it("renders an accessible SVG globe without ECharts", () => { + const globeGeoJson: MapGeoJson = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "visible-region", + properties: { name: "Visible region" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-20, -10], + [20, -10], + [20, 10], + [-20, 10], + [-20, -10], + ], + ], + }, + }, + ], + }; + + const { getByRole } = render( + , + ); + + const globe = getByRole("img", { name: "Traffic globe" }); + expect(globe.tagName).toBe("svg"); + expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); + expect( + globe.querySelectorAll('[data-land-style="dotted"] circle').length, + ).toBeGreaterThan(0); + expect(globe.querySelector("pattern")).toBeNull(); + expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); + }); +}); + describe("BubbleMap", () => { it("reuses the generated map name across remounts for the same GeoJSON", () => { const mockEcharts = createMockEcharts(); diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index 130f27f76d..73043de543 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -1,5 +1,10 @@ import type * as echarts from "echarts/core"; -import type { ForwardedRef, ReactElement, RefAttributes } from "react"; +import type { + ForwardedRef, + PointerEvent as ReactPointerEvent, + ReactElement, + RefAttributes, +} from "react"; import { forwardRef, useCallback, @@ -7,8 +12,19 @@ import { useLayoutEffect, useMemo, useRef, + useState, } from "react"; -import { geoMercator } from "d3-geo"; +import { + geoArea, + geoContains, + geoDistance, + geoGraticule, + geoMercator, + geoOrthographic, + geoPath, + type GeoPermissibleObjects, +} from "d3-geo"; +import { cn } from "../../utils/cn"; import { Chart, type ChartEvents, type KumoChartOption } from "./EChart"; import { ChartPalette } from "./Color"; import { defaultValueFormat, escapeHtml } from "./tooltip-utils"; @@ -997,6 +1013,547 @@ export const ChoroplethMap = forwardRef(ChoroplethMapRoot) as (( ChoroplethMap.displayName = "ChoroplethMap"; +export interface GlobeMapMarker { + /** Longitude in decimal degrees. */ + longitude: number; + /** Latitude in decimal degrees. */ + latitude: number; + /** Primary tooltip label. */ + name: string; + /** Optional secondary tooltip text. */ + description?: string; + /** Marker fill. Overrides `markerColor`. */ + color?: string; + /** Marker radius in view-box pixels. Overrides `markerRadius`. */ + radius?: number; +} + +export interface GlobeMapProps { + /** GeoJSON `FeatureCollection` rendered on the globe. */ + geoJson: MapGeoJson; + /** Raw data rows joined to GeoJSON features. Optional for marker-only globes. */ + data?: T[]; + /** Region-key accessor (key of `T` or `(row) => string`). */ + name?: MapAccessor; + /** Value accessor — drives the region's fill colour. */ + value?: MapAccessor; + /** GeoJSON feature property to join on. Default: `"name"`. */ + nameProperty?: string; + /** Sequential colour ramp (low → high). */ + colorRange?: string[]; + /** Lower bound of the colour scale. Default: data minimum. */ + min?: number; + /** Upper bound of the colour scale. Default: data maximum. */ + max?: number; + /** Fill for regions with no matching data row. */ + noDataColor?: string; + /** Render no-data land as a solid fill or a field of dots. Default: `"solid"`. */ + landStyle?: "solid" | "dotted"; + /** Spacing between dot centers in the dotted land style. Default: `10`. */ + landDotSpacing?: number; + /** Fill behind the land and graticule. Default: `"transparent"`. */ + oceanColor?: string; + /** Geographic points drawn above the land. Back-facing points are clipped. */ + markers?: GlobeMapMarker[]; + /** Default marker fill. Defaults to the Kumo chart blue. */ + markerColor?: string; + /** Default marker radius in view-box pixels. Default: `7`. */ + markerRadius?: number; + /** Called when a visible marker is clicked. */ + onMarkerClick?: (marker: GlobeMapMarker) => void; + /** Initial globe rotation as `[longitude, latitude, roll]`. */ + rotation?: [number, number, number]; + /** Allow pointer dragging to rotate the globe. Default: `true`. */ + draggable?: boolean; + /** Draw latitude and longitude guides. Default: `false`. */ + showGraticule?: boolean; + /** Show the Kumo-styled region tooltip. Default: `true`. */ + showTooltip?: boolean; + /** Format the value in the default tooltip. */ + valueFormat?: (value: number) => string; + /** Called as the pointer enters/leaves a region with data. */ + onRegionHover?: (row: T | undefined) => void; + /** Called when a region with data is clicked. */ + onRegionClick?: (row: T) => void; + /** Called after pointer dragging changes the globe rotation. */ + onRotationChange?: (rotation: [number, number, number]) => void; + /** Accessible label for the visualization. Default: `"Interactive globe map"`. */ + "aria-label"?: string; + /** Fixed component height. Otherwise the globe uses a square aspect ratio. */ + height?: number; + className?: string; + isDarkMode?: boolean; +} + +interface GlobeTooltip { + name: string; + detail: string; + x: number; + y: number; +} + +const GLOBE_VIEWBOX_SIZE = 640; +const GLOBE_PADDING = 18; +const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; + +/** + * d3-geo uses spherical winding (small exterior rings run clockwise), while + * RFC 7946 GeoJSON commonly uses counter-clockwise exterior rings. In that + * case d3 interprets a country as the rest of the globe, painting over the + * ocean. Rewind polygon rings only when d3 reports the feature as larger than + * a hemisphere. + */ +function normalizeGlobeFeature( + feature: MapGeoJson["features"][number], +): GeoPermissibleObjects { + const permissible = feature as unknown as GeoPermissibleObjects; + const geometry = feature.geometry; + if (!geometry || typeof geometry !== "object") return permissible; + + const typedGeometry = geometry as Record; + const coordinates = typedGeometry.coordinates; + if (!Array.isArray(coordinates)) return permissible; + + const normalizePolygon = (polygon: unknown): unknown => { + if (!Array.isArray(polygon)) return polygon; + const polygonObject = { + type: "Polygon", + coordinates: polygon, + } as unknown as GeoPermissibleObjects; + if (geoArea(polygonObject) <= 2 * Math.PI) return polygon; + return polygon.map((ring) => + Array.isArray(ring) ? [...ring].reverse() : ring, + ); + }; + + // Check every MultiPolygon part independently. Rewinding the entire feature + // based on its aggregate area can invert otherwise-correct islands and create + // long antimeridian wedges (most visibly around Russia and Alaska). + const normalizedCoordinates = + typedGeometry.type === "Polygon" + ? normalizePolygon(coordinates) + : typedGeometry.type === "MultiPolygon" + ? coordinates.map(normalizePolygon) + : coordinates; + + return { + ...feature, + geometry: { ...typedGeometry, coordinates: normalizedCoordinates }, + } as unknown as GeoPermissibleObjects; +} + +/** + * GlobeMap — an SVG orthographic globe with choropleth regions, geographic + * markers, or both. Unlike the ECharts map components, it uses d3-geo's stream + * clipping so land and markers on the back hemisphere are hidden correctly. + * Rendering is SVG-only and does not use WebGL. + */ +export function GlobeMap({ + geoJson, + data, + name, + value, + nameProperty = "name", + colorRange, + min, + max, + noDataColor, + landStyle = "solid", + landDotSpacing = 10, + oceanColor = "transparent", + markers = [], + markerColor, + markerRadius = 7, + onMarkerClick, + rotation = [-10, -20, 0], + draggable = true, + showGraticule = false, + showTooltip = true, + valueFormat = defaultValueFormat, + onRegionHover, + onRegionClick, + onRotationChange, + "aria-label": ariaLabel = "Interactive globe map", + height, + className, + isDarkMode, +}: GlobeMapProps) { + const [currentRotation, setCurrentRotation] = useState(rotation); + const [tooltip, setTooltip] = useState(null); + const didDragRef = useRef(false); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + rotation: [number, number, number]; + } | null>(null); + + const rotationLongitude = rotation[0]; + const rotationLatitude = rotation[1]; + const rotationRoll = rotation[2]; + useEffect(() => { + setCurrentRotation([rotationLongitude, rotationLatitude, rotationRoll]); + }, [rotationLongitude, rotationLatitude, rotationRoll]); + + const palette = useMemo( + () => ChartPalette.mapColors(isDarkMode), + [isDarkMode], + ); + const colors = colorRange ?? palette.scale; + const noData = noDataColor ?? palette.area; + const resolvedMarkerColor = markerColor ?? palette.bubble; + + const rowsByName = useMemo(() => { + const rows = new Map(); + if (!data || name === undefined || value === undefined) return rows; + for (const row of data) { + rows.set(resolve(row, name), { row, value: resolve(row, value) }); + } + return rows; + }, [data, name, value]); + + const values = useMemo( + () => Array.from(rowsByName.values(), (entry) => entry.value), + [rowsByName], + ); + const dataMin = values.length ? Math.min(...values) : 0; + const dataMax = values.length ? Math.max(...values) : 1; + const resolvedMin = min ?? dataMin; + const resolvedMax = max ?? (dataMax > dataMin ? dataMax : dataMin + 1); + + const globeFeatures = useMemo( + () => + geoJson.features.map((feature) => ({ + feature, + geometry: normalizeGlobeFeature(feature), + })), + [geoJson], + ); + + const projection = useMemo( + () => + geoOrthographic() + .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) + .scale(GLOBE_RADIUS) + .rotate(currentRotation) + // Clip just inside the mathematical horizon. d3's default is 90° plus + // epsilon, which can leak resampled fragments from the hidden hemisphere + // into the visible globe for polygons near the antimeridian. + .clipAngle(89.999) + .precision(0.25), + [currentRotation], + ); + const path = useMemo(() => geoPath(projection), [projection]); + const spherePath = path({ type: "Sphere" }) ?? undefined; + const graticulePath = path(geoGraticule().step([15, 10])()) ?? undefined; + const dottedLandCoordinates = useMemo(() => { + if (landStyle !== "dotted" || landDotSpacing <= 0) return []; + + const coordinates: Array<[number, number]> = []; + const angularStep = (landDotSpacing / GLOBE_RADIUS) * (180 / Math.PI); + for ( + let latitude = -90 + angularStep / 2; + latitude < 90; + latitude += angularStep + ) { + const longitudeStep = + angularStep / Math.max(Math.cos((latitude * Math.PI) / 180), 0.15); + for ( + let longitude = -180 + longitudeStep / 2; + longitude < 180; + longitude += longitudeStep + ) { + const coordinate: [number, number] = [longitude, latitude]; + if ( + globeFeatures.some(({ geometry }) => + geoContains(geometry, coordinate), + ) + ) { + coordinates.push(coordinate); + } + } + } + return coordinates; + }, [globeFeatures, landDotSpacing, landStyle]); + const dottedLandPoints = useMemo(() => { + const center = projection.invert?.([ + GLOBE_VIEWBOX_SIZE / 2, + GLOBE_VIEWBOX_SIZE / 2, + ]); + if (!center) return []; + + const horizonInset = landDotSpacing / 2 / GLOBE_RADIUS; + return dottedLandCoordinates.flatMap<[number, number]>((coordinate) => { + if (geoDistance(center, coordinate) >= Math.PI / 2 - horizonInset) { + return []; + } + const point = projection(coordinate); + return point ? [point] : []; + }); + }, [dottedLandCoordinates, landDotSpacing, projection]); + + const fillForValue = useCallback( + (regionValue: number) => { + if (colors.length === 0) return noData; + const range = resolvedMax - resolvedMin; + const position = range > 0 ? (regionValue - resolvedMin) / range : 0; + const index = Math.round( + Math.max(0, Math.min(1, position)) * (colors.length - 1), + ); + return colors[index]; + }, + [colors, noData, resolvedMax, resolvedMin], + ); + + const moveTooltip = useCallback( + ( + event: ReactPointerEvent, + tooltipName: string, + detail: string, + ) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: tooltipName, + detail, + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + }, + [showTooltip], + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!draggable) return; + event.currentTarget.setPointerCapture(event.pointerId); + didDragRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + rotation: currentRotation, + }; + setTooltip(null); + }, + [currentRotation, draggable], + ); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; + const next: [number, number, number] = [ + drag.rotation[0] + deltaX * 0.3, + Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), + drag.rotation[2], + ]; + setCurrentRotation(next); + onRotationChange?.(next); + }, + [onRotationChange], + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }, + [], + ); + + return ( +

+ { + if (!dragRef.current) setTooltip(null); + }} + > + + {showGraticule ? ( + + ) : null} + {landStyle === "dotted" ? ( + + {dottedLandPoints.map(([x, y]) => ( + + ))} + + ) : null} + + {globeFeatures.map(({ feature, geometry }, index) => { + const property = feature.properties?.[nameProperty]; + const regionName = + typeof property === "string" || typeof property === "number" + ? String(property) + : ""; + const entry = rowsByName.get(regionName); + if (!entry && landStyle === "dotted") return null; + const featurePath = path(geometry); + if (!featurePath) return null; + return ( + { + if (!entry) return; + onRegionHover?.(entry.row); + moveTooltip(event, regionName, valueFormat(entry.value)); + }} + onPointerMove={(event) => { + if (entry && !dragRef.current) { + moveTooltip(event, regionName, valueFormat(entry.value)); + } + }} + onPointerLeave={() => { + if (!entry) return; + onRegionHover?.(undefined); + setTooltip(null); + }} + onFocus={(event) => { + if (!entry || !showTooltip) return; + onRegionHover?.(entry.row); + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: regionName, + detail: valueFormat(entry.value), + x: bounds.width / 2, + y: bounds.height / 2, + }); + }} + onBlur={() => { + if (entry) onRegionHover?.(undefined); + setTooltip(null); + }} + onClick={() => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + if (entry) onRegionClick?.(entry.row); + }} + /> + ); + })} + + + {markers.map((marker, index) => { + const markerPath = path.pointRadius(marker.radius ?? markerRadius)({ + type: "Point", + coordinates: [marker.longitude, marker.latitude], + }); + if (!markerPath) return null; + const detail = + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; + return ( + + moveTooltip(event, marker.name, detail) + } + onPointerMove={(event) => { + if (!dragRef.current) moveTooltip(event, marker.name, detail); + }} + onPointerLeave={() => setTooltip(null)} + onFocus={(event) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail, + x: bounds.width / 2, + y: bounds.height / 2, + }); + }} + onBlur={() => setTooltip(null)} + onClick={() => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + onMarkerClick?.(marker); + }} + /> + ); + })} + + + + {tooltip ? ( +
+ {tooltip.name} + {tooltip.detail} +
+ ) : null} +
+ ); +} + +GlobeMap.displayName = "GlobeMap"; + /** Register the GeoJSON with ECharts before the child Chart's setOption runs. */ function useRegisterMap( ec: typeof echarts, diff --git a/packages/kumo/src/components/chart/index.ts b/packages/kumo/src/components/chart/index.ts index 7faf795e43..241c69617d 100644 --- a/packages/kumo/src/components/chart/index.ts +++ b/packages/kumo/src/components/chart/index.ts @@ -24,12 +24,15 @@ export { export { BubbleMap, ChoroplethMap, + GlobeMap, type MapGeoJson, type MapProjection, type MapAccessor, type MapStyle, type BubbleMapProps, type ChoroplethMapProps, + type GlobeMapProps, + type GlobeMapMarker, } from "./Maps"; // Re-export color utilities for consumers who need to match chart colors outside of a chart instance export { ChartPalette } from "./Color"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 3f8ff571a2..a0fd8bb2c1 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -255,6 +255,7 @@ export { ChartLegend, BubbleMap, ChoroplethMap, + GlobeMap, type KumoChartOption, type SankeyChartProps, type SankeyNodeData, @@ -266,6 +267,8 @@ export { type MapStyle, type BubbleMapProps, type ChoroplethMapProps, + type GlobeMapProps, + type GlobeMapMarker, } from "./components/chart"; export { Autocomplete, From 94903bb997f1e2e32684ba6e847f64e11233c444 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Tue, 1 Sep 2026 16:00:34 -0400 Subject: [PATCH 02/15] perf(chart): optimize globe rendering --- .../kumo/src/components/chart/Maps.test.tsx | 5 +- packages/kumo/src/components/chart/Maps.tsx | 115 +++++++++++------- 2 files changed, 77 insertions(+), 43 deletions(-) diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index 4eaeca8563..7bcaac7e22 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -82,8 +82,9 @@ describe("GlobeMap", () => { expect(globe.tagName).toBe("svg"); expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); expect( - globe.querySelectorAll('[data-land-style="dotted"] circle').length, - ).toBeGreaterThan(0); + globe.querySelector('[data-land-style="dotted"]')?.getAttribute("d"), + ).toContain("a"); + expect(globe.querySelectorAll("circle")).toHaveLength(0); expect(globe.querySelector("pattern")).toBeNull(); expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); }); diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index 73043de543..bbcac3fb42 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -16,6 +16,7 @@ import { } from "react"; import { geoArea, + geoBounds, geoContains, geoDistance, geoGraticule, @@ -1095,6 +1096,10 @@ interface GlobeTooltip { const GLOBE_VIEWBOX_SIZE = 640; const GLOBE_PADDING = 18; const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; +const globeDotCoordinateCache = new WeakMap< + MapGeoJson, + Map> +>(); /** * d3-geo uses spherical winding (small exterior rings run clockwise), while @@ -1181,6 +1186,8 @@ export function GlobeMap({ const [currentRotation, setCurrentRotation] = useState(rotation); const [tooltip, setTooltip] = useState(null); const didDragRef = useRef(false); + const dragFrameRef = useRef(null); + const pendingRotationRef = useRef<[number, number, number] | null>(null); const dragRef = useRef<{ pointerId: number; x: number; @@ -1188,13 +1195,6 @@ export function GlobeMap({ rotation: [number, number, number]; } | null>(null); - const rotationLongitude = rotation[0]; - const rotationLatitude = rotation[1]; - const rotationRoll = rotation[2]; - useEffect(() => { - setCurrentRotation([rotationLongitude, rotationLatitude, rotationRoll]); - }, [rotationLongitude, rotationLatitude, rotationRoll]); - const palette = useMemo( () => ChartPalette.mapColors(isDarkMode), [isDarkMode], @@ -1223,10 +1223,10 @@ export function GlobeMap({ const globeFeatures = useMemo( () => - geoJson.features.map((feature) => ({ - feature, - geometry: normalizeGlobeFeature(feature), - })), + geoJson.features.map((feature) => { + const geometry = normalizeGlobeFeature(feature); + return { feature, geometry, bounds: geoBounds(geometry) }; + }), [geoJson], ); @@ -1249,6 +1249,9 @@ export function GlobeMap({ const dottedLandCoordinates = useMemo(() => { if (landStyle !== "dotted" || landDotSpacing <= 0) return []; + const cached = globeDotCoordinateCache.get(geoJson)?.get(landDotSpacing); + if (cached) return cached; + const coordinates: Array<[number, number]> = []; const angularStep = (landDotSpacing / GLOBE_RADIUS) * (180 / Math.PI); for ( @@ -1265,31 +1268,51 @@ export function GlobeMap({ ) { const coordinate: [number, number] = [longitude, latitude]; if ( - globeFeatures.some(({ geometry }) => - geoContains(geometry, coordinate), - ) + globeFeatures.some(({ geometry, bounds }) => { + const [[west, south], [east, north]] = bounds; + if (latitude < south || latitude > north) return false; + const isWithinLongitude = + west <= east + ? longitude >= west && longitude <= east + : longitude >= west || longitude <= east; + return isWithinLongitude && geoContains(geometry, coordinate); + }) ) { coordinates.push(coordinate); } } } + const cacheForGeoJson = + globeDotCoordinateCache.get(geoJson) ?? + new Map>(); + cacheForGeoJson.set(landDotSpacing, coordinates); + globeDotCoordinateCache.set(geoJson, cacheForGeoJson); return coordinates; - }, [globeFeatures, landDotSpacing, landStyle]); - const dottedLandPoints = useMemo(() => { + }, [geoJson, globeFeatures, landDotSpacing, landStyle]); + const dottedLandPath = useMemo(() => { const center = projection.invert?.([ GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2, ]); - if (!center) return []; + if (!center) return undefined; - const horizonInset = landDotSpacing / 2 / GLOBE_RADIUS; - return dottedLandCoordinates.flatMap<[number, number]>((coordinate) => { + const radius = Number((landDotSpacing * 0.28).toFixed(2)); + const horizonInset = radius / GLOBE_RADIUS; + const diameter = radius * 2; + const commands: string[] = []; + for (const coordinate of dottedLandCoordinates) { if (geoDistance(center, coordinate) >= Math.PI / 2 - horizonInset) { - return []; + continue; } const point = projection(coordinate); - return point ? [point] : []; - }); + if (!point) continue; + const x = Number(point[0].toFixed(2)); + const y = Number(point[1].toFixed(2)); + commands.push( + `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, + ); + } + return commands.join("") || undefined; }, [dottedLandCoordinates, landDotSpacing, projection]); const fillForValue = useCallback( @@ -1341,6 +1364,15 @@ export function GlobeMap({ [currentRotation, draggable], ); + const applyPendingRotation = useCallback(() => { + dragFrameRef.current = null; + const next = pendingRotationRef.current; + pendingRotationRef.current = null; + if (!next) return; + setCurrentRotation(next); + onRotationChange?.(next); + }, [onRotationChange]); + const handlePointerMove = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; @@ -1348,15 +1380,16 @@ export function GlobeMap({ const deltaX = event.clientX - drag.x; const deltaY = event.clientY - drag.y; if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; - const next: [number, number, number] = [ + pendingRotationRef.current = [ drag.rotation[0] + deltaX * 0.3, Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), drag.rotation[2], ]; - setCurrentRotation(next); - onRotationChange?.(next); + if (dragFrameRef.current === null) { + dragFrameRef.current = requestAnimationFrame(applyPendingRotation); + } }, - [onRotationChange], + [applyPendingRotation], ); const handlePointerUp = useCallback( @@ -1364,8 +1397,13 @@ export function GlobeMap({ if (dragRef.current?.pointerId !== event.pointerId) return; dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + applyPendingRotation(); }, - [], + [applyPendingRotation], ); return ( @@ -1404,17 +1442,12 @@ export function GlobeMap({ /> ) : null} {landStyle === "dotted" ? ( - - {dottedLandPoints.map(([x, y]) => ( - - ))} - + ) : null} {globeFeatures.map(({ feature, geometry }, index) => { @@ -1433,7 +1466,7 @@ export function GlobeMap({ d={featurePath} fill={entry ? fillForValue(entry.value) : noData} className={cn( - "outline-none transition-opacity", + "transition-opacity outline-none", landStyle === "solid" && "stroke-kumo-line", entry && "hover:opacity-80 focus-visible:opacity-80", )} @@ -1498,7 +1531,7 @@ export function GlobeMap({ d={markerPath} fill={marker.color ?? resolvedMarkerColor} strokeWidth={2} - className="stroke-kumo-base outline-none transition-opacity hover:opacity-80 focus-visible:opacity-80" + className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" tabIndex={0} onPointerEnter={(event) => moveTooltip(event, marker.name, detail) @@ -1541,7 +1574,7 @@ export function GlobeMap({ {tooltip ? (
{tooltip.name} From 144e693f05ad4035ab109386cd093629c570efa0 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Tue, 1 Sep 2026 16:05:04 -0400 Subject: [PATCH 03/15] feat(chart): auto-rotate globe demos --- .changeset/warm-globes-drift.md | 2 +- .../components/demos/Chart/GlobeMapDemo.tsx | 2 + .../src/pages/charts/maps.astro | 2 + packages/kumo/src/components/chart/Maps.tsx | 37 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md index 481a0f8312..90f63d06d8 100644 --- a/.changeset/warm-globes-drift.md +++ b/.changeset/warm-globes-drift.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `GlobeMap`, an SVG orthographic map with choropleth regions or clipped geographic markers, solid and dotted land styles, optional geographic guides, drag-to-rotate interaction, Kumo-themed tooltips, and no WebGL or ECharts requirement. +Add `GlobeMap`, an SVG orthographic map with choropleth regions or clipped geographic markers, solid and dotted land styles, optional geographic guides, drag and automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 1d6a21be12..c80e5f2ff4 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -103,6 +103,7 @@ export function GlobeMapAvailabilityZonesDemo({ geoJson }: GlobeMapDemoProps) { showGraticule markerColor="var(--color-kumo-brand)" markerRadius={8} + autoRotate aria-label="Cloudflare availability locations" isDarkMode={isDarkMode} /> @@ -124,6 +125,7 @@ export function GlobeMapBasicDemo({ geoJson }: GlobeMapDemoProps) { name="country" value="requests" valueFormat={formatRequests} + autoRotate isDarkMode={isDarkMode} />
diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index d047758f14..e40917aec5 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -195,6 +195,7 @@ export default function Example() { showGraticule markerColor="var(--color-kumo-brand)" markerRadius={8} + autoRotate />`}> @@ -210,6 +211,7 @@ export default function Example() { data={countries} name="country" value="requests" + autoRotate />`}> diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index bbcac3fb42..78edcd7b3a 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -1066,6 +1066,10 @@ export interface GlobeMapProps { rotation?: [number, number, number]; /** Allow pointer dragging to rotate the globe. Default: `true`. */ draggable?: boolean; + /** Continuously rotate the globe horizontally. Default: `false`. */ + autoRotate?: boolean; + /** Horizontal auto-rotation speed in degrees per second. Default: `4`. */ + autoRotateSpeed?: number; /** Draw latitude and longitude guides. Default: `false`. */ showGraticule?: boolean; /** Show the Kumo-styled region tooltip. Default: `true`. */ @@ -1172,6 +1176,8 @@ export function GlobeMap({ onMarkerClick, rotation = [-10, -20, 0], draggable = true, + autoRotate = false, + autoRotateSpeed = 4, showGraticule = false, showTooltip = true, valueFormat = defaultValueFormat, @@ -1186,6 +1192,8 @@ export function GlobeMap({ const [currentRotation, setCurrentRotation] = useState(rotation); const [tooltip, setTooltip] = useState(null); const didDragRef = useRef(false); + const autoRotateFrameRef = useRef(null); + const autoRotateTimeRef = useRef(null); const dragFrameRef = useRef(null); const pendingRotationRef = useRef<[number, number, number] | null>(null); const dragRef = useRef<{ @@ -1348,6 +1356,34 @@ export function GlobeMap({ [showTooltip], ); + const handleSvgRef = useCallback( + (node: SVGSVGElement | null) => { + if (autoRotateFrameRef.current !== null) { + cancelAnimationFrame(autoRotateFrameRef.current); + autoRotateFrameRef.current = null; + } + autoRotateTimeRef.current = null; + if (!node || !autoRotate) return; + + const rotate = (time: number) => { + const previousTime = autoRotateTimeRef.current; + autoRotateTimeRef.current = time; + if (previousTime !== null && !dragRef.current) { + const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); + setCurrentRotation((current) => [ + current[0] + autoRotateSpeed * deltaSeconds, + current[1], + current[2], + ]); + } + autoRotateFrameRef.current = requestAnimationFrame(rotate); + }; + + autoRotateFrameRef.current = requestAnimationFrame(rotate); + }, + [autoRotate, autoRotateSpeed], + ); + const handlePointerDown = useCallback( (event: ReactPointerEvent) => { if (!draggable) return; @@ -1412,6 +1448,7 @@ export function GlobeMap({ style={height === undefined ? { aspectRatio: "1" } : { height }} > Date: Tue, 1 Sep 2026 16:52:53 -0400 Subject: [PATCH 04/15] fix(chart): prevent globe animation trails --- .../src/components/demos/Chart/GlobeMapDemo.tsx | 1 + packages/kumo-docs-astro/src/pages/charts/maps.astro | 1 + packages/kumo/src/components/chart/Maps.tsx | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index c80e5f2ff4..081763763c 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -125,6 +125,7 @@ export function GlobeMapBasicDemo({ geoJson }: GlobeMapDemoProps) { name="country" value="requests" valueFormat={formatRequests} + oceanColor="var(--color-kumo-base)" autoRotate isDarkMode={isDarkMode} /> diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index e40917aec5..2649906159 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -211,6 +211,7 @@ export default function Example() { data={countries} name="country" value="requests" + oceanColor="var(--color-kumo-base)" autoRotate />`}> diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index 78edcd7b3a..c7c7d2e52b 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -1052,7 +1052,7 @@ export interface GlobeMapProps { landStyle?: "solid" | "dotted"; /** Spacing between dot centers in the dotted land style. Default: `10`. */ landDotSpacing?: number; - /** Fill behind the land and graticule. Default: `"transparent"`. */ + /** Fill behind the land and graticule. Default: the Kumo base surface. */ oceanColor?: string; /** Geographic points drawn above the land. Back-facing points are clipped. */ markers?: GlobeMapMarker[]; @@ -1169,7 +1169,7 @@ export function GlobeMap({ noDataColor, landStyle = "solid", landDotSpacing = 10, - oceanColor = "transparent", + oceanColor = "var(--color-kumo-base)", markers = [], markerColor, markerRadius = 7, From 1b346dadbd9cc762dbf4b2ec327f467933cf0038 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Wed, 2 Sep 2026 08:46:58 -0400 Subject: [PATCH 05/15] fix(chart): support keyboard globe interactions --- .../kumo/src/components/chart/Maps.test.tsx | 26 +++++++++-- packages/kumo/src/components/chart/Maps.tsx | 45 +++++++++++++------ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index 7bcaac7e22..c2e0f77847 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -1,5 +1,5 @@ import { createRef } from "react"; -import { render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vite-plus/test"; import { BubbleMap, GlobeMap, type MapGeoJson } from "./Maps"; @@ -58,7 +58,9 @@ describe("GlobeMap", () => { ], }; - const { getByRole } = render( + const onRegionClick = vi.fn(); + const onMarkerClick = vi.fn(); + const { getByLabelText, getByRole } = render( { ]} landStyle="dotted" showGraticule + onRegionClick={onRegionClick} + onMarkerClick={onMarkerClick} aria-label="Traffic globe" />, ); - const globe = getByRole("img", { name: "Traffic globe" }); + const globe = getByLabelText("Traffic globe"); expect(globe.tagName).toBe("svg"); + expect(globe.getAttribute("role")).toBeNull(); expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); expect( globe.querySelector('[data-land-style="dotted"]')?.getAttribute("d"), @@ -87,6 +92,21 @@ describe("GlobeMap", () => { expect(globe.querySelectorAll("circle")).toHaveLength(0); expect(globe.querySelector("pattern")).toBeNull(); expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); + + fireEvent.keyDown(getByRole("button", { name: "Visible region: 10" }), { + key: " ", + }); + fireEvent.keyDown( + getByRole("button", { name: "London: Availability location" }), + { key: "Enter" }, + ); + expect(onRegionClick).toHaveBeenCalledWith({ + country: "Visible region", + requests: 10, + }); + expect(onMarkerClick).toHaveBeenCalledWith( + expect.objectContaining({ name: "London" }), + ); }); }); diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index c7c7d2e52b..2ce5f57382 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -1449,7 +1449,6 @@ export function GlobeMap({ > ({ if (!entry && landStyle === "dotted") return null; const featurePath = path(geometry); if (!featurePath) return null; + const activateRegion = () => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + if (entry) onRegionClick?.(entry.row); + }; return ( ({ entry && "hover:opacity-80 focus-visible:opacity-80", )} strokeWidth={landStyle === "solid" ? 0.5 : 0} + role={entry ? "button" : undefined} + aria-label={ + entry + ? `${regionName}: ${valueFormat(entry.value)}` + : undefined + } tabIndex={entry ? 0 : undefined} onPointerEnter={(event) => { if (!entry) return; @@ -1541,12 +1553,11 @@ export function GlobeMap({ if (entry) onRegionHover?.(undefined); setTooltip(null); }} - onClick={() => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - if (entry) onRegionClick?.(entry.row); + onClick={activateRegion} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateRegion(); }} /> ); @@ -1562,6 +1573,13 @@ export function GlobeMap({ const detail = marker.description ?? `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; + const activateMarker = () => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + onMarkerClick?.(marker); + }; return ( ({ fill={marker.color ?? resolvedMarkerColor} strokeWidth={2} className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" + role="button" + aria-label={`${marker.name}: ${detail}`} tabIndex={0} onPointerEnter={(event) => moveTooltip(event, marker.name, detail) @@ -1590,12 +1610,11 @@ export function GlobeMap({ }); }} onBlur={() => setTooltip(null)} - onClick={() => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - onMarkerClick?.(marker); + onClick={activateMarker} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateMarker(); }} /> ); From a1ce79cd9e276f99b2202c3d0cd5d3c337dc1bfc Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Wed, 2 Sep 2026 13:42:13 -0400 Subject: [PATCH 06/15] perf(chart): focus globe on dotted rendering --- .changeset/warm-globes-drift.md | 2 +- .../components/demos/Chart/GlobeMapDemo.tsx | 50 +-- .../src/pages/charts/maps.astro | 25 +- .../kumo/src/components/chart/Maps.test.tsx | 17 +- packages/kumo/src/components/chart/Maps.tsx | 307 +++++++----------- 5 files changed, 124 insertions(+), 277 deletions(-) diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md index 90f63d06d8..87ee2adeec 100644 --- a/.changeset/warm-globes-drift.md +++ b/.changeset/warm-globes-drift.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `GlobeMap`, an SVG orthographic map with choropleth regions or clipped geographic markers, solid and dotted land styles, optional geographic guides, drag and automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. +Add `GlobeMap`, an SVG orthographic globe with boundary-free dotted land, clipped geographic markers, horizon fading, optional geographic guides, drag and automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 081763763c..35e827d6fb 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -9,31 +9,6 @@ interface GlobeMapDemoProps { geoJson: MapGeoJson | null; } -interface CountryTraffic { - country: string; - requests: number; -} - -const countries: CountryTraffic[] = [ - { country: "United States of America", requests: 4200 }, - { country: "Germany", requests: 3100 }, - { country: "United Kingdom", requests: 2800 }, - { country: "Japan", requests: 2500 }, - { country: "France", requests: 2200 }, - { country: "Brazil", requests: 1700 }, - { country: "India", requests: 1500 }, - { country: "Canada", requests: 1300 }, - { country: "Australia", requests: 1100 }, - { country: "Spain", requests: 900 }, - { country: "Netherlands", requests: 700 }, - { country: "Mexico", requests: 600 }, - { country: "Argentina", requests: 420 }, - { country: "Nigeria", requests: 300 }, - { country: "South Africa", requests: 220 }, -]; - -const formatRequests = (value: number) => `${value.toLocaleString()} requests`; - const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ { name: "SFO", @@ -96,8 +71,7 @@ export function GlobeMapAvailabilityZonesDemo({ geoJson }: GlobeMapDemoProps) { ); } - -/** An SVG globe with drag-to-rotate interaction and no WebGL dependency. */ -export function GlobeMapBasicDemo({ geoJson }: GlobeMapDemoProps) { - const isDarkMode = useIsDarkMode(); - - if (!geoJson) return null; - - return ( -
- - geoJson={geoJson} - data={countries} - name="country" - value="requests" - valueFormat={formatRequests} - oceanColor="var(--color-kumo-base)" - autoRotate - isDarkMode={isDarkMode} - /> -
- ); -} diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index 2649906159..1e647b5571 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -10,10 +10,7 @@ import { BubbleMapCloudflareLocationsDemo, } from "~/components/demos/Chart/BubbleMapDemo"; import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo"; -import { - GlobeMapAvailabilityZonesDemo, - GlobeMapBasicDemo, -} from "~/components/demos/Chart/GlobeMapDemo"; +import { GlobeMapAvailabilityZonesDemo } from "~/components/demos/Chart/GlobeMapDemo"; import type { MapGeoJson } from "@cloudflare/kumo"; const WORLD_GEO_JSON_URL = @@ -188,8 +185,7 @@ export default function Example() {
-
- Globe Map -

- Render the same GeoJSON as a clipped orthographic SVG globe. Drag to rotate it; land on the hidden hemisphere is clipped without WebGL. -

- `}> - - -
-
Custom Tooltips

diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index c2e0f77847..c5a843a8c1 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -58,14 +58,10 @@ describe("GlobeMap", () => { ], }; - const onRegionClick = vi.fn(); const onMarkerClick = vi.fn(); const { getByLabelText, getByRole } = render( { longitude: -0.12, }, ]} - landStyle="dotted" showGraticule - onRegionClick={onRegionClick} onMarkerClick={onMarkerClick} aria-label="Traffic globe" />, @@ -91,19 +85,16 @@ describe("GlobeMap", () => { ).toContain("a"); expect(globe.querySelectorAll("circle")).toHaveLength(0); expect(globe.querySelector("pattern")).toBeNull(); + expect(globe.querySelector("mask")).not.toBeNull(); + expect( + globe.querySelector('[data-land-style="dotted"]')?.getAttribute("mask"), + ).toMatch(/^url\(#kumo-globe-fade-/); expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); - fireEvent.keyDown(getByRole("button", { name: "Visible region: 10" }), { - key: " ", - }); fireEvent.keyDown( getByRole("button", { name: "London: Availability location" }), { key: "Enter" }, ); - expect(onRegionClick).toHaveBeenCalledWith({ - country: "Visible region", - requests: 10, - }); expect(onMarkerClick).toHaveBeenCalledWith( expect.objectContaining({ name: "London" }), ); diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index 2ce5f57382..ef4a332e62 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -9,6 +9,7 @@ import { forwardRef, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -18,7 +19,6 @@ import { geoArea, geoBounds, geoContains, - geoDistance, geoGraticule, geoMercator, geoOrthographic, @@ -1029,28 +1029,12 @@ export interface GlobeMapMarker { radius?: number; } -export interface GlobeMapProps { - /** GeoJSON `FeatureCollection` rendered on the globe. */ +export interface GlobeMapProps { + /** GeoJSON `FeatureCollection` used to determine dotted land coverage. */ geoJson: MapGeoJson; - /** Raw data rows joined to GeoJSON features. Optional for marker-only globes. */ - data?: T[]; - /** Region-key accessor (key of `T` or `(row) => string`). */ - name?: MapAccessor; - /** Value accessor — drives the region's fill colour. */ - value?: MapAccessor; - /** GeoJSON feature property to join on. Default: `"name"`. */ - nameProperty?: string; - /** Sequential colour ramp (low → high). */ - colorRange?: string[]; - /** Lower bound of the colour scale. Default: data minimum. */ - min?: number; - /** Upper bound of the colour scale. Default: data maximum. */ - max?: number; - /** Fill for regions with no matching data row. */ - noDataColor?: string; - /** Render no-data land as a solid fill or a field of dots. Default: `"solid"`. */ - landStyle?: "solid" | "dotted"; - /** Spacing between dot centers in the dotted land style. Default: `10`. */ + /** Fill for the dotted land. Defaults to the neutral Kumo map area color. */ + landColor?: string; + /** Spacing between land-dot centers in view-box pixels. Default: `10`. */ landDotSpacing?: number; /** Fill behind the land and graticule. Default: the Kumo base surface. */ oceanColor?: string; @@ -1072,14 +1056,8 @@ export interface GlobeMapProps { autoRotateSpeed?: number; /** Draw latitude and longitude guides. Default: `false`. */ showGraticule?: boolean; - /** Show the Kumo-styled region tooltip. Default: `true`. */ + /** Show the Kumo-styled marker tooltip. Default: `true`. */ showTooltip?: boolean; - /** Format the value in the default tooltip. */ - valueFormat?: (value: number) => string; - /** Called as the pointer enters/leaves a region with data. */ - onRegionHover?: (row: T | undefined) => void; - /** Called when a region with data is clicked. */ - onRegionClick?: (row: T) => void; /** Called after pointer dragging changes the globe rotation. */ onRotationChange?: (rotation: [number, number, number]) => void; /** Accessible label for the visualization. Default: `"Interactive globe map"`. */ @@ -1100,9 +1078,15 @@ interface GlobeTooltip { const GLOBE_VIEWBOX_SIZE = 640; const GLOBE_PADDING = 18; const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; +interface GlobeLandDot { + x: number; + y: number; + z: number; +} + const globeDotCoordinateCache = new WeakMap< MapGeoJson, - Map> + Map >(); /** @@ -1152,22 +1136,13 @@ function normalizeGlobeFeature( } /** - * GlobeMap — an SVG orthographic globe with choropleth regions, geographic - * markers, or both. Unlike the ECharts map components, it uses d3-geo's stream - * clipping so land and markers on the back hemisphere are hidden correctly. - * Rendering is SVG-only and does not use WebGL. + * GlobeMap — an SVG orthographic globe with dotted land and geographic + * markers. Land boundaries are used only for dot placement and are never + * drawn. Rendering is SVG-only and does not use WebGL. */ -export function GlobeMap({ +export function GlobeMap({ geoJson, - data, - name, - value, - nameProperty = "name", - colorRange, - min, - max, - noDataColor, - landStyle = "solid", + landColor, landDotSpacing = 10, oceanColor = "var(--color-kumo-base)", markers = [], @@ -1180,19 +1155,18 @@ export function GlobeMap({ autoRotateSpeed = 4, showGraticule = false, showTooltip = true, - valueFormat = defaultValueFormat, - onRegionHover, - onRegionClick, onRotationChange, "aria-label": ariaLabel = "Interactive globe map", height, className, isDarkMode, -}: GlobeMapProps) { +}: GlobeMapProps) { const [currentRotation, setCurrentRotation] = useState(rotation); + const fadeMaskId = `kumo-globe-fade-${useId().replaceAll(":", "")}`; const [tooltip, setTooltip] = useState(null); const didDragRef = useRef(false); const autoRotateFrameRef = useRef(null); + const autoRotateObserverRef = useRef(null); const autoRotateTimeRef = useRef(null); const dragFrameRef = useRef(null); const pendingRotationRef = useRef<[number, number, number] | null>(null); @@ -1207,33 +1181,14 @@ export function GlobeMap({ () => ChartPalette.mapColors(isDarkMode), [isDarkMode], ); - const colors = colorRange ?? palette.scale; - const noData = noDataColor ?? palette.area; + const resolvedLandColor = landColor ?? palette.area; const resolvedMarkerColor = markerColor ?? palette.bubble; - const rowsByName = useMemo(() => { - const rows = new Map(); - if (!data || name === undefined || value === undefined) return rows; - for (const row of data) { - rows.set(resolve(row, name), { row, value: resolve(row, value) }); - } - return rows; - }, [data, name, value]); - - const values = useMemo( - () => Array.from(rowsByName.values(), (entry) => entry.value), - [rowsByName], - ); - const dataMin = values.length ? Math.min(...values) : 0; - const dataMax = values.length ? Math.max(...values) : 1; - const resolvedMin = min ?? dataMin; - const resolvedMax = max ?? (dataMax > dataMin ? dataMax : dataMin + 1); - const globeFeatures = useMemo( () => geoJson.features.map((feature) => { const geometry = normalizeGlobeFeature(feature); - return { feature, geometry, bounds: geoBounds(geometry) }; + return { geometry, bounds: geoBounds(geometry) }; }), [geoJson], ); @@ -1255,12 +1210,12 @@ export function GlobeMap({ const spherePath = path({ type: "Sphere" }) ?? undefined; const graticulePath = path(geoGraticule().step([15, 10])()) ?? undefined; const dottedLandCoordinates = useMemo(() => { - if (landStyle !== "dotted" || landDotSpacing <= 0) return []; + if (landDotSpacing <= 0) return []; const cached = globeDotCoordinateCache.get(geoJson)?.get(landDotSpacing); if (cached) return cached; - const coordinates: Array<[number, number]> = []; + const coordinates: GlobeLandDot[] = []; const angularStep = (landDotSpacing / GLOBE_RADIUS) * (180 / Math.PI); for ( let latitude = -90 + angularStep / 2; @@ -1286,55 +1241,60 @@ export function GlobeMap({ return isWithinLongitude && geoContains(geometry, coordinate); }) ) { - coordinates.push(coordinate); + const longitudeRadians = (longitude * Math.PI) / 180; + const latitudeRadians = (latitude * Math.PI) / 180; + const cosLatitude = Math.cos(latitudeRadians); + coordinates.push({ + x: Math.cos(longitudeRadians) * cosLatitude, + y: Math.sin(longitudeRadians) * cosLatitude, + z: Math.sin(latitudeRadians), + }); } } } const cacheForGeoJson = - globeDotCoordinateCache.get(geoJson) ?? - new Map>(); + globeDotCoordinateCache.get(geoJson) ?? new Map(); cacheForGeoJson.set(landDotSpacing, coordinates); globeDotCoordinateCache.set(geoJson, cacheForGeoJson); return coordinates; - }, [geoJson, globeFeatures, landDotSpacing, landStyle]); + }, [geoJson, globeFeatures, landDotSpacing]); const dottedLandPath = useMemo(() => { - const center = projection.invert?.([ - GLOBE_VIEWBOX_SIZE / 2, - GLOBE_VIEWBOX_SIZE / 2, - ]); - if (!center) return undefined; - const radius = Number((landDotSpacing * 0.28).toFixed(2)); - const horizonInset = radius / GLOBE_RADIUS; const diameter = radius * 2; + const [longitude, latitude, roll] = currentRotation.map( + (degrees) => (degrees * Math.PI) / 180, + ); + const cosLongitude = Math.cos(longitude); + const sinLongitude = Math.sin(longitude); + const cosLatitude = Math.cos(latitude); + const sinLatitude = Math.sin(latitude); + const cosRoll = Math.cos(roll); + const sinRoll = Math.sin(roll); const commands: string[] = []; - for (const coordinate of dottedLandCoordinates) { - if (geoDistance(center, coordinate) >= Math.PI / 2 - horizonInset) { - continue; - } - const point = projection(coordinate); - if (!point) continue; - const x = Number(point[0].toFixed(2)); - const y = Number(point[1].toFixed(2)); + + // This is algebraically equivalent to d3's spherical rotation followed by + // its orthographic projection, without function calls or allocations per dot. + for (const dot of dottedLandCoordinates) { + const rotatedX = dot.x * cosLongitude - dot.y * sinLongitude; + const rotatedY = dot.y * cosLongitude + dot.x * sinLongitude; + const latitudeAxis = dot.z * cosLatitude + rotatedX * sinLatitude; + const depth = rotatedX * cosLatitude - dot.z * sinLatitude; + if (depth <= 0) continue; + + const projectedX = rotatedY * cosRoll - latitudeAxis * sinRoll; + const projectedY = latitudeAxis * cosRoll + rotatedY * sinRoll; + const x = Number( + (GLOBE_VIEWBOX_SIZE / 2 + GLOBE_RADIUS * projectedX).toFixed(2), + ); + const y = Number( + (GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS * projectedY).toFixed(2), + ); commands.push( `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, ); } return commands.join("") || undefined; - }, [dottedLandCoordinates, landDotSpacing, projection]); - - const fillForValue = useCallback( - (regionValue: number) => { - if (colors.length === 0) return noData; - const range = resolvedMax - resolvedMin; - const position = range > 0 ? (regionValue - resolvedMin) / range : 0; - const index = Math.round( - Math.max(0, Math.min(1, position)) * (colors.length - 1), - ); - return colors[index]; - }, - [colors, noData, resolvedMax, resolvedMin], - ); + }, [currentRotation, dottedLandCoordinates, landDotSpacing]); const moveTooltip = useCallback( ( @@ -1362,6 +1322,8 @@ export function GlobeMap({ cancelAnimationFrame(autoRotateFrameRef.current); autoRotateFrameRef.current = null; } + autoRotateObserverRef.current?.disconnect(); + autoRotateObserverRef.current = null; autoRotateTimeRef.current = null; if (!node || !autoRotate) return; @@ -1378,8 +1340,27 @@ export function GlobeMap({ } autoRotateFrameRef.current = requestAnimationFrame(rotate); }; + const start = () => { + if (autoRotateFrameRef.current !== null) return; + autoRotateTimeRef.current = null; + autoRotateFrameRef.current = requestAnimationFrame(rotate); + }; + const stop = () => { + if (autoRotateFrameRef.current === null) return; + cancelAnimationFrame(autoRotateFrameRef.current); + autoRotateFrameRef.current = null; + autoRotateTimeRef.current = null; + }; - autoRotateFrameRef.current = requestAnimationFrame(rotate); + if (typeof IntersectionObserver === "undefined") { + start(); + return; + } + autoRotateObserverRef.current = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) start(); + else stop(); + }); + autoRotateObserverRef.current.observe(node); }, [autoRotate, autoRotateSpeed], ); @@ -1463,6 +1444,28 @@ export function GlobeMap({ if (!dragRef.current) setTooltip(null); }} > + + + + + + + + + ({ strokeWidth={0.75} /> ) : null} - {landStyle === "dotted" ? ( - - ) : null} - - {globeFeatures.map(({ feature, geometry }, index) => { - const property = feature.properties?.[nameProperty]; - const regionName = - typeof property === "string" || typeof property === "number" - ? String(property) - : ""; - const entry = rowsByName.get(regionName); - if (!entry && landStyle === "dotted") return null; - const featurePath = path(geometry); - if (!featurePath) return null; - const activateRegion = () => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - if (entry) onRegionClick?.(entry.row); - }; - return ( - { - if (!entry) return; - onRegionHover?.(entry.row); - moveTooltip(event, regionName, valueFormat(entry.value)); - }} - onPointerMove={(event) => { - if (entry && !dragRef.current) { - moveTooltip(event, regionName, valueFormat(entry.value)); - } - }} - onPointerLeave={() => { - if (!entry) return; - onRegionHover?.(undefined); - setTooltip(null); - }} - onFocus={(event) => { - if (!entry || !showTooltip) return; - onRegionHover?.(entry.row); - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: regionName, - detail: valueFormat(entry.value), - x: bounds.width / 2, - y: bounds.height / 2, - }); - }} - onBlur={() => { - if (entry) onRegionHover?.(undefined); - setTooltip(null); - }} - onClick={activateRegion} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateRegion(); - }} - /> - ); - })} - + {markers.map((marker, index) => { const markerPath = path.pointRadius(marker.radius ?? markerRadius)({ @@ -1585,6 +1509,7 @@ export function GlobeMap({ key={`${marker.name}-${index}`} d={markerPath} fill={marker.color ?? resolvedMarkerColor} + mask={`url(#${fadeMaskId})`} strokeWidth={2} className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" role="button" From 224e4c5c3e253c36b60e105309646e596d161983 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Wed, 2 Sep 2026 18:07:55 -0400 Subject: [PATCH 07/15] perf(chart): optimize globe land rendering --- .../components/demos/Chart/GlobeMapDemo.tsx | 17 +- .../src/pages/charts/maps.astro | 7 +- .../kumo/scripts/generate-globe-land-mask.mjs | 80 +++ .../kumo/src/components/chart/GlobeMap.tsx | 673 ++++++++++++++++++ .../kumo/src/components/chart/Maps.test.tsx | 54 +- packages/kumo/src/components/chart/Maps.tsx | 575 +-------------- .../src/components/chart/globe-land-mask.ts | 23 + packages/kumo/src/components/chart/index.ts | 4 +- 8 files changed, 811 insertions(+), 622 deletions(-) create mode 100644 packages/kumo/scripts/generate-globe-land-mask.mjs create mode 100644 packages/kumo/src/components/chart/GlobeMap.tsx create mode 100644 packages/kumo/src/components/chart/globe-land-mask.ts diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 35e827d6fb..3746869559 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -1,14 +1,6 @@ -import { - GlobeMap, - type GlobeMapMarker, - type MapGeoJson, -} from "@cloudflare/kumo"; +import { GlobeMap, type GlobeMapMarker } from "@cloudflare/kumo"; import { useIsDarkMode } from "~/lib/use-is-dark-mode"; -interface GlobeMapDemoProps { - geoJson: MapGeoJson | null; -} - const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ { name: "SFO", @@ -61,18 +53,15 @@ const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ ]; /** Illustrative Cloudflare network locations on a draggable SVG globe. */ -export function GlobeMapAvailabilityZonesDemo({ geoJson }: GlobeMapDemoProps) { +export function GlobeMapAvailabilityZonesDemo() { const isDarkMode = useIsDarkMode(); - if (!geoJson) return null; - return (

Installation

- BubbleMap and ChoroplethMap require echarts as a peer dependency. GlobeMap renders directly to SVG and does not use WebGL or require ECharts. Consumers provide the GeoJSON feature collection; map components do not fetch map data or use map tiles. + BubbleMap and ChoroplethMap require echarts as a peer dependency. GlobeMap renders a built-in Natural Earth land mask directly to SVG and does not use WebGL or require ECharts. Planar map consumers provide GeoJSON; map components do not fetch map data or use map tiles.

@@ -183,17 +183,16 @@ export default function Example() { Plot geographic markers without WebGL. Dotted neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe to reveal points on the hidden hemisphere.

`}> - +
diff --git a/packages/kumo/scripts/generate-globe-land-mask.mjs b/packages/kumo/scripts/generate-globe-land-mask.mjs new file mode 100644 index 0000000000..ba230901fd --- /dev/null +++ b/packages/kumo/scripts/generate-globe-land-mask.mjs @@ -0,0 +1,80 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { geoArea, geoContains } from "d3-geo"; + +const SOURCE_URL = + "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_110m_land.geojson"; +const OUTPUT_URL = new URL( + "../src/components/chart/globe-land-mask.ts", + import.meta.url, +); +const WIDTH = 360; +const HEIGHT = 180; + +function normalizeFeature(feature) { + const geometry = feature.geometry; + const normalizePolygon = (polygon) => { + const polygonObject = { type: "Polygon", coordinates: polygon }; + if (geoArea(polygonObject) <= 2 * Math.PI) return polygon; + return polygon.map((ring) => [...ring].reverse()); + }; + + return { + ...feature, + geometry: { + ...geometry, + coordinates: + geometry.type === "Polygon" + ? normalizePolygon(geometry.coordinates) + : geometry.type === "MultiPolygon" + ? geometry.coordinates.map(normalizePolygon) + : geometry.coordinates, + }, + }; +} + +const response = await fetch(SOURCE_URL); +if (!response.ok) { + throw new Error(`Natural Earth download failed: ${response.status}`); +} +const geoJson = await response.json(); +const features = geoJson.features.map(normalizeFeature); +const bytes = new Uint8Array((WIDTH * HEIGHT) / 8); + +for (let row = 0; row < HEIGHT; row += 1) { + for (let column = 0; column < WIDTH; column += 1) { + const coordinate = [column - 179.5, row - 89.5]; + if (!features.some((feature) => geoContains(feature, coordinate))) continue; + const index = row * WIDTH + column; + bytes[index >> 3] |= 1 << (index & 7); + } +} + +const base64 = Buffer.from(bytes).toString("base64"); +const source = `/** + * One-degree land occupancy mask generated from Natural Earth 1:110m land. + * Natural Earth data is public domain: https://www.naturalearthdata.com/about/terms-of-use/ + * Source: ${SOURCE_URL} + * Generated by: packages/kumo/scripts/generate-globe-land-mask.mjs + */ +const LAND_MASK_BASE64 = + "${base64}"; + +const LAND_MASK_WIDTH = ${WIDTH}; +const landMask = Uint8Array.from(atob(LAND_MASK_BASE64), (value) => + value.charCodeAt(0), +); + +export function isCanonicalLand(longitude: number, latitude: number): boolean { + const column = Math.max( + 0, + Math.min(LAND_MASK_WIDTH - 1, Math.floor(longitude + 180)), + ); + const row = Math.max(0, Math.min(${HEIGHT - 1}, Math.floor(latitude + 90))); + const index = row * LAND_MASK_WIDTH + column; + return (landMask[index >> 3]! & (1 << (index & 7))) !== 0; +} +`; + +await writeFile(OUTPUT_URL, source); +console.log(`Generated ${fileURLToPath(OUTPUT_URL)} (${bytes.length} bytes)`); diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx new file mode 100644 index 0000000000..abf3c1423f --- /dev/null +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -0,0 +1,673 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useId, useMemo, useRef, useState } from "react"; +import { cn } from "../../utils/cn"; +import { ChartPalette } from "./Color"; +import { isCanonicalLand } from "./globe-land-mask"; + +export interface GlobeMapMarker { + /** Longitude in decimal degrees. */ + longitude: number; + /** Latitude in decimal degrees. */ + latitude: number; + /** Primary tooltip label. */ + name: string; + /** Optional secondary tooltip text. */ + description?: string; + /** Marker fill. Overrides `markerColor`. */ + color?: string; + /** Marker radius in view-box pixels. Overrides `markerRadius`. */ + radius?: number; +} + +export interface GlobeMapProps { + /** Fill for the dotted land. Defaults to the neutral Kumo map area color. */ + landColor?: string; + /** Spacing between land-dot centers in view-box pixels. Default: `10`. */ + landDotSpacing?: number; + /** Fill behind the land and graticule. Default: the Kumo base surface. */ + oceanColor?: string; + /** Geographic points drawn above the land. Back-facing points are clipped. */ + markers?: GlobeMapMarker[]; + /** Default marker fill. Defaults to the Kumo chart blue. */ + markerColor?: string; + /** Default marker radius in view-box pixels. Default: `7`. */ + markerRadius?: number; + /** Called when a visible marker is clicked. */ + onMarkerClick?: (marker: GlobeMapMarker) => void; + /** Initial globe rotation as `[longitude, latitude, roll]`. */ + rotation?: [number, number, number]; + /** Allow pointer dragging to rotate the globe. Default: `true`. */ + draggable?: boolean; + /** Continuously rotate the globe horizontally. Default: `false`. */ + autoRotate?: boolean; + /** Horizontal auto-rotation speed in degrees per second. Default: `4`. */ + autoRotateSpeed?: number; + /** Draw latitude and longitude guides. Default: `false`. */ + showGraticule?: boolean; + /** Show the Kumo-styled marker tooltip. Default: `true`. */ + showTooltip?: boolean; + /** Called after pointer dragging changes the globe rotation. */ + onRotationChange?: (rotation: [number, number, number]) => void; + /** Accessible label for the visualization. Default: `"Interactive globe map"`. */ + "aria-label"?: string; + /** Fixed component height. Otherwise the globe uses a square aspect ratio. */ + height?: number; + className?: string; + isDarkMode?: boolean; +} + +interface GlobeTooltip { + name: string; + detail: string; + x: number; + y: number; +} + +const GLOBE_VIEWBOX_SIZE = 640; +const GLOBE_PADDING = 18; +const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; +interface GlobeLandDot { + x: number; + y: number; + z: number; +} + +interface GlobeScreenDot { + projectedX: number; + projectedY: number; + depth: number; + command: string; +} + +interface GlobeRotationTransform { + cosLongitude: number; + sinLongitude: number; + cosLatitude: number; + sinLatitude: number; + cosRoll: number; + sinRoll: number; +} + +const GLOBE_SPHERE_PATH = `M${GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS},${GLOBE_VIEWBOX_SIZE / 2}a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 ${GLOBE_RADIUS * 2},0a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 -${GLOBE_RADIUS * 2},0`; +const globeDotCoordinateCache = new Map(); + +function toCartesian(longitude: number, latitude: number): GlobeLandDot { + const longitudeRadians = (longitude * Math.PI) / 180; + const latitudeRadians = (latitude * Math.PI) / 180; + const cosLatitude = Math.cos(latitudeRadians); + return { + x: Math.cos(longitudeRadians) * cosLatitude, + y: Math.sin(longitudeRadians) * cosLatitude, + z: Math.sin(latitudeRadians), + }; +} + +function createRotationTransform( + rotation: [number, number, number], +): GlobeRotationTransform { + const [longitude, latitude, roll] = rotation.map( + (degrees) => (degrees * Math.PI) / 180, + ); + return { + cosLongitude: Math.cos(longitude), + sinLongitude: Math.sin(longitude), + cosLatitude: Math.cos(latitude), + sinLatitude: Math.sin(latitude), + cosRoll: Math.cos(roll), + sinRoll: Math.sin(roll), + }; +} + +function projectVector( + point: GlobeLandDot, + transform: GlobeRotationTransform, + output: [number, number, number], +): void { + const rotatedX = + point.x * transform.cosLongitude - point.y * transform.sinLongitude; + const rotatedY = + point.y * transform.cosLongitude + point.x * transform.sinLongitude; + const latitudeAxis = + point.z * transform.cosLatitude + rotatedX * transform.sinLatitude; + output[2] = + rotatedX * transform.cosLatitude - point.z * transform.sinLatitude; + output[0] = + GLOBE_VIEWBOX_SIZE / 2 + + GLOBE_RADIUS * + (rotatedY * transform.cosRoll - latitudeAxis * transform.sinRoll); + output[1] = + GLOBE_VIEWBOX_SIZE / 2 - + GLOBE_RADIUS * + (latitudeAxis * transform.cosRoll + rotatedY * transform.sinRoll); +} + +const globeGraticuleLines: GlobeLandDot[][] = (() => { + const lines: GlobeLandDot[][] = []; + for (let longitude = -180; longitude < 180; longitude += 15) { + const line: GlobeLandDot[] = []; + for (let latitude = -88; latitude <= 88; latitude += 4) { + line.push(toCartesian(longitude, latitude)); + } + lines.push(line); + } + for (let latitude = -80; latitude <= 80; latitude += 10) { + const line: GlobeLandDot[] = []; + for (let longitude = -180; longitude <= 180; longitude += 4) { + line.push(toCartesian(longitude, latitude)); + } + lines.push(line); + } + return lines; +})(); + +function createGraticulePath( + rotation: [number, number, number], +): string | undefined { + const transform = createRotationTransform(rotation); + const projected: [number, number, number] = [0, 0, 0]; + const commands: string[] = []; + + for (const line of globeGraticuleLines) { + let previousX = 0; + let previousY = 0; + let previousDepth = -1; + for (const point of line) { + projectVector(point, transform, projected); + const [x, y, depth] = projected; + if (depth > 0) { + if (previousDepth <= 0) { + if (previousDepth !== -1) { + const ratio = depth / (depth - previousDepth); + commands.push( + `M${(x + (previousX - x) * ratio).toFixed(2)},${(y + (previousY - y) * ratio).toFixed(2)}`, + ); + } else { + commands.push(`M${x.toFixed(2)},${y.toFixed(2)}`); + } + } + commands.push(`L${x.toFixed(2)},${y.toFixed(2)}`); + } else if (previousDepth > 0) { + const ratio = previousDepth / (previousDepth - depth); + commands.push( + `L${(previousX + (x - previousX) * ratio).toFixed(2)},${(previousY + (y - previousY) * ratio).toFixed(2)}`, + ); + } + previousX = x; + previousY = y; + previousDepth = depth; + } + } + return commands.join("") || undefined; +} + +function createDottedLandPath( + dots: GlobeScreenDot[], + rotation: [number, number, number], +): string | undefined { + const { + cosLongitude, + sinLongitude, + cosLatitude, + sinLatitude, + cosRoll, + sinRoll, + } = createRotationTransform(rotation); + const commands: string[] = []; + + // Keep a stable screen-space lattice and inverse-project each point into the + // rotating land mask. Only membership changes; dot geometry never moves. + for (const dot of dots) { + const rotatedY = dot.projectedX * cosRoll + dot.projectedY * sinRoll; + const latitudeAxis = dot.projectedY * cosRoll - dot.projectedX * sinRoll; + const rotatedX = dot.depth * cosLatitude + latitudeAxis * sinLatitude; + const worldZ = latitudeAxis * cosLatitude - dot.depth * sinLatitude; + const worldX = rotatedX * cosLongitude + rotatedY * sinLongitude; + const worldY = rotatedY * cosLongitude - rotatedX * sinLongitude; + const longitude = (Math.atan2(worldY, worldX) * 180) / Math.PI; + const latitude = + (Math.asin(Math.max(-1, Math.min(1, worldZ))) * 180) / Math.PI; + if (isCanonicalLand(longitude, latitude)) commands.push(dot.command); + } + return commands.join("") || undefined; +} + +function createMarkerPath( + point: GlobeLandDot, + transform: GlobeRotationTransform, + radius: number, +): string | undefined { + const projected: [number, number, number] = [0, 0, 0]; + projectVector(point, transform, projected); + if (projected[2] <= 0) return undefined; + const x = Number(projected[0].toFixed(2)); + const y = Number(projected[1].toFixed(2)); + const diameter = radius * 2; + return `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`; +} + +/** + * GlobeMap — an SVG orthographic globe with dotted land and geographic + * markers. Land boundaries are used only for dot placement and are never + * drawn. Rendering is SVG-only and does not use WebGL. + */ +export function GlobeMap({ + landColor, + landDotSpacing = 10, + oceanColor = "var(--color-kumo-base)", + markers = [], + markerColor, + markerRadius = 7, + onMarkerClick, + rotation = [-10, -20, 0], + draggable = true, + autoRotate = false, + autoRotateSpeed = 4, + showGraticule = false, + showTooltip = true, + onRotationChange, + "aria-label": ariaLabel = "Interactive globe map", + height, + className, + isDarkMode, +}: GlobeMapProps) { + const currentRotationRef = useRef(rotation); + const fadeMaskId = `kumo-globe-fade-${useId().replaceAll(":", "")}`; + const [tooltip, setTooltip] = useState(null); + const didDragRef = useRef(false); + const autoRotateFrameRef = useRef(null); + const autoRotateObserverRef = useRef(null); + const autoRotateTimeRef = useRef(null); + const dragFrameRef = useRef(null); + const landPathRef = useRef(null); + const graticulePathRef = useRef(null); + const markerPathRefs = useRef<(SVGPathElement | null)[]>([]); + const pendingRotationRef = useRef<[number, number, number] | null>(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + rotation: [number, number, number]; + } | null>(null); + + const palette = useMemo( + () => ChartPalette.mapColors(isDarkMode), + [isDarkMode], + ); + const resolvedLandColor = landColor ?? palette.area; + const resolvedMarkerColor = markerColor ?? palette.bubble; + const markerCoordinates = useMemo( + () => + markers.map((marker) => toCartesian(marker.longitude, marker.latitude)), + [markers], + ); + const currentRotationTransform = createRotationTransform( + currentRotationRef.current, + ); + + const spherePath = GLOBE_SPHERE_PATH; + const graticulePath = showGraticule + ? createGraticulePath(currentRotationRef.current) + : undefined; + const dottedLandDots = useMemo(() => { + if (landDotSpacing <= 0) return []; + + const cached = globeDotCoordinateCache.get(landDotSpacing); + if (cached) return cached; + + const dots: GlobeScreenDot[] = []; + const radius = Number((landDotSpacing * 0.28).toFixed(2)); + const diameter = radius * 2; + const start = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS; + const end = GLOBE_VIEWBOX_SIZE / 2 + GLOBE_RADIUS; + for (let y = start + landDotSpacing / 2; y < end; y += landDotSpacing) { + for (let x = start + landDotSpacing / 2; x < end; x += landDotSpacing) { + const projectedX = (x - GLOBE_VIEWBOX_SIZE / 2) / GLOBE_RADIUS; + const projectedY = (GLOBE_VIEWBOX_SIZE / 2 - y) / GLOBE_RADIUS; + const squaredDistance = + projectedX * projectedX + projectedY * projectedY; + if (squaredDistance >= 1) continue; + dots.push({ + projectedX, + projectedY, + depth: Math.sqrt(1 - squaredDistance), + command: `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, + }); + } + } + globeDotCoordinateCache.set(landDotSpacing, dots); + return dots; + }, [landDotSpacing]); + const dottedLandPath = useMemo( + () => createDottedLandPath(dottedLandDots, currentRotationRef.current), + [dottedLandDots, landDotSpacing], + ); + + const renderRotation = useCallback( + (nextRotation: [number, number, number]) => { + currentRotationRef.current = nextRotation; + + const nextLandPath = createDottedLandPath(dottedLandDots, nextRotation); + if (nextLandPath) landPathRef.current?.setAttribute("d", nextLandPath); + else landPathRef.current?.removeAttribute("d"); + + if (graticulePathRef.current) { + const nextGraticulePath = createGraticulePath(nextRotation); + if (nextGraticulePath) { + graticulePathRef.current.setAttribute("d", nextGraticulePath); + } + } + + const rotationTransform = createRotationTransform(nextRotation); + markers.forEach((marker, index) => { + const element = markerPathRefs.current[index]; + if (!element) return; + const nextMarkerPath = createMarkerPath( + markerCoordinates[index]!, + rotationTransform, + marker.radius ?? markerRadius, + ); + if (!nextMarkerPath) { + element.style.display = "none"; + element.setAttribute("aria-hidden", "true"); + element.setAttribute("tabindex", "-1"); + return; + } + element.style.removeProperty("display"); + element.removeAttribute("aria-hidden"); + element.setAttribute("tabindex", "0"); + element.setAttribute("d", nextMarkerPath); + }); + }, + [landDotSpacing, markerCoordinates, markerRadius, markers, dottedLandDots], + ); + + const moveTooltip = useCallback( + ( + event: ReactPointerEvent, + tooltipName: string, + detail: string, + ) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: tooltipName, + detail, + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + }, + [showTooltip], + ); + + const handleSvgRef = useCallback( + (node: SVGSVGElement | null) => { + if (autoRotateFrameRef.current !== null) { + cancelAnimationFrame(autoRotateFrameRef.current); + autoRotateFrameRef.current = null; + } + autoRotateObserverRef.current?.disconnect(); + autoRotateObserverRef.current = null; + autoRotateTimeRef.current = null; + if (!node || !autoRotate) return; + + const rotate = (time: number) => { + const previousTime = autoRotateTimeRef.current; + if (previousTime === null) autoRotateTimeRef.current = time; + if ( + previousTime !== null && + time - previousTime >= 1000 / 30 - 2 && + !dragRef.current + ) { + const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); + autoRotateTimeRef.current = time; + const current = currentRotationRef.current; + renderRotation([ + current[0] + autoRotateSpeed * deltaSeconds, + current[1], + current[2], + ]); + } + autoRotateFrameRef.current = requestAnimationFrame(rotate); + }; + const start = () => { + if (autoRotateFrameRef.current !== null) return; + autoRotateTimeRef.current = null; + autoRotateFrameRef.current = requestAnimationFrame(rotate); + }; + const stop = () => { + if (autoRotateFrameRef.current === null) return; + cancelAnimationFrame(autoRotateFrameRef.current); + autoRotateFrameRef.current = null; + autoRotateTimeRef.current = null; + }; + + if ( + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + if (typeof IntersectionObserver === "undefined") { + start(); + return; + } + autoRotateObserverRef.current = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) start(); + else stop(); + }); + autoRotateObserverRef.current.observe(node); + }, + [autoRotate, autoRotateSpeed, renderRotation], + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!draggable) return; + event.currentTarget.setPointerCapture(event.pointerId); + didDragRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + rotation: currentRotationRef.current, + }; + setTooltip(null); + }, + [draggable], + ); + + const applyPendingRotation = useCallback(() => { + dragFrameRef.current = null; + const next = pendingRotationRef.current; + pendingRotationRef.current = null; + if (!next) return; + renderRotation(next); + onRotationChange?.(next); + }, [onRotationChange, renderRotation]); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; + pendingRotationRef.current = [ + drag.rotation[0] + deltaX * 0.3, + Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), + drag.rotation[2], + ]; + if (dragFrameRef.current === null) { + dragFrameRef.current = requestAnimationFrame(applyPendingRotation); + } + }, + [applyPendingRotation], + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + applyPendingRotation(); + }, + [applyPendingRotation], + ); + + return ( +
+ { + if (!dragRef.current) setTooltip(null); + }} + > + + + + + + + + + + + {showGraticule ? ( + + ) : null} + + + {markers.map((marker, index) => { + const markerPath = createMarkerPath( + markerCoordinates[index]!, + currentRotationTransform, + marker.radius ?? markerRadius, + ); + const detail = + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; + const activateMarker = () => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + onMarkerClick?.(marker); + }; + return ( + { + markerPathRefs.current[index] = node; + }} + d={markerPath ?? undefined} + style={markerPath ? undefined : { display: "none" }} + fill={marker.color ?? resolvedMarkerColor} + mask={`url(#${fadeMaskId})`} + strokeWidth={2} + className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" + role="button" + aria-label={`${marker.name}: ${detail}`} + tabIndex={markerPath ? 0 : -1} + aria-hidden={markerPath ? undefined : true} + onPointerEnter={(event) => + moveTooltip(event, marker.name, detail) + } + onPointerMove={(event) => { + if (!dragRef.current) moveTooltip(event, marker.name, detail); + }} + onPointerLeave={() => setTooltip(null)} + onFocus={(event) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail, + x: bounds.width / 2, + y: bounds.height / 2, + }); + }} + onBlur={() => setTooltip(null)} + onClick={activateMarker} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateMarker(); + }} + /> + ); + })} + + + + {tooltip ? ( +
+ {tooltip.name} + {tooltip.detail} +
+ ) : null} +
+ ); +} + +GlobeMap.displayName = "GlobeMap"; diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index c5a843a8c1..7dcb75dbfa 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -1,7 +1,8 @@ import { createRef } from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { BubbleMap, GlobeMap, type MapGeoJson } from "./Maps"; +import { GlobeMap } from "./GlobeMap"; +import { BubbleMap, type MapGeoJson } from "./Maps"; const createMockChart = () => ({ setOption: vi.fn(), @@ -35,33 +36,9 @@ const data = [ describe("GlobeMap", () => { it("renders an accessible SVG globe without ECharts", () => { - const globeGeoJson: MapGeoJson = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - id: "visible-region", - properties: { name: "Visible region" }, - geometry: { - type: "Polygon", - coordinates: [ - [ - [-20, -10], - [20, -10], - [20, 10], - [-20, 10], - [-20, -10], - ], - ], - }, - }, - ], - }; - const onMarkerClick = vi.fn(); const { getByLabelText, getByRole } = render( { expect(globe.tagName).toBe("svg"); expect(globe.getAttribute("role")).toBeNull(); expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); - expect( - globe.querySelector('[data-land-style="dotted"]')?.getAttribute("d"), - ).toContain("a"); + const landPath = globe + .querySelector('[data-land-style="dotted"]') + ?.getAttribute("d"); + expect(landPath).toContain("a"); + expect(landPath?.match(/M/g)?.length).toBeGreaterThan(1_000); expect(globe.querySelectorAll("circle")).toHaveLength(0); expect(globe.querySelector("pattern")).toBeNull(); expect(globe.querySelector("mask")).not.toBeNull(); @@ -99,6 +78,25 @@ describe("GlobeMap", () => { expect.objectContaining({ name: "London" }), ); }); + + it("updates rotation while dragging", async () => { + const onRotationChange = vi.fn(); + const { getByLabelText } = render( + , + ); + const globe = getByLabelText("Draggable globe"); + const land = globe.querySelector('[data-land-style="dotted"]'); + const initialPath = land?.getAttribute("d"); + + fireEvent.pointerDown(globe, { pointerId: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(globe, { pointerId: 1, clientX: 140, clientY: 100 }); + + await waitFor(() => expect(land?.getAttribute("d")).not.toBe(initialPath)); + expect(onRotationChange).toHaveBeenCalledWith([2, -20, 0]); + }); }); describe("BubbleMap", () => { diff --git a/packages/kumo/src/components/chart/Maps.tsx b/packages/kumo/src/components/chart/Maps.tsx index ef4a332e62..130f27f76d 100644 --- a/packages/kumo/src/components/chart/Maps.tsx +++ b/packages/kumo/src/components/chart/Maps.tsx @@ -1,31 +1,14 @@ import type * as echarts from "echarts/core"; -import type { - ForwardedRef, - PointerEvent as ReactPointerEvent, - ReactElement, - RefAttributes, -} from "react"; +import type { ForwardedRef, ReactElement, RefAttributes } from "react"; import { forwardRef, useCallback, useEffect, - useId, useLayoutEffect, useMemo, useRef, - useState, } from "react"; -import { - geoArea, - geoBounds, - geoContains, - geoGraticule, - geoMercator, - geoOrthographic, - geoPath, - type GeoPermissibleObjects, -} from "d3-geo"; -import { cn } from "../../utils/cn"; +import { geoMercator } from "d3-geo"; import { Chart, type ChartEvents, type KumoChartOption } from "./EChart"; import { ChartPalette } from "./Color"; import { defaultValueFormat, escapeHtml } from "./tooltip-utils"; @@ -1014,560 +997,6 @@ export const ChoroplethMap = forwardRef(ChoroplethMapRoot) as (( ChoroplethMap.displayName = "ChoroplethMap"; -export interface GlobeMapMarker { - /** Longitude in decimal degrees. */ - longitude: number; - /** Latitude in decimal degrees. */ - latitude: number; - /** Primary tooltip label. */ - name: string; - /** Optional secondary tooltip text. */ - description?: string; - /** Marker fill. Overrides `markerColor`. */ - color?: string; - /** Marker radius in view-box pixels. Overrides `markerRadius`. */ - radius?: number; -} - -export interface GlobeMapProps { - /** GeoJSON `FeatureCollection` used to determine dotted land coverage. */ - geoJson: MapGeoJson; - /** Fill for the dotted land. Defaults to the neutral Kumo map area color. */ - landColor?: string; - /** Spacing between land-dot centers in view-box pixels. Default: `10`. */ - landDotSpacing?: number; - /** Fill behind the land and graticule. Default: the Kumo base surface. */ - oceanColor?: string; - /** Geographic points drawn above the land. Back-facing points are clipped. */ - markers?: GlobeMapMarker[]; - /** Default marker fill. Defaults to the Kumo chart blue. */ - markerColor?: string; - /** Default marker radius in view-box pixels. Default: `7`. */ - markerRadius?: number; - /** Called when a visible marker is clicked. */ - onMarkerClick?: (marker: GlobeMapMarker) => void; - /** Initial globe rotation as `[longitude, latitude, roll]`. */ - rotation?: [number, number, number]; - /** Allow pointer dragging to rotate the globe. Default: `true`. */ - draggable?: boolean; - /** Continuously rotate the globe horizontally. Default: `false`. */ - autoRotate?: boolean; - /** Horizontal auto-rotation speed in degrees per second. Default: `4`. */ - autoRotateSpeed?: number; - /** Draw latitude and longitude guides. Default: `false`. */ - showGraticule?: boolean; - /** Show the Kumo-styled marker tooltip. Default: `true`. */ - showTooltip?: boolean; - /** Called after pointer dragging changes the globe rotation. */ - onRotationChange?: (rotation: [number, number, number]) => void; - /** Accessible label for the visualization. Default: `"Interactive globe map"`. */ - "aria-label"?: string; - /** Fixed component height. Otherwise the globe uses a square aspect ratio. */ - height?: number; - className?: string; - isDarkMode?: boolean; -} - -interface GlobeTooltip { - name: string; - detail: string; - x: number; - y: number; -} - -const GLOBE_VIEWBOX_SIZE = 640; -const GLOBE_PADDING = 18; -const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; -interface GlobeLandDot { - x: number; - y: number; - z: number; -} - -const globeDotCoordinateCache = new WeakMap< - MapGeoJson, - Map ->(); - -/** - * d3-geo uses spherical winding (small exterior rings run clockwise), while - * RFC 7946 GeoJSON commonly uses counter-clockwise exterior rings. In that - * case d3 interprets a country as the rest of the globe, painting over the - * ocean. Rewind polygon rings only when d3 reports the feature as larger than - * a hemisphere. - */ -function normalizeGlobeFeature( - feature: MapGeoJson["features"][number], -): GeoPermissibleObjects { - const permissible = feature as unknown as GeoPermissibleObjects; - const geometry = feature.geometry; - if (!geometry || typeof geometry !== "object") return permissible; - - const typedGeometry = geometry as Record; - const coordinates = typedGeometry.coordinates; - if (!Array.isArray(coordinates)) return permissible; - - const normalizePolygon = (polygon: unknown): unknown => { - if (!Array.isArray(polygon)) return polygon; - const polygonObject = { - type: "Polygon", - coordinates: polygon, - } as unknown as GeoPermissibleObjects; - if (geoArea(polygonObject) <= 2 * Math.PI) return polygon; - return polygon.map((ring) => - Array.isArray(ring) ? [...ring].reverse() : ring, - ); - }; - - // Check every MultiPolygon part independently. Rewinding the entire feature - // based on its aggregate area can invert otherwise-correct islands and create - // long antimeridian wedges (most visibly around Russia and Alaska). - const normalizedCoordinates = - typedGeometry.type === "Polygon" - ? normalizePolygon(coordinates) - : typedGeometry.type === "MultiPolygon" - ? coordinates.map(normalizePolygon) - : coordinates; - - return { - ...feature, - geometry: { ...typedGeometry, coordinates: normalizedCoordinates }, - } as unknown as GeoPermissibleObjects; -} - -/** - * GlobeMap — an SVG orthographic globe with dotted land and geographic - * markers. Land boundaries are used only for dot placement and are never - * drawn. Rendering is SVG-only and does not use WebGL. - */ -export function GlobeMap({ - geoJson, - landColor, - landDotSpacing = 10, - oceanColor = "var(--color-kumo-base)", - markers = [], - markerColor, - markerRadius = 7, - onMarkerClick, - rotation = [-10, -20, 0], - draggable = true, - autoRotate = false, - autoRotateSpeed = 4, - showGraticule = false, - showTooltip = true, - onRotationChange, - "aria-label": ariaLabel = "Interactive globe map", - height, - className, - isDarkMode, -}: GlobeMapProps) { - const [currentRotation, setCurrentRotation] = useState(rotation); - const fadeMaskId = `kumo-globe-fade-${useId().replaceAll(":", "")}`; - const [tooltip, setTooltip] = useState(null); - const didDragRef = useRef(false); - const autoRotateFrameRef = useRef(null); - const autoRotateObserverRef = useRef(null); - const autoRotateTimeRef = useRef(null); - const dragFrameRef = useRef(null); - const pendingRotationRef = useRef<[number, number, number] | null>(null); - const dragRef = useRef<{ - pointerId: number; - x: number; - y: number; - rotation: [number, number, number]; - } | null>(null); - - const palette = useMemo( - () => ChartPalette.mapColors(isDarkMode), - [isDarkMode], - ); - const resolvedLandColor = landColor ?? palette.area; - const resolvedMarkerColor = markerColor ?? palette.bubble; - - const globeFeatures = useMemo( - () => - geoJson.features.map((feature) => { - const geometry = normalizeGlobeFeature(feature); - return { geometry, bounds: geoBounds(geometry) }; - }), - [geoJson], - ); - - const projection = useMemo( - () => - geoOrthographic() - .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) - .scale(GLOBE_RADIUS) - .rotate(currentRotation) - // Clip just inside the mathematical horizon. d3's default is 90° plus - // epsilon, which can leak resampled fragments from the hidden hemisphere - // into the visible globe for polygons near the antimeridian. - .clipAngle(89.999) - .precision(0.25), - [currentRotation], - ); - const path = useMemo(() => geoPath(projection), [projection]); - const spherePath = path({ type: "Sphere" }) ?? undefined; - const graticulePath = path(geoGraticule().step([15, 10])()) ?? undefined; - const dottedLandCoordinates = useMemo(() => { - if (landDotSpacing <= 0) return []; - - const cached = globeDotCoordinateCache.get(geoJson)?.get(landDotSpacing); - if (cached) return cached; - - const coordinates: GlobeLandDot[] = []; - const angularStep = (landDotSpacing / GLOBE_RADIUS) * (180 / Math.PI); - for ( - let latitude = -90 + angularStep / 2; - latitude < 90; - latitude += angularStep - ) { - const longitudeStep = - angularStep / Math.max(Math.cos((latitude * Math.PI) / 180), 0.15); - for ( - let longitude = -180 + longitudeStep / 2; - longitude < 180; - longitude += longitudeStep - ) { - const coordinate: [number, number] = [longitude, latitude]; - if ( - globeFeatures.some(({ geometry, bounds }) => { - const [[west, south], [east, north]] = bounds; - if (latitude < south || latitude > north) return false; - const isWithinLongitude = - west <= east - ? longitude >= west && longitude <= east - : longitude >= west || longitude <= east; - return isWithinLongitude && geoContains(geometry, coordinate); - }) - ) { - const longitudeRadians = (longitude * Math.PI) / 180; - const latitudeRadians = (latitude * Math.PI) / 180; - const cosLatitude = Math.cos(latitudeRadians); - coordinates.push({ - x: Math.cos(longitudeRadians) * cosLatitude, - y: Math.sin(longitudeRadians) * cosLatitude, - z: Math.sin(latitudeRadians), - }); - } - } - } - const cacheForGeoJson = - globeDotCoordinateCache.get(geoJson) ?? new Map(); - cacheForGeoJson.set(landDotSpacing, coordinates); - globeDotCoordinateCache.set(geoJson, cacheForGeoJson); - return coordinates; - }, [geoJson, globeFeatures, landDotSpacing]); - const dottedLandPath = useMemo(() => { - const radius = Number((landDotSpacing * 0.28).toFixed(2)); - const diameter = radius * 2; - const [longitude, latitude, roll] = currentRotation.map( - (degrees) => (degrees * Math.PI) / 180, - ); - const cosLongitude = Math.cos(longitude); - const sinLongitude = Math.sin(longitude); - const cosLatitude = Math.cos(latitude); - const sinLatitude = Math.sin(latitude); - const cosRoll = Math.cos(roll); - const sinRoll = Math.sin(roll); - const commands: string[] = []; - - // This is algebraically equivalent to d3's spherical rotation followed by - // its orthographic projection, without function calls or allocations per dot. - for (const dot of dottedLandCoordinates) { - const rotatedX = dot.x * cosLongitude - dot.y * sinLongitude; - const rotatedY = dot.y * cosLongitude + dot.x * sinLongitude; - const latitudeAxis = dot.z * cosLatitude + rotatedX * sinLatitude; - const depth = rotatedX * cosLatitude - dot.z * sinLatitude; - if (depth <= 0) continue; - - const projectedX = rotatedY * cosRoll - latitudeAxis * sinRoll; - const projectedY = latitudeAxis * cosRoll + rotatedY * sinRoll; - const x = Number( - (GLOBE_VIEWBOX_SIZE / 2 + GLOBE_RADIUS * projectedX).toFixed(2), - ); - const y = Number( - (GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS * projectedY).toFixed(2), - ); - commands.push( - `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, - ); - } - return commands.join("") || undefined; - }, [currentRotation, dottedLandCoordinates, landDotSpacing]); - - const moveTooltip = useCallback( - ( - event: ReactPointerEvent, - tooltipName: string, - detail: string, - ) => { - if (!showTooltip) return; - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: tooltipName, - detail, - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, - }); - }, - [showTooltip], - ); - - const handleSvgRef = useCallback( - (node: SVGSVGElement | null) => { - if (autoRotateFrameRef.current !== null) { - cancelAnimationFrame(autoRotateFrameRef.current); - autoRotateFrameRef.current = null; - } - autoRotateObserverRef.current?.disconnect(); - autoRotateObserverRef.current = null; - autoRotateTimeRef.current = null; - if (!node || !autoRotate) return; - - const rotate = (time: number) => { - const previousTime = autoRotateTimeRef.current; - autoRotateTimeRef.current = time; - if (previousTime !== null && !dragRef.current) { - const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); - setCurrentRotation((current) => [ - current[0] + autoRotateSpeed * deltaSeconds, - current[1], - current[2], - ]); - } - autoRotateFrameRef.current = requestAnimationFrame(rotate); - }; - const start = () => { - if (autoRotateFrameRef.current !== null) return; - autoRotateTimeRef.current = null; - autoRotateFrameRef.current = requestAnimationFrame(rotate); - }; - const stop = () => { - if (autoRotateFrameRef.current === null) return; - cancelAnimationFrame(autoRotateFrameRef.current); - autoRotateFrameRef.current = null; - autoRotateTimeRef.current = null; - }; - - if (typeof IntersectionObserver === "undefined") { - start(); - return; - } - autoRotateObserverRef.current = new IntersectionObserver(([entry]) => { - if (entry?.isIntersecting) start(); - else stop(); - }); - autoRotateObserverRef.current.observe(node); - }, - [autoRotate, autoRotateSpeed], - ); - - const handlePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (!draggable) return; - event.currentTarget.setPointerCapture(event.pointerId); - didDragRef.current = false; - dragRef.current = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - rotation: currentRotation, - }; - setTooltip(null); - }, - [currentRotation, draggable], - ); - - const applyPendingRotation = useCallback(() => { - dragFrameRef.current = null; - const next = pendingRotationRef.current; - pendingRotationRef.current = null; - if (!next) return; - setCurrentRotation(next); - onRotationChange?.(next); - }, [onRotationChange]); - - const handlePointerMove = useCallback( - (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - const deltaX = event.clientX - drag.x; - const deltaY = event.clientY - drag.y; - if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; - pendingRotationRef.current = [ - drag.rotation[0] + deltaX * 0.3, - Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), - drag.rotation[2], - ]; - if (dragFrameRef.current === null) { - dragFrameRef.current = requestAnimationFrame(applyPendingRotation); - } - }, - [applyPendingRotation], - ); - - const handlePointerUp = useCallback( - (event: ReactPointerEvent) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - event.currentTarget.releasePointerCapture(event.pointerId); - if (dragFrameRef.current !== null) { - cancelAnimationFrame(dragFrameRef.current); - dragFrameRef.current = null; - } - applyPendingRotation(); - }, - [applyPendingRotation], - ); - - return ( -
- { - if (!dragRef.current) setTooltip(null); - }} - > - - - - - - - - - - - {showGraticule ? ( - - ) : null} - - - {markers.map((marker, index) => { - const markerPath = path.pointRadius(marker.radius ?? markerRadius)({ - type: "Point", - coordinates: [marker.longitude, marker.latitude], - }); - if (!markerPath) return null; - const detail = - marker.description ?? - `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; - const activateMarker = () => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - onMarkerClick?.(marker); - }; - return ( - - moveTooltip(event, marker.name, detail) - } - onPointerMove={(event) => { - if (!dragRef.current) moveTooltip(event, marker.name, detail); - }} - onPointerLeave={() => setTooltip(null)} - onFocus={(event) => { - if (!showTooltip) return; - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: marker.name, - detail, - x: bounds.width / 2, - y: bounds.height / 2, - }); - }} - onBlur={() => setTooltip(null)} - onClick={activateMarker} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateMarker(); - }} - /> - ); - })} - - - - {tooltip ? ( -
- {tooltip.name} - {tooltip.detail} -
- ) : null} -
- ); -} - -GlobeMap.displayName = "GlobeMap"; - /** Register the GeoJSON with ECharts before the child Chart's setOption runs. */ function useRegisterMap( ec: typeof echarts, diff --git a/packages/kumo/src/components/chart/globe-land-mask.ts b/packages/kumo/src/components/chart/globe-land-mask.ts new file mode 100644 index 0000000000..a74d1874de --- /dev/null +++ b/packages/kumo/src/components/chart/globe-land-mask.ts @@ -0,0 +1,23 @@ +/** + * One-degree land occupancy mask generated from Natural Earth 1:110m land. + * Natural Earth data is public domain: https://www.naturalearthdata.com/about/terms-of-use/ + * Source: https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_110m_land.geojson + * Generated by: packages/kumo/scripts/generate-globe-land-mask.mjs + */ +const LAND_MASK_BASE64 = + "////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////f///////////////////////////////////////////////////////3x8AAP7///////////////////////////////////////////////////8/AAAA/P///////////////////////////////////////////////////x8AAAAA+P//////////////4f///////////////////////////////////wAAAAAA/v////////////8HAAD4////////////////////////////////HwAAAAAAAP7//////////wf8wD8AwP//////////////////////////////DwAAAAAcgP//////////fwAAAP4BwP//////////////////////////////HwAAAACH////////////PwAAAPgA/////////////////////////////////wcAAADA/////////////wEAAAAA8P///////////////////////////////wAAAAAAAPz//////////z8AAAAAAP///////////////////////////////wAAAAAAAPz///////////8HAAAAAAD+/////////////////////////////wAAAAAAAADwv/MXgP////8fAAAAAADw/////////////////////////////wMAAAAAAAAAYAYA4ON/OPh/AAAAAADw/////////////////////////////x8AAAAAAAAAAAAAAAAAAAB+AAAAAACA/////////////////////////////38AAAAAAAAAAAAAAAAAAP5+AAAAAAAA5v////////////D//////////////38AAAAAAAAAAAAAAAAAAHA/AAAAAAAAAAA8//P//////+D/////////////HwAAAAAAAAAAAAAAAAAAADAfAAAAAAAAAAAAAADw8////wP+////////////AwAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAgP///wH4//////////8DAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAPD/AADA/////////z8AAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAIAfAAAAAAAeeADADwAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAMABAAAAAAAAAAAAAAAAAPwfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAIADAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAALAAAAAAAAAAAAAAAAAPw/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAPg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAPj/AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgAAAA4AAAAAAAAAAAAAAAAAPz/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAIAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAEAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP8DAAACAAAAAAAAAAAAAAAAAPD/OwAAAAAAAAAAgAEAAAAAAAAAAAAAAAcA6P8HAAAAAAAAAAAAAAAAAAAAAPD/fwAAAAAAAAAAwH8AAAAAAAAAAAAAgP8A2P8HAAAAAAAAAAAAAAAAAAAAAOD//wAAAAAAAAAAwP8BAAAAAAAAAAAAAP8D/P8PAAAAAAAAAAAAAAAAAAAAAPD//wEAAAAAAAAAwP8DAAAAAAAAAAAAAP////8fAAAAAAAAAAAAAAAAAAAAAPD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAOD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA8P8PAAAAAAAAAAAAgP////8/AAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAA4P////8fAAAAAAAAAAAAAAAAAAAAAOD//w8AAAAAAAAA+P8fAAIAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//x8AAAAAAAAA+P9/AAcAAAAAAAAA4P////8PAAAAAAAAAAAAAAAAAAAAAMD//38AAAAAAAAA/P9/AA8AAAAAAAAAwP////8HAAAAAAAAAAAAAAAAAAAAAMD///8DAAAAAAAA/P//gA8AAAAAAAAAwP////8DAAAAAAAAAAAAAAAAAAAAAMD///8HAAAAAAAA/P9/gA8AAAAAAAAAgP////8BAAIAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAPz///8BAAEAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAOD//38AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA////AB8AAAAAAAAAAMD//z8AAAAAAAAAAAAAAAAAAAAAAOD///8fAAAAAAAA////Ax8AAAAAAAAAAMD//z4AAABAAAAAAAAAAAAAAAAAAPj///8fAAAAAAAA////Dz8AAAAAAAAAAAD/Pz4AAAiAAAAAAAAAAAAAAAAAAP7///8fAAAAAAAA////DzgAAAAAAAAAAAD/DxwAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA////HzAAAAAAAAAAAADsDxwAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA/v//HyAAAAAAAAAAAADADwwAAAAAAAAAAAAAAAAAAAAAgP////8/AAAAAAAA/v//HyAAAAAAAAAAAACAHwQAAAAAAAAAAAAAAAAAAAAAgP////9/AAAAAAAA/P//DwAAAAAAAAAAAAAAAQQAAAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/v//DwAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAA4P//////AAAAAAAA/v//DwAAAAAAAAAAAAgBAIABMAAAAAAAAAAAAAAAAAAA4P//////AQAAAAAA/v//BwAAAAAAAAAAQFYEAMcAAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA/v//BwAAAAAAAACAHwAAwH8ABAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAADAAQAAxP+AAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAAAwAEAAxD8NAAAAAAAAAAAAAAAAAAAA+P////8/AAAAAACA////BwAAAAAAAAA4AEAA8B8AAAAAAAAAAAAAAAAAAAAA8P////8fAAAAAACA////DwAAAAAAAAA8wChE/gcAAAAAAAAAAAAAAAAAAAAA+P///38BAAAAAADA////HwAAAAAAAAA+/DkA8gAAAAAAAAAAAAAAAAAAAAAA+P///38AAAAAAADg////PwAAAAAAAAAO/DmAAwAAAAAAAAAAAAAAAAAAAAAA8P///wcAAAAAAADg////PwAAAAAAAAAP/gMAAQAAAAAAAAAAAAAAAAAAAAAA8P///wMAAAAAAADg////fwAAAAAAAIAH/vMQAAAAAAAAAAAAAAAAAAAAAAAA4P///wMAAAAAAADg/////wEAAAAAAIAL/gMQAAAAAAAAAAAAAAAAAAAAAAAA4P///wEAAAAAAADA/////wMAAAAAAMAM+AMAAAAAAAAAAAAAAAAAAAAAAAAAgP///wEAAAAAAADA/////wcAAAAAAGAG4AEAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAEAD8/////w8AAAAAADAGwAcAAAAAAAAAAAAAAAAAAAAAAAAAwP//fwAAAAAA/A/+/////x8AAAAAAAAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAgP//AwAAAAAA/v///////x8AAAAwAAADAAMBAAAAAAAAAAAAAAAAAAAAAAAAyP//AQAAAACA/////////z8AAAAwAIAAAEAHAAAAAAAAAAAAAAAAAAAAAAAAz///AAAAAACA/////////z8AAAASAMAAAIACAAAAAAAAAAAAAAAAAAAAAAAAE+9/AAAAAADA/////////38AAAAXAEAgAAQCAAAAAAAAAAAAAAAAAAAAAADAAe9TAAAAAADg/////////34AAAAPAEBwAAgAAAAAAAAAAAAAAAAAAAAAAADAAAgAAAAAAADw////////f2AAAAAPAID4AUACAAAAAAAAAAAAAAAAAAAAAADgAQAAAAAAAAD4////////fwAAAIAPAMD8AQABAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAD4////////vwcAAIAPAMD/AcAAAAAAAAAAAAAAAAAAAAAAAAD/AQAAAAAAAAD4////////nx8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAID/AAAAAAAAAAD4////////j/8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAP4PAAAAAAAAAAD4////////h/8BAOA/APz/ADAAAAAAAAAAAAAAAAAAAAAAgP8PAAAAAAAAAADw////////x/8HAOB/APx/ADAAAAAAAAAAAAAAAAAAAAAA8J8PAPwCAAAAAADw////////4/8fAOD/APw/AgAAAAAAAAAAAAAAAQAAAAAA+A8eADgAAAAAAADw////////4f8/AOD/Afw/BgAAAAAAAAAAAAAAAAAAAAAA/AccgAEAAAAAAADw////////8f9/AOD/B/5/AAAAAAAAAAAAAAAAAAAAAAAA+AcAYAAAAAAAAAD4////////+f9/APz/B///BgAAAAAAAAAAAAAAAAAAAAAA/AMAHQAAAAAAAADw////////+P//APz/f///fxAAAAAAAAAAAAAAAAAAAAAA/gMAAAAAAAAAAADw////////+P9/AP///////xEAAAAAAAAAAAAAAAAAAAAQ/wMAAAAAAAAAAADg//////9//n8cgP///////ycAAAAAAAAAAAAAAAAAAACQ/wcACAAAAAAAAADg//////9//r+A/////////wcAAAAAAAAAAAAAAAAAAACI/wcALAAAAAAAAADA//////8//z/g/////////w8AAAAAAAAAAAAAAAAAAADu/wcADgAAAAAAAACA//////8f/x/+/////////x8AAAAAAAAAAAAAAAAAAAD0/w8ABgAAAAAAAAAA/v////+//4///////////z8AAAAAAAAAAAAAAAAAAADy/x8GBgAAAAAAAAAA/P/////v/8///////////z8AAAAAAAAAAAAAAAAAAAD5////BwAAAAAAAAAA/P//f////////////////x8AAAAAAAAAAAAAAAAAAID9////BwAAAAAAAAAA/P//D3+E/////////////z9AAAAAAAAAAAAAAAAAAID/////DwAAAAAAAAAA+P//BwaA/////////////x/AAAAAAAAAAAAAAAAAAMD/////HwAAAAAAAAAA8P9/AACA/////////////x/AAwAAAAAAAAAAAAAAAPD/////fwAAAAAAAAAA4P8/AAAA/////////////w+EGwAAAAAAAAAAAAAAAPj//////wAAAAAAAAAAQPx/AAAA/////////////w8Y/gEAAAAAAAAAAAAAAPz//////wAAAAAAAAAAQOB/AAQ2/////////////x8c8AEAAAAAAAAAAAAAAP7//////wAAAAAAAAAA+AcABob//z/8/////////zccwAEAAAAAAAAAAAAAAP7//////wEAAAAAAAAA+A8AAM7//x/8/////////wMOAAEAAAAAAAAAAAAAAP///////wMAAAAAAAAA+A8wEMf//x/+/////////0cOAAMAAAAAAAAAAAAAAP///////wMAAAAAAAAA+B8wuE/+/z/+/////////98fAAMAAAAAAAAAAAAAAP///////x8AAAAAAAAA+D8Aj//w4R/8//////////8/AAAAAAAAAAAAAAAAAP///////x8AAAAAAAAA+H+Aw/8AwA/+//////////9/AA8AAAAAAAAAAAAAAP///////z8AAAAAAAAAcPzH8/8A8I//////////////Bx4AAAAAAAAAAAAAAP////////8MAAAAAAAAAPj/+P8B/Mf/////////////DwQAAAAAAAAAAAAAAP////////9/AAAAAAAAAPj//f9j/g/+////////////HwAAAAAAAAAAAAAAAP////////+XAAAAAAAAAPz///93/D/+////////////PwwAAAAAAAAAAAAAAP3///////8HUAAAAAAAAP7/////////////////////fwQAAAAAAAAAAAAAAP7//////38HfgAAAAAAgP///////////////////////wQAAAAAAAAAAAAAYP////////8xLAAAAAAAAPz//////////////////////wwAAAAAAAAAAAAA0P//////////DAAAAAAAgOH//////////////////////w0AAAAAAAAAAAAA8P//////7///FwAAAAAAAB///////////////////////wUAAgAAAAAAAAAA8P//////4///DwAAAAAAGD///////////////////////wUAAwAAAAAAAACA+P//////4///DwAAAAAAOA74/////////////////////wUADwAAAIAAAAAA/v//////8///BwAAAAAAOAcgcP//////////////////HwAAHwAAAAAGAAAA//////8/wP//AAAAAAAAwAOwA/7/////////////////DwCAPwAAAAAwAAAA//////8PgP8/AAAAAAAAwAFwD/7/////////////////PwAAfwAAAADADgDA/////38AgP8/AAAAAAAAwANAH/T//////////////////wAAfgAAAACAAwD8/////38AwP8eAAAAAAAAgAGcH/D//////////////////wEAOAAAAAD8MwD//////x8AwH8MAAAAAAAAAAD+P2D///////////////////95cAAAAID///z//////z8AwD8AAMABAAAAAAD+P/z///////////////////9/wH8AAMD//////////z8AwA8AAPgBAAAAAAD+H/7/////////////////////Of8BAID///////////8AQ4ABAPwBAAAAAAD8P/7///////////////////////9fAAP4///////////BBPAHAP4HAAAAAADg//z///////////////////////9/QID4///////////Pw/0DAP8HAMAfAACA//F//v////////////////////8//vD///////////9fAPwYgP8PAOA/AAAA/+N/8P//////////////////////34H8////////////Afw/gP//AQAQAAAA/v8fH////+//////////////////DwD////////////vh/kPwP//BwAAAAAA/P//H/P//5//////////////////A+D///////8DB8TPB/4AAP///wAAAAAA8P//AwDjf77/////////////////AAD+///h/4//H+/Bg/8BwP7//w8AAAAAwP8fAAAAj9///////////////88/AAD4HwAAAAD4H/D4/38A4P///xMAAAAAAPAHAAAAgN//////////////DwAAAQAAAAAAAO//B3f8/wsA4P///z8AAAAAAAAAAAAPAG////////8f2f8HAAAAAAAAAAAAgN9/xzf8RwAA4P///z8AAAAAAAAAAAAPAB7g//////8fgD8AAAAAAAAAAAAAAP8Ahvc5OwAA8P///38AAAAAAAAAAAAcAADg////34cHAAcAAAAAAAAAAAAAAAAMAAB8AgAA+P////8AAAAAAAAAAABwAAAA/P//AwAAAAAAAAAAAAAAAAAAAIC/g7PfBwAA/v////8AAAAAAAAAAADgBwAAwP//PwAA4B8AAAAAAAAAAAAAADzAgPP5H8D//////38AAAAAAAAAAAAA4AEAAAD+DwAAAAAAAAAAAAAAAAAAAOAQACDwP4D///////8BAAAAPAQAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAA4+I73//n///////8AAAAAfwAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAAAAAOD//wP8//////8BAACAfx4AAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAAAAPAH/j/+//////8PAAAAgB0AAHgAAAAAAH8AAAAAAAAAAAAAAAAAAAAAAAAAAAD+///D/////5//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/v8/AOD//z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const LAND_MASK_WIDTH = 360; +const landMask = Uint8Array.from(atob(LAND_MASK_BASE64), (value) => + value.charCodeAt(0), +); + +export function isCanonicalLand(longitude: number, latitude: number): boolean { + const column = Math.max( + 0, + Math.min(LAND_MASK_WIDTH - 1, Math.floor(longitude + 180)), + ); + const row = Math.max(0, Math.min(179, Math.floor(latitude + 90))); + const index = row * LAND_MASK_WIDTH + column; + return (landMask[index >> 3]! & (1 << (index & 7))) !== 0; +} diff --git a/packages/kumo/src/components/chart/index.ts b/packages/kumo/src/components/chart/index.ts index 241c69617d..1875b9bf41 100644 --- a/packages/kumo/src/components/chart/index.ts +++ b/packages/kumo/src/components/chart/index.ts @@ -24,15 +24,13 @@ export { export { BubbleMap, ChoroplethMap, - GlobeMap, type MapGeoJson, type MapProjection, type MapAccessor, type MapStyle, type BubbleMapProps, type ChoroplethMapProps, - type GlobeMapProps, - type GlobeMapMarker, } from "./Maps"; +export { GlobeMap, type GlobeMapProps, type GlobeMapMarker } from "./GlobeMap"; // Re-export color utilities for consumers who need to match chart colors outside of a chart instance export { ChartPalette } from "./Color"; From 1ea39f9290862e9de69dbfbe237f9dd92242199d Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 3 Sep 2026 14:54:01 -0400 Subject: [PATCH 08/15] feat(chart): add d3 globe comparison --- packages/kumo-docs-astro/package.json | 2 + .../components/demos/Chart/GlobeMapDemo.tsx | 201 ++++++++++++++++++ .../src/pages/charts/maps.astro | 4 +- .../kumo/src/components/chart/GlobeMap.tsx | 88 ++++---- pnpm-lock.yaml | 26 ++- 5 files changed, 260 insertions(+), 61 deletions(-) diff --git a/packages/kumo-docs-astro/package.json b/packages/kumo-docs-astro/package.json index f9e7482484..a97dfba3bd 100644 --- a/packages/kumo-docs-astro/package.json +++ b/packages/kumo-docs-astro/package.json @@ -29,6 +29,7 @@ "@types/turndown": "5.0.6", "astro": "7.1.1", "clsx": "catalog:", + "d3-geo": "^3.1.1", "echarts": "^6.0.0", "marked": "^18.0.6", "match-sorter": "^8.2.0", @@ -43,6 +44,7 @@ "@astrojs/check": "^0.9.9", "@astrojs/react": "^6.0.1", "@tailwindcss/vite": "^4.3.3", + "@types/d3-geo": "^3.1.0", "@types/mdast": "4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 3746869559..86fae81c78 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -1,4 +1,12 @@ import { GlobeMap, type GlobeMapMarker } from "@cloudflare/kumo"; +import { + geoDistance, + geoGraticule10, + geoOrthographic, + geoPath, + type GeoPermissibleObjects, +} from "d3-geo"; +import { useEffect, useId, useState, type PointerEvent } from "react"; import { useIsDarkMode } from "~/lib/use-is-dark-mode"; const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ @@ -73,3 +81,196 @@ export function GlobeMapAvailabilityZonesDemo() {
); } + +const LAND_URL = + "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_110m_land.geojson"; + +function D3OrthographicGlobePrototype({ isDarkMode }: { isDarkMode: boolean }) { + const [land, setLand] = useState(null); + const [tooltip, setTooltip] = useState<{ + name: string; + detail: string; + x: number; + y: number; + } | null>(null); + const [rotation, setRotation] = useState<[number, number]>([-10, -20]); + const [dragStart, setDragStart] = useState<{ + x: number; + y: number; + rotation: [number, number]; + } | null>(null); + const landPatternId = `d3-globe-land-${useId().replaceAll(":", "")}`; + const landStroke = isDarkMode + ? "var(--text-color-kumo-inactive)" + : "var(--text-color-kumo-subtle)"; + + useEffect(() => { + void fetch(LAND_URL) + .then((response) => response.json()) + .then((data: unknown) => setLand(data as GeoPermissibleObjects)); + }, []); + + const projection = geoOrthographic() + .translate([320, 320]) + .scale(302) + .clipAngle(90) + .rotate(rotation); + const path = geoPath(projection); + const center = projection.invert?.([320, 320]); + const handlePointerDown = (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setDragStart({ x: event.clientX, y: event.clientY, rotation }); + }; + const handlePointerMove = (event: PointerEvent) => { + if (!dragStart) return; + setRotation([ + dragStart.rotation[0] + (event.clientX - dragStart.x) * 0.3, + Math.max( + -90, + Math.min( + 90, + dragStart.rotation[1] - (event.clientY - dragStart.y) * 0.3, + ), + ), + ]); + }; + const showTooltip = ( + event: PointerEvent, + marker: GlobeMapMarker, + ) => { + const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail: + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`, + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + }; + + return ( +
+ setDragStart(null)} + onPointerCancel={() => setDragStart(null)} + > + {land ? ( + + + + + + ) : null} + + + {land ? ( + + ) : null} + {cloudflareAvailabilityLocations.map((marker) => { + const position = projection([marker.longitude, marker.latitude]); + const isVisible = + center && + geoDistance(center, [marker.longitude, marker.latitude]) <= + Math.PI / 2; + return position && isVisible ? ( + showTooltip(event, marker)} + onPointerMove={(event) => { + if (!dragStart) showTooltip(event, marker); + }} + onPointerLeave={() => setTooltip(null)} + /> + ) : null; + })} + + + {tooltip ? ( +
+ {tooltip.name} + {tooltip.detail} +
+ ) : null} +
+ ); +} + +/** Local comparison of the shipped globe renderer and d3-geo's orthographic projection. */ +export function GlobeMapComparisonDemo() { + const isDarkMode = useIsDarkMode(); + + return ( +
+
+

+ Current GlobeMap +

+ +
+
+

+ D3 geoOrthographic prototype +

+
+ +
+
+
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index 3fb61a4ab0..dd91e2d495 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -10,7 +10,7 @@ import { BubbleMapCloudflareLocationsDemo, } from "~/components/demos/Chart/BubbleMapDemo"; import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo"; -import { GlobeMapAvailabilityZonesDemo } from "~/components/demos/Chart/GlobeMapDemo"; +import { GlobeMapComparisonDemo } from "~/components/demos/Chart/GlobeMapDemo"; import type { MapGeoJson } from "@cloudflare/kumo"; const WORLD_GEO_JSON_URL = @@ -192,7 +192,7 @@ export default function Example() { markerRadius={8} autoRotate />`}> - + diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx index abf3c1423f..f64cab8a79 100644 --- a/packages/kumo/src/components/chart/GlobeMap.tsx +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -72,13 +72,6 @@ interface GlobeLandDot { z: number; } -interface GlobeScreenDot { - projectedX: number; - projectedY: number; - depth: number; - command: string; -} - interface GlobeRotationTransform { cosLongitude: number; sinLongitude: number; @@ -89,7 +82,7 @@ interface GlobeRotationTransform { } const GLOBE_SPHERE_PATH = `M${GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS},${GLOBE_VIEWBOX_SIZE / 2}a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 ${GLOBE_RADIUS * 2},0a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 -${GLOBE_RADIUS * 2},0`; -const globeDotCoordinateCache = new Map(); +const globeDotCoordinateCache = new Map(); function toCartesian(longitude: number, latitude: number): GlobeLandDot { const longitudeRadians = (longitude * Math.PI) / 180; @@ -201,32 +194,22 @@ function createGraticulePath( } function createDottedLandPath( - dots: GlobeScreenDot[], - rotation: [number, number, number], + dots: GlobeLandDot[], + transform: GlobeRotationTransform, + radius: number, ): string | undefined { - const { - cosLongitude, - sinLongitude, - cosLatitude, - sinLatitude, - cosRoll, - sinRoll, - } = createRotationTransform(rotation); + const projected: [number, number, number] = [0, 0, 0]; + const diameter = radius * 2; const commands: string[] = []; - // Keep a stable screen-space lattice and inverse-project each point into the - // rotating land mask. Only membership changes; dot geometry never moves. for (const dot of dots) { - const rotatedY = dot.projectedX * cosRoll + dot.projectedY * sinRoll; - const latitudeAxis = dot.projectedY * cosRoll - dot.projectedX * sinRoll; - const rotatedX = dot.depth * cosLatitude + latitudeAxis * sinLatitude; - const worldZ = latitudeAxis * cosLatitude - dot.depth * sinLatitude; - const worldX = rotatedX * cosLongitude + rotatedY * sinLongitude; - const worldY = rotatedY * cosLongitude - rotatedX * sinLongitude; - const longitude = (Math.atan2(worldY, worldX) * 180) / Math.PI; - const latitude = - (Math.asin(Math.max(-1, Math.min(1, worldZ))) * 180) / Math.PI; - if (isCanonicalLand(longitude, latitude)) commands.push(dot.command); + projectVector(dot, transform, projected); + if (projected[2] <= 0) continue; + const x = Number(projected[0].toFixed(2)); + const y = Number(projected[1].toFixed(2)); + commands.push( + `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, + ); } return commands.join("") || undefined; } @@ -314,31 +297,34 @@ export function GlobeMap({ const cached = globeDotCoordinateCache.get(landDotSpacing); if (cached) return cached; - const dots: GlobeScreenDot[] = []; + const dots: GlobeLandDot[] = []; + const geographicSpacing = Math.max(1, Math.round(landDotSpacing * 0.3)); const radius = Number((landDotSpacing * 0.28).toFixed(2)); - const diameter = radius * 2; - const start = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS; - const end = GLOBE_VIEWBOX_SIZE / 2 + GLOBE_RADIUS; - for (let y = start + landDotSpacing / 2; y < end; y += landDotSpacing) { - for (let x = start + landDotSpacing / 2; x < end; x += landDotSpacing) { - const projectedX = (x - GLOBE_VIEWBOX_SIZE / 2) / GLOBE_RADIUS; - const projectedY = (GLOBE_VIEWBOX_SIZE / 2 - y) / GLOBE_RADIUS; - const squaredDistance = - projectedX * projectedX + projectedY * projectedY; - if (squaredDistance >= 1) continue; - dots.push({ - projectedX, - projectedY, - depth: Math.sqrt(1 - squaredDistance), - command: `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, - }); + for ( + let latitude = -90 + geographicSpacing / 2; + latitude < 90; + latitude += geographicSpacing + ) { + for ( + let longitude = -180 + geographicSpacing / 2; + longitude < 180; + longitude += geographicSpacing + ) { + if (isCanonicalLand(longitude, latitude)) { + dots.push(toCartesian(longitude, latitude)); + } } } globeDotCoordinateCache.set(landDotSpacing, dots); return dots; }, [landDotSpacing]); const dottedLandPath = useMemo( - () => createDottedLandPath(dottedLandDots, currentRotationRef.current), + () => + createDottedLandPath( + dottedLandDots, + currentRotationTransform, + Number((landDotSpacing * 0.28).toFixed(2)), + ), [dottedLandDots, landDotSpacing], ); @@ -346,7 +332,11 @@ export function GlobeMap({ (nextRotation: [number, number, number]) => { currentRotationRef.current = nextRotation; - const nextLandPath = createDottedLandPath(dottedLandDots, nextRotation); + const nextLandPath = createDottedLandPath( + dottedLandDots, + createRotationTransform(nextRotation), + Number((landDotSpacing * 0.28).toFixed(2)), + ); if (nextLandPath) landPathRef.current?.setAttribute("d", nextLandPath); else landPathRef.current?.removeAttribute("d"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71c433eb68..7ebc69b6f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,10 +104,10 @@ importers: version: 5.9.3 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.6 - version: '@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.8.9)(jiti@2.7.0)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0) + version: 0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.7.0)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0) packages/kumo: dependencies: @@ -277,6 +277,9 @@ importers: clsx: specifier: 'catalog:' version: 2.1.1 + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 echarts: specifier: ^6.0.0 version: 6.0.0 @@ -314,6 +317,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0)) + '@types/d3-geo': + specifier: ^3.1.0 + version: 3.1.0 '@types/mdast': specifier: 4.0.4 version: 4.0.4 @@ -380,10 +386,10 @@ importers: version: 5.9.3 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.6 - version: '@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.7.0)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0) + version: 0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.8.9)(jiti@2.7.0)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: 4.1.10 version: 4.1.10(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/browser-preview@4.1.10)(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.27.2)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(happy-dom@20.8.9)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3)) @@ -819,31 +825,31 @@ packages: optional: true '@cloudflare/workerd-darwin-64@1.20260430.1': - resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==} + resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260430.1': - resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==} + resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20260430.1': - resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==} + resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260430.1': - resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==} + resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260430.1': - resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==} + resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -8391,7 +8397,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@22.19.1)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/browser-preview@4.1.10)(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@22.19.1)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(happy-dom@20.8.9)(msw@2.12.3(@types/node@22.19.1)(typescript@5.9.3)) + vitest: 4.1.10(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(msw@2.12.3(@types/node@24.13.3)(typescript@5.9.3))(playwright@1.57.0)(vitest@4.1.10))(@vitest/browser-preview@4.1.10)(@vitest/ui@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@arethetypeswrong/core@0.18.5)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(publint@0.3.21)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.9.0))(happy-dom@20.8.9)(msw@2.12.3(@types/node@24.13.3)(typescript@5.9.3)) '@vitest/utils@4.1.10': dependencies: From 9fe4fa38ef9e200dadb72311bc1a2519ec8b7d8b Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 3 Sep 2026 14:55:09 -0400 Subject: [PATCH 09/15] fix(docs): route globe search result --- packages/kumo-docs-astro/src/components/SearchDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kumo-docs-astro/src/components/SearchDialog.tsx b/packages/kumo-docs-astro/src/components/SearchDialog.tsx index 63a2cf34a7..ccd2e64649 100644 --- a/packages/kumo-docs-astro/src/components/SearchDialog.tsx +++ b/packages/kumo-docs-astro/src/components/SearchDialog.tsx @@ -42,6 +42,7 @@ const CHART_COMPONENT_URLS: Record = { TimeseriesChart: "/charts/timeseries", BubbleMap: "/charts/maps#bubble-map", ChoroplethMap: "/charts/maps#choropleth-map", + GlobeMap: "/charts/maps#cloudflare-availability-locations", }; /** From 49a55fccb6b81e815b8ead38e8cf73b05703d135 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 3 Sep 2026 15:09:38 -0400 Subject: [PATCH 10/15] refactor(chart): use d3 globe projection --- packages/kumo-docs-astro/package.json | 2 - .../components/demos/Chart/GlobeMapDemo.tsx | 201 ------ .../src/pages/charts/maps.astro | 6 +- .../kumo/src/components/chart/GlobeMap.tsx | 671 +++++------------- pnpm-lock.yaml | 12 +- 5 files changed, 203 insertions(+), 689 deletions(-) diff --git a/packages/kumo-docs-astro/package.json b/packages/kumo-docs-astro/package.json index a97dfba3bd..f9e7482484 100644 --- a/packages/kumo-docs-astro/package.json +++ b/packages/kumo-docs-astro/package.json @@ -29,7 +29,6 @@ "@types/turndown": "5.0.6", "astro": "7.1.1", "clsx": "catalog:", - "d3-geo": "^3.1.1", "echarts": "^6.0.0", "marked": "^18.0.6", "match-sorter": "^8.2.0", @@ -44,7 +43,6 @@ "@astrojs/check": "^0.9.9", "@astrojs/react": "^6.0.1", "@tailwindcss/vite": "^4.3.3", - "@types/d3-geo": "^3.1.0", "@types/mdast": "4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 86fae81c78..3746869559 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -1,12 +1,4 @@ import { GlobeMap, type GlobeMapMarker } from "@cloudflare/kumo"; -import { - geoDistance, - geoGraticule10, - geoOrthographic, - geoPath, - type GeoPermissibleObjects, -} from "d3-geo"; -import { useEffect, useId, useState, type PointerEvent } from "react"; import { useIsDarkMode } from "~/lib/use-is-dark-mode"; const cloudflareAvailabilityLocations: GlobeMapMarker[] = [ @@ -81,196 +73,3 @@ export function GlobeMapAvailabilityZonesDemo() { ); } - -const LAND_URL = - "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_110m_land.geojson"; - -function D3OrthographicGlobePrototype({ isDarkMode }: { isDarkMode: boolean }) { - const [land, setLand] = useState(null); - const [tooltip, setTooltip] = useState<{ - name: string; - detail: string; - x: number; - y: number; - } | null>(null); - const [rotation, setRotation] = useState<[number, number]>([-10, -20]); - const [dragStart, setDragStart] = useState<{ - x: number; - y: number; - rotation: [number, number]; - } | null>(null); - const landPatternId = `d3-globe-land-${useId().replaceAll(":", "")}`; - const landStroke = isDarkMode - ? "var(--text-color-kumo-inactive)" - : "var(--text-color-kumo-subtle)"; - - useEffect(() => { - void fetch(LAND_URL) - .then((response) => response.json()) - .then((data: unknown) => setLand(data as GeoPermissibleObjects)); - }, []); - - const projection = geoOrthographic() - .translate([320, 320]) - .scale(302) - .clipAngle(90) - .rotate(rotation); - const path = geoPath(projection); - const center = projection.invert?.([320, 320]); - const handlePointerDown = (event: PointerEvent) => { - event.currentTarget.setPointerCapture(event.pointerId); - setDragStart({ x: event.clientX, y: event.clientY, rotation }); - }; - const handlePointerMove = (event: PointerEvent) => { - if (!dragStart) return; - setRotation([ - dragStart.rotation[0] + (event.clientX - dragStart.x) * 0.3, - Math.max( - -90, - Math.min( - 90, - dragStart.rotation[1] - (event.clientY - dragStart.y) * 0.3, - ), - ), - ]); - }; - const showTooltip = ( - event: PointerEvent, - marker: GlobeMapMarker, - ) => { - const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: marker.name, - detail: - marker.description ?? - `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`, - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, - }); - }; - - return ( -
- setDragStart(null)} - onPointerCancel={() => setDragStart(null)} - > - {land ? ( - - - - - - ) : null} - - - {land ? ( - - ) : null} - {cloudflareAvailabilityLocations.map((marker) => { - const position = projection([marker.longitude, marker.latitude]); - const isVisible = - center && - geoDistance(center, [marker.longitude, marker.latitude]) <= - Math.PI / 2; - return position && isVisible ? ( - showTooltip(event, marker)} - onPointerMove={(event) => { - if (!dragStart) showTooltip(event, marker); - }} - onPointerLeave={() => setTooltip(null)} - /> - ) : null; - })} - - - {tooltip ? ( -
- {tooltip.name} - {tooltip.detail} -
- ) : null} -
- ); -} - -/** Local comparison of the shipped globe renderer and d3-geo's orthographic projection. */ -export function GlobeMapComparisonDemo() { - const isDarkMode = useIsDarkMode(); - - return ( -
-
-

- Current GlobeMap -

- -
-
-

- D3 geoOrthographic prototype -

-
- -
-
-
- ); -} diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index dd91e2d495..b780d2cca4 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -10,7 +10,7 @@ import { BubbleMapCloudflareLocationsDemo, } from "~/components/demos/Chart/BubbleMapDemo"; import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo"; -import { GlobeMapComparisonDemo } from "~/components/demos/Chart/GlobeMapDemo"; +import { GlobeMapAvailabilityZonesDemo } from "~/components/demos/Chart/GlobeMapDemo"; import type { MapGeoJson } from "@cloudflare/kumo"; const WORLD_GEO_JSON_URL = @@ -180,7 +180,7 @@ export default function Example() {
Cloudflare Availability Locations

- Plot geographic markers without WebGL. Dotted neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe to reveal points on the hidden hemisphere. + Plot geographic markers without WebGL. Hatched neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe to reveal points on the hidden hemisphere.

`}> - +
diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx index f64cab8a79..5e2facbaac 100644 --- a/packages/kumo/src/components/chart/GlobeMap.tsx +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -1,5 +1,6 @@ import type { PointerEvent as ReactPointerEvent } from "react"; -import { useCallback, useId, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { geoDistance, geoGraticule10, geoOrthographic, geoPath } from "d3-geo"; import { cn } from "../../utils/cn"; import { ChartPalette } from "./Color"; import { isCanonicalLand } from "./globe-land-mask"; @@ -20,9 +21,9 @@ export interface GlobeMapMarker { } export interface GlobeMapProps { - /** Fill for the dotted land. Defaults to the neutral Kumo map area color. */ + /** Stroke color for the hatched land. Defaults to the neutral Kumo map area color. */ landColor?: string; - /** Spacing between land-dot centers in view-box pixels. Default: `10`. */ + /** Spacing between land hatch lines in view-box pixels. Default: `10`. */ landDotSpacing?: number; /** Fill behind the land and graticule. Default: the Kumo base surface. */ oceanColor?: string; @@ -66,172 +67,45 @@ interface GlobeTooltip { const GLOBE_VIEWBOX_SIZE = 640; const GLOBE_PADDING = 18; const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; -interface GlobeLandDot { - x: number; - y: number; - z: number; -} - -interface GlobeRotationTransform { - cosLongitude: number; - sinLongitude: number; - cosLatitude: number; - sinLatitude: number; - cosRoll: number; - sinRoll: number; -} - -const GLOBE_SPHERE_PATH = `M${GLOBE_VIEWBOX_SIZE / 2 - GLOBE_RADIUS},${GLOBE_VIEWBOX_SIZE / 2}a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 ${GLOBE_RADIUS * 2},0a${GLOBE_RADIUS},${GLOBE_RADIUS} 0 1,0 -${GLOBE_RADIUS * 2},0`; -const globeDotCoordinateCache = new Map(); - -function toCartesian(longitude: number, latitude: number): GlobeLandDot { - const longitudeRadians = (longitude * Math.PI) / 180; - const latitudeRadians = (latitude * Math.PI) / 180; - const cosLatitude = Math.cos(latitudeRadians); - return { - x: Math.cos(longitudeRadians) * cosLatitude, - y: Math.sin(longitudeRadians) * cosLatitude, - z: Math.sin(latitudeRadians), - }; -} - -function createRotationTransform( - rotation: [number, number, number], -): GlobeRotationTransform { - const [longitude, latitude, roll] = rotation.map( - (degrees) => (degrees * Math.PI) / 180, - ); - return { - cosLongitude: Math.cos(longitude), - sinLongitude: Math.sin(longitude), - cosLatitude: Math.cos(latitude), - sinLatitude: Math.sin(latitude), - cosRoll: Math.cos(roll), - sinRoll: Math.sin(roll), - }; -} - -function projectVector( - point: GlobeLandDot, - transform: GlobeRotationTransform, - output: [number, number, number], -): void { - const rotatedX = - point.x * transform.cosLongitude - point.y * transform.sinLongitude; - const rotatedY = - point.y * transform.cosLongitude + point.x * transform.sinLongitude; - const latitudeAxis = - point.z * transform.cosLatitude + rotatedX * transform.sinLatitude; - output[2] = - rotatedX * transform.cosLatitude - point.z * transform.sinLatitude; - output[0] = - GLOBE_VIEWBOX_SIZE / 2 + - GLOBE_RADIUS * - (rotatedY * transform.cosRoll - latitudeAxis * transform.sinRoll); - output[1] = - GLOBE_VIEWBOX_SIZE / 2 - - GLOBE_RADIUS * - (latitudeAxis * transform.cosRoll + rotatedY * transform.sinRoll); -} - -const globeGraticuleLines: GlobeLandDot[][] = (() => { - const lines: GlobeLandDot[][] = []; - for (let longitude = -180; longitude < 180; longitude += 15) { - const line: GlobeLandDot[] = []; - for (let latitude = -88; latitude <= 88; latitude += 4) { - line.push(toCartesian(longitude, latitude)); - } - lines.push(line); - } - for (let latitude = -80; latitude <= 80; latitude += 10) { - const line: GlobeLandDot[] = []; - for (let longitude = -180; longitude <= 180; longitude += 4) { - line.push(toCartesian(longitude, latitude)); - } - lines.push(line); - } - return lines; -})(); -function createGraticulePath( - rotation: [number, number, number], +function createLandHatchPath( + projection: ReturnType, + spacing: number, ): string | undefined { - const transform = createRotationTransform(rotation); - const projected: [number, number, number] = [0, 0, 0]; - const commands: string[] = []; + if (spacing <= 0) return undefined; + if (!projection.invert) return undefined; - for (const line of globeGraticuleLines) { - let previousX = 0; - let previousY = 0; - let previousDepth = -1; - for (const point of line) { - projectVector(point, transform, projected); - const [x, y, depth] = projected; - if (depth > 0) { - if (previousDepth <= 0) { - if (previousDepth !== -1) { - const ratio = depth / (depth - previousDepth); - commands.push( - `M${(x + (previousX - x) * ratio).toFixed(2)},${(y + (previousY - y) * ratio).toFixed(2)}`, - ); - } else { - commands.push(`M${x.toFixed(2)},${y.toFixed(2)}`); - } - } - commands.push(`L${x.toFixed(2)},${y.toFixed(2)}`); - } else if (previousDepth > 0) { - const ratio = previousDepth / (previousDepth - depth); - commands.push( - `L${(previousX + (x - previousX) * ratio).toFixed(2)},${(previousY + (y - previousY) * ratio).toFixed(2)}`, - ); + const commands: string[] = []; + const sampleStep = 3; + for ( + let offset = -GLOBE_VIEWBOX_SIZE; + offset < GLOBE_VIEWBOX_SIZE * 2; + offset += spacing + ) { + let drawing = false; + for (let y = 0; y <= GLOBE_VIEWBOX_SIZE; y += sampleStep) { + const x = y + offset; + const coordinates = + x >= 0 && x <= GLOBE_VIEWBOX_SIZE ? projection.invert?.([x, y]) : null; + const onLand = + coordinates !== null && isCanonicalLand(coordinates[0], coordinates[1]); + + if (onLand && !drawing) { + commands.push(`M${x.toFixed(1)},${y.toFixed(1)}`); + drawing = true; + } else if (onLand) { + commands.push(`L${x.toFixed(1)},${y.toFixed(1)}`); + } else { + drawing = false; } - previousX = x; - previousY = y; - previousDepth = depth; } } return commands.join("") || undefined; } -function createDottedLandPath( - dots: GlobeLandDot[], - transform: GlobeRotationTransform, - radius: number, -): string | undefined { - const projected: [number, number, number] = [0, 0, 0]; - const diameter = radius * 2; - const commands: string[] = []; - - for (const dot of dots) { - projectVector(dot, transform, projected); - if (projected[2] <= 0) continue; - const x = Number(projected[0].toFixed(2)); - const y = Number(projected[1].toFixed(2)); - commands.push( - `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`, - ); - } - return commands.join("") || undefined; -} - -function createMarkerPath( - point: GlobeLandDot, - transform: GlobeRotationTransform, - radius: number, -): string | undefined { - const projected: [number, number, number] = [0, 0, 0]; - projectVector(point, transform, projected); - if (projected[2] <= 0) return undefined; - const x = Number(projected[0].toFixed(2)); - const y = Number(projected[1].toFixed(2)); - const diameter = radius * 2; - return `M${x - radius},${y}a${radius},${radius} 0 1,0 ${diameter},0a${radius},${radius} 0 1,0 -${diameter},0`; -} - /** - * GlobeMap — an SVG orthographic globe with dotted land and geographic - * markers. Land boundaries are used only for dot placement and are never - * drawn. Rendering is SVG-only and does not use WebGL. + * GlobeMap — an SVG orthographic globe with hatched land and geographic + * markers. Rendering is SVG-only and does not use WebGL. */ export function GlobeMap({ landColor, @@ -241,7 +115,7 @@ export function GlobeMap({ markerColor, markerRadius = 7, onMarkerClick, - rotation = [-10, -20, 0], + rotation: initialRotation = [-10, -20, 0], draggable = true, autoRotate = false, autoRotateSpeed = 4, @@ -253,24 +127,17 @@ export function GlobeMap({ className, isDarkMode, }: GlobeMapProps) { - const currentRotationRef = useRef(rotation); - const fadeMaskId = `kumo-globe-fade-${useId().replaceAll(":", "")}`; + const [rotation, setRotation] = useState(initialRotation); const [tooltip, setTooltip] = useState(null); - const didDragRef = useRef(false); - const autoRotateFrameRef = useRef(null); - const autoRotateObserverRef = useRef(null); - const autoRotateTimeRef = useRef(null); - const dragFrameRef = useRef(null); - const landPathRef = useRef(null); - const graticulePathRef = useRef(null); - const markerPathRefs = useRef<(SVGPathElement | null)[]>([]); - const pendingRotationRef = useRef<[number, number, number] | null>(null); + const rotationRef = useRef(rotation); + const svgRef = useRef(null); const dragRef = useRef<{ pointerId: number; x: number; y: number; rotation: [number, number, number]; } | null>(null); + const didDragRef = useRef(false); const palette = useMemo( () => ChartPalette.mapColors(isDarkMode), @@ -278,112 +145,86 @@ export function GlobeMap({ ); const resolvedLandColor = landColor ?? palette.area; const resolvedMarkerColor = markerColor ?? palette.bubble; - const markerCoordinates = useMemo( - () => - markers.map((marker) => toCartesian(marker.longitude, marker.latitude)), - [markers], - ); - const currentRotationTransform = createRotationTransform( - currentRotationRef.current, + const projection = geoOrthographic() + .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) + .scale(GLOBE_RADIUS) + .clipAngle(90) + .rotate(rotation); + const path = geoPath(projection); + const landHatchPath = createLandHatchPath(projection, landDotSpacing); + const center = projection.invert?.([ + GLOBE_VIEWBOX_SIZE / 2, + GLOBE_VIEWBOX_SIZE / 2, + ]); + + const updateRotation = useCallback( + (nextRotation: [number, number, number], notify = false) => { + rotationRef.current = nextRotation; + setRotation(nextRotation); + if (notify) onRotationChange?.(nextRotation); + }, + [onRotationChange], ); - const spherePath = GLOBE_SPHERE_PATH; - const graticulePath = showGraticule - ? createGraticulePath(currentRotationRef.current) - : undefined; - const dottedLandDots = useMemo(() => { - if (landDotSpacing <= 0) return []; - - const cached = globeDotCoordinateCache.get(landDotSpacing); - if (cached) return cached; - - const dots: GlobeLandDot[] = []; - const geographicSpacing = Math.max(1, Math.round(landDotSpacing * 0.3)); - const radius = Number((landDotSpacing * 0.28).toFixed(2)); - for ( - let latitude = -90 + geographicSpacing / 2; - latitude < 90; - latitude += geographicSpacing + useEffect(() => { + if (!autoRotate) return; + if ( + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches ) { - for ( - let longitude = -180 + geographicSpacing / 2; - longitude < 180; - longitude += geographicSpacing - ) { - if (isCanonicalLand(longitude, latitude)) { - dots.push(toCartesian(longitude, latitude)); - } - } + return; } - globeDotCoordinateCache.set(landDotSpacing, dots); - return dots; - }, [landDotSpacing]); - const dottedLandPath = useMemo( - () => - createDottedLandPath( - dottedLandDots, - currentRotationTransform, - Number((landDotSpacing * 0.28).toFixed(2)), - ), - [dottedLandDots, landDotSpacing], - ); - const renderRotation = useCallback( - (nextRotation: [number, number, number]) => { - currentRotationRef.current = nextRotation; - - const nextLandPath = createDottedLandPath( - dottedLandDots, - createRotationTransform(nextRotation), - Number((landDotSpacing * 0.28).toFixed(2)), - ); - if (nextLandPath) landPathRef.current?.setAttribute("d", nextLandPath); - else landPathRef.current?.removeAttribute("d"); - - if (graticulePathRef.current) { - const nextGraticulePath = createGraticulePath(nextRotation); - if (nextGraticulePath) { - graticulePathRef.current.setAttribute("d", nextGraticulePath); - } + let frame: number | null = null; + let previousTime: number | null = null; + const rotate = (time: number) => { + if (previousTime !== null && !dragRef.current) { + const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); + const current = rotationRef.current; + updateRotation([ + current[0] + autoRotateSpeed * deltaSeconds, + current[1], + current[2], + ]); } - - const rotationTransform = createRotationTransform(nextRotation); - markers.forEach((marker, index) => { - const element = markerPathRefs.current[index]; - if (!element) return; - const nextMarkerPath = createMarkerPath( - markerCoordinates[index]!, - rotationTransform, - marker.radius ?? markerRadius, - ); - if (!nextMarkerPath) { - element.style.display = "none"; - element.setAttribute("aria-hidden", "true"); - element.setAttribute("tabindex", "-1"); - return; - } - element.style.removeProperty("display"); - element.removeAttribute("aria-hidden"); - element.setAttribute("tabindex", "0"); - element.setAttribute("d", nextMarkerPath); - }); - }, - [landDotSpacing, markerCoordinates, markerRadius, markers, dottedLandDots], - ); + previousTime = time; + frame = requestAnimationFrame(rotate); + }; + const start = () => { + if (frame === null) frame = requestAnimationFrame(rotate); + }; + const stop = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + previousTime = null; + }; + const observer = + typeof IntersectionObserver === "undefined" + ? null + : new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) start(); + else stop(); + }); + + if (observer && svgRef.current) observer.observe(svgRef.current); + else start(); + return () => { + observer?.disconnect(); + stop(); + }; + }, [autoRotate, autoRotateSpeed, updateRotation]); const moveTooltip = useCallback( - ( - event: ReactPointerEvent, - tooltipName: string, - detail: string, - ) => { + (event: ReactPointerEvent, marker: GlobeMapMarker) => { if (!showTooltip) return; const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); if (!bounds) return; setTooltip({ - name: tooltipName, - detail, + name: marker.name, + detail: + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`, x: event.clientX - bounds.left, y: event.clientY - bounds.top, }); @@ -391,124 +232,38 @@ export function GlobeMap({ [showTooltip], ); - const handleSvgRef = useCallback( - (node: SVGSVGElement | null) => { - if (autoRotateFrameRef.current !== null) { - cancelAnimationFrame(autoRotateFrameRef.current); - autoRotateFrameRef.current = null; - } - autoRotateObserverRef.current?.disconnect(); - autoRotateObserverRef.current = null; - autoRotateTimeRef.current = null; - if (!node || !autoRotate) return; - - const rotate = (time: number) => { - const previousTime = autoRotateTimeRef.current; - if (previousTime === null) autoRotateTimeRef.current = time; - if ( - previousTime !== null && - time - previousTime >= 1000 / 30 - 2 && - !dragRef.current - ) { - const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); - autoRotateTimeRef.current = time; - const current = currentRotationRef.current; - renderRotation([ - current[0] + autoRotateSpeed * deltaSeconds, - current[1], - current[2], - ]); - } - autoRotateFrameRef.current = requestAnimationFrame(rotate); - }; - const start = () => { - if (autoRotateFrameRef.current !== null) return; - autoRotateTimeRef.current = null; - autoRotateFrameRef.current = requestAnimationFrame(rotate); - }; - const stop = () => { - if (autoRotateFrameRef.current === null) return; - cancelAnimationFrame(autoRotateFrameRef.current); - autoRotateFrameRef.current = null; - autoRotateTimeRef.current = null; - }; - - if ( - typeof matchMedia === "function" && - matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - return; - } - if (typeof IntersectionObserver === "undefined") { - start(); - return; - } - autoRotateObserverRef.current = new IntersectionObserver(([entry]) => { - if (entry?.isIntersecting) start(); - else stop(); - }); - autoRotateObserverRef.current.observe(node); - }, - [autoRotate, autoRotateSpeed, renderRotation], - ); - - const handlePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (!draggable) return; - event.currentTarget.setPointerCapture(event.pointerId); - didDragRef.current = false; - dragRef.current = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - rotation: currentRotationRef.current, - }; - setTooltip(null); - }, - [draggable], - ); - - const applyPendingRotation = useCallback(() => { - dragFrameRef.current = null; - const next = pendingRotationRef.current; - pendingRotationRef.current = null; - if (!next) return; - renderRotation(next); - onRotationChange?.(next); - }, [onRotationChange, renderRotation]); - - const handlePointerMove = useCallback( - (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - const deltaX = event.clientX - drag.x; - const deltaY = event.clientY - drag.y; - if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; - pendingRotationRef.current = [ + const handlePointerDown = (event: ReactPointerEvent) => { + if (!draggable) return; + event.currentTarget.setPointerCapture(event.pointerId); + didDragRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + rotation: rotationRef.current, + }; + setTooltip(null); + }; + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; + updateRotation( + [ drag.rotation[0] + deltaX * 0.3, Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), drag.rotation[2], - ]; - if (dragFrameRef.current === null) { - dragFrameRef.current = requestAnimationFrame(applyPendingRotation); - } - }, - [applyPendingRotation], - ); - - const handlePointerUp = useCallback( - (event: ReactPointerEvent) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - event.currentTarget.releasePointerCapture(event.pointerId); - if (dragFrameRef.current !== null) { - cancelAnimationFrame(dragFrameRef.current); - dragFrameRef.current = null; - } - applyPendingRotation(); - }, - [applyPendingRotation], - ); + ], + true, + ); + }; + const handlePointerUp = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }; return (
- - - - - - - - - {showGraticule ? ( ) : null} - - {markers.map((marker, index) => { - const markerPath = createMarkerPath( - markerCoordinates[index]!, - currentRotationTransform, - marker.radius ?? markerRadius, - ); - const detail = - marker.description ?? - `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; - const activateMarker = () => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - onMarkerClick?.(marker); - }; - return ( - { - markerPathRefs.current[index] = node; - }} - d={markerPath ?? undefined} - style={markerPath ? undefined : { display: "none" }} - fill={marker.color ?? resolvedMarkerColor} - mask={`url(#${fadeMaskId})`} - strokeWidth={2} - className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" - role="button" - aria-label={`${marker.name}: ${detail}`} - tabIndex={markerPath ? 0 : -1} - aria-hidden={markerPath ? undefined : true} - onPointerEnter={(event) => - moveTooltip(event, marker.name, detail) - } - onPointerMove={(event) => { - if (!dragRef.current) moveTooltip(event, marker.name, detail); - }} - onPointerLeave={() => setTooltip(null)} - onFocus={(event) => { - if (!showTooltip) return; - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: marker.name, - detail, - x: bounds.width / 2, - y: bounds.height / 2, - }); - }} - onBlur={() => setTooltip(null)} - onClick={activateMarker} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateMarker(); - }} - /> - ); - })} - + {markers.map((marker, index) => { + const position = projection([marker.longitude, marker.latitude]); + const isVisible = + center && + geoDistance(center, [marker.longitude, marker.latitude]) <= + Math.PI / 2; + const detail = + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; + const activateMarker = () => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + onMarkerClick?.(marker); + }; + return position && isVisible ? ( + moveTooltip(event, marker)} + onPointerMove={(event) => { + if (!dragRef.current) moveTooltip(event, marker); + }} + onPointerLeave={() => setTooltip(null)} + onFocus={(event) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail, + x: bounds.width / 2, + y: bounds.height / 2, + }); + }} + onBlur={() => setTooltip(null)} + onClick={activateMarker} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateMarker(); + }} + /> + ) : null; + })} Date: Thu, 3 Sep 2026 15:20:15 -0400 Subject: [PATCH 11/15] test(chart): cover hatched globe land --- packages/kumo/src/components/chart/GlobeMap.tsx | 1 + packages/kumo/src/components/chart/Maps.test.tsx | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx index 5e2facbaac..49e9c6168f 100644 --- a/packages/kumo/src/components/chart/GlobeMap.tsx +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -301,6 +301,7 @@ export function GlobeMap({ /> ) : null} { expect(globe.getAttribute("role")).toBeNull(); expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); const landPath = globe - .querySelector('[data-land-style="dotted"]') + .querySelector('[data-land-style="hatched"]') ?.getAttribute("d"); - expect(landPath).toContain("a"); - expect(landPath?.match(/M/g)?.length).toBeGreaterThan(1_000); - expect(globe.querySelectorAll("circle")).toHaveLength(0); + expect(landPath).toContain("L"); + expect(landPath?.match(/M/g)?.length).toBeGreaterThan(100); + expect(globe.querySelectorAll("circle")).toHaveLength(1); expect(globe.querySelector("pattern")).toBeNull(); - expect(globe.querySelector("mask")).not.toBeNull(); - expect( - globe.querySelector('[data-land-style="dotted"]')?.getAttribute("mask"), - ).toMatch(/^url\(#kumo-globe-fade-/); expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); fireEvent.keyDown( @@ -88,7 +84,7 @@ describe("GlobeMap", () => { />, ); const globe = getByLabelText("Draggable globe"); - const land = globe.querySelector('[data-land-style="dotted"]'); + const land = globe.querySelector('[data-land-style="hatched"]'); const initialPath = land?.getAttribute("d"); fireEvent.pointerDown(globe, { pointerId: 1, clientX: 100, clientY: 100 }); From 67033edbb47764ab57691b637461f7fa880953f1 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 3 Sep 2026 15:30:34 -0400 Subject: [PATCH 12/15] fix(chart): preserve globe marker clicks --- .../kumo/src/components/chart/GlobeMap.tsx | 549 +++++++++--------- .../kumo/src/components/chart/Maps.test.tsx | 17 + 2 files changed, 303 insertions(+), 263 deletions(-) diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx index 49e9c6168f..74a2acc54e 100644 --- a/packages/kumo/src/components/chart/GlobeMap.tsx +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -1,5 +1,12 @@ import type { PointerEvent as ReactPointerEvent } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { geoDistance, geoGraticule10, geoOrthographic, geoPath } from "d3-geo"; import { cn } from "../../utils/cn"; import { ChartPalette } from "./Color"; @@ -107,281 +114,297 @@ function createLandHatchPath( * GlobeMap — an SVG orthographic globe with hatched land and geographic * markers. Rendering is SVG-only and does not use WebGL. */ -export function GlobeMap({ - landColor, - landDotSpacing = 10, - oceanColor = "var(--color-kumo-base)", - markers = [], - markerColor, - markerRadius = 7, - onMarkerClick, - rotation: initialRotation = [-10, -20, 0], - draggable = true, - autoRotate = false, - autoRotateSpeed = 4, - showGraticule = false, - showTooltip = true, - onRotationChange, - "aria-label": ariaLabel = "Interactive globe map", - height, - className, - isDarkMode, -}: GlobeMapProps) { - const [rotation, setRotation] = useState(initialRotation); - const [tooltip, setTooltip] = useState(null); - const rotationRef = useRef(rotation); - const svgRef = useRef(null); - const dragRef = useRef<{ - pointerId: number; - x: number; - y: number; - rotation: [number, number, number]; - } | null>(null); - const didDragRef = useRef(false); - - const palette = useMemo( - () => ChartPalette.mapColors(isDarkMode), - [isDarkMode], - ); - const resolvedLandColor = landColor ?? palette.area; - const resolvedMarkerColor = markerColor ?? palette.bubble; - const projection = geoOrthographic() - .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) - .scale(GLOBE_RADIUS) - .clipAngle(90) - .rotate(rotation); - const path = geoPath(projection); - const landHatchPath = createLandHatchPath(projection, landDotSpacing); - const center = projection.invert?.([ - GLOBE_VIEWBOX_SIZE / 2, - GLOBE_VIEWBOX_SIZE / 2, - ]); - - const updateRotation = useCallback( - (nextRotation: [number, number, number], notify = false) => { - rotationRef.current = nextRotation; - setRotation(nextRotation); - if (notify) onRotationChange?.(nextRotation); +export const GlobeMap = forwardRef( + function GlobeMap( + { + landColor, + landDotSpacing = 10, + oceanColor = "var(--color-kumo-base)", + markers = [], + markerColor, + markerRadius = 7, + onMarkerClick, + rotation: initialRotation = [-10, -20, 0], + draggable = true, + autoRotate = false, + autoRotateSpeed = 4, + showGraticule = false, + showTooltip = true, + onRotationChange, + "aria-label": ariaLabel = "Interactive globe map", + height, + className, + isDarkMode, }, - [onRotationChange], - ); + ref, + ) { + const [rotation, setRotation] = useState(initialRotation); + const [tooltip, setTooltip] = useState(null); + const rotationRef = useRef(rotation); + const svgRef = useRef(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + rotation: [number, number, number]; + } | null>(null); + const didDragRef = useRef(false); - useEffect(() => { - if (!autoRotate) return; - if ( - typeof matchMedia === "function" && - matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - return; - } + const palette = useMemo( + () => ChartPalette.mapColors(isDarkMode), + [isDarkMode], + ); + const resolvedLandColor = landColor ?? palette.area; + const resolvedMarkerColor = markerColor ?? palette.bubble; + const projection = geoOrthographic() + .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) + .scale(GLOBE_RADIUS) + .clipAngle(90) + .rotate(rotation); + const path = geoPath(projection); + const landHatchPath = createLandHatchPath(projection, landDotSpacing); + const center = projection.invert?.([ + GLOBE_VIEWBOX_SIZE / 2, + GLOBE_VIEWBOX_SIZE / 2, + ]); - let frame: number | null = null; - let previousTime: number | null = null; - const rotate = (time: number) => { - if (previousTime !== null && !dragRef.current) { - const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); - const current = rotationRef.current; - updateRotation([ - current[0] + autoRotateSpeed * deltaSeconds, - current[1], - current[2], - ]); + const updateRotation = useCallback( + (nextRotation: [number, number, number], notify = false) => { + rotationRef.current = nextRotation; + setRotation(nextRotation); + if (notify) onRotationChange?.(nextRotation); + }, + [onRotationChange], + ); + + useEffect(() => { + if (!autoRotate) return; + if ( + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; } - previousTime = time; - frame = requestAnimationFrame(rotate); - }; - const start = () => { - if (frame === null) frame = requestAnimationFrame(rotate); - }; - const stop = () => { - if (frame !== null) cancelAnimationFrame(frame); - frame = null; - previousTime = null; - }; - const observer = - typeof IntersectionObserver === "undefined" - ? null - : new IntersectionObserver(([entry]) => { - if (entry?.isIntersecting) start(); - else stop(); - }); - if (observer && svgRef.current) observer.observe(svgRef.current); - else start(); - return () => { - observer?.disconnect(); - stop(); - }; - }, [autoRotate, autoRotateSpeed, updateRotation]); + let frame: number | null = null; + let previousTime: number | null = null; + const rotate = (time: number) => { + if (previousTime !== null && !dragRef.current) { + const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); + const current = rotationRef.current; + updateRotation([ + current[0] + autoRotateSpeed * deltaSeconds, + current[1], + current[2], + ]); + } + previousTime = time; + frame = requestAnimationFrame(rotate); + }; + const start = () => { + if (frame === null) frame = requestAnimationFrame(rotate); + }; + const stop = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + previousTime = null; + }; + const observer = + typeof IntersectionObserver === "undefined" + ? null + : new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) start(); + else stop(); + }); - const moveTooltip = useCallback( - (event: ReactPointerEvent, marker: GlobeMapMarker) => { - if (!showTooltip) return; - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: marker.name, - detail: - marker.description ?? - `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`, - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, - }); - }, - [showTooltip], - ); + if (observer && svgRef.current) observer.observe(svgRef.current); + else start(); + return () => { + observer?.disconnect(); + stop(); + }; + }, [autoRotate, autoRotateSpeed, updateRotation]); - const handlePointerDown = (event: ReactPointerEvent) => { - if (!draggable) return; - event.currentTarget.setPointerCapture(event.pointerId); - didDragRef.current = false; - dragRef.current = { - pointerId: event.pointerId, - x: event.clientX, - y: event.clientY, - rotation: rotationRef.current, - }; - setTooltip(null); - }; - const handlePointerMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - const deltaX = event.clientX - drag.x; - const deltaY = event.clientY - drag.y; - if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; - updateRotation( - [ - drag.rotation[0] + deltaX * 0.3, - Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), - drag.rotation[2], - ], - true, + const moveTooltip = useCallback( + (event: ReactPointerEvent, marker: GlobeMapMarker) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail: + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`, + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + }, + [showTooltip], ); - }; - const handlePointerUp = (event: ReactPointerEvent) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - event.currentTarget.releasePointerCapture(event.pointerId); - }; - return ( -
- { - if (!dragRef.current) setTooltip(null); - }} + const handlePointerDown = (event: ReactPointerEvent) => { + if (!draggable) return; + if ( + event.target instanceof Element && + event.target.closest('circle[role="button"]') + ) { + didDragRef.current = false; + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + didDragRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + rotation: rotationRef.current, + }; + setTooltip(null); + }; + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; + updateRotation( + [ + drag.rotation[0] + deltaX * 0.3, + Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), + drag.rotation[2], + ], + true, + ); + }; + const handlePointerUp = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }; + + return ( +
- - {showGraticule ? ( + { + if (!dragRef.current) setTooltip(null); + }} + > - ) : null} - - {markers.map((marker, index) => { - const position = projection([marker.longitude, marker.latitude]); - const isVisible = - center && - geoDistance(center, [marker.longitude, marker.latitude]) <= - Math.PI / 2; - const detail = - marker.description ?? - `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; - const activateMarker = () => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } - onMarkerClick?.(marker); - }; - return position && isVisible ? ( - moveTooltip(event, marker)} - onPointerMove={(event) => { - if (!dragRef.current) moveTooltip(event, marker); - }} - onPointerLeave={() => setTooltip(null)} - onFocus={(event) => { - if (!showTooltip) return; - const bounds = - event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - setTooltip({ - name: marker.name, - detail, - x: bounds.width / 2, - y: bounds.height / 2, - }); - }} - onBlur={() => setTooltip(null)} - onClick={activateMarker} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateMarker(); - }} + {showGraticule ? ( + - ) : null; - })} - - - {tooltip ? ( -
- {tooltip.name} - {tooltip.detail} -
- ) : null} -
- ); -} + ) : null} + + {markers.map((marker, index) => { + const position = projection([marker.longitude, marker.latitude]); + const isVisible = + center && + geoDistance(center, [marker.longitude, marker.latitude]) <= + Math.PI / 2; + const detail = + marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; + const activateMarker = () => { + if (didDragRef.current) { + didDragRef.current = false; + return; + } + onMarkerClick?.(marker); + }; + if (!position || !isVisible) return null; + return ( + moveTooltip(event, marker)} + onPointerMove={(event) => { + if (!dragRef.current) moveTooltip(event, marker); + }} + onPointerLeave={() => setTooltip(null)} + onFocus={(event) => { + if (!showTooltip) return; + const bounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setTooltip({ + name: marker.name, + detail, + x: (position[0] / GLOBE_VIEWBOX_SIZE) * bounds.width, + y: (position[1] / GLOBE_VIEWBOX_SIZE) * bounds.height, + }); + }} + onBlur={() => setTooltip(null)} + onClick={activateMarker} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateMarker(); + }} + /> + ); + })} + + + {tooltip ? ( +
+ {tooltip.name} + {tooltip.detail} +
+ ) : null} +
+ ); + }, +); GlobeMap.displayName = "GlobeMap"; diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index e7bd63b010..69224a1a22 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -1,5 +1,6 @@ import { createRef } from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vite-plus/test"; import { GlobeMap } from "./GlobeMap"; import { BubbleMap, type MapGeoJson } from "./Maps"; @@ -93,6 +94,22 @@ describe("GlobeMap", () => { await waitFor(() => expect(land?.getAttribute("d")).not.toBe(initialPath)); expect(onRotationChange).toHaveBeenCalledWith([2, -20, 0]); }); + + it("calls onMarkerClick when a marker is clicked", async () => { + const user = userEvent.setup(); + const onMarkerClick = vi.fn(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: /London:/ })); + expect(onMarkerClick).toHaveBeenCalledWith( + expect.objectContaining({ name: "London" }), + ); + }); }); describe("BubbleMap", () => { From dcac81dfd10625801dbed075b776e02ccd9cd4a9 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Thu, 3 Sep 2026 19:33:38 -0400 Subject: [PATCH 13/15] fix(chart): clip globe texture to sphere --- packages/kumo/src/components/chart/GlobeMap.tsx | 8 ++++++++ packages/kumo/src/components/chart/Maps.test.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/kumo/src/components/chart/GlobeMap.tsx b/packages/kumo/src/components/chart/GlobeMap.tsx index 74a2acc54e..ce4202a5d0 100644 --- a/packages/kumo/src/components/chart/GlobeMap.tsx +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -140,6 +141,7 @@ export const GlobeMap = forwardRef( ) { const [rotation, setRotation] = useState(initialRotation); const [tooltip, setTooltip] = useState(null); + const sphereClipId = useId(); const rotationRef = useRef(rotation); const svgRef = useRef(null); const dragRef = useRef<{ @@ -305,6 +307,11 @@ export const GlobeMap = forwardRef( if (!dragRef.current) setTooltip(null); }} > + + + + + ( strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" + clipPath={`url(#${sphereClipId})`} className="pointer-events-none" /> {markers.map((marker, index) => { diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index 69224a1a22..bf32a6c97f 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -63,6 +63,14 @@ describe("GlobeMap", () => { ?.getAttribute("d"); expect(landPath).toContain("L"); expect(landPath?.match(/M/g)?.length).toBeGreaterThan(100); + const sphereClip = globe.querySelector("clipPath"); + expect(sphereClip).not.toBeNull(); + expect(landPath).toBeTruthy(); + expect( + globe + .querySelector('[data-land-style="hatched"]') + ?.getAttribute("clip-path"), + ).toBe(`url(#${sphereClip?.id})`); expect(globe.querySelectorAll("circle")).toHaveLength(1); expect(globe.querySelector("pattern")).toBeNull(); expect(globe.querySelector(".stroke-kumo-base")).not.toBeNull(); From a86fc5b24f90d8b595224c7bf372b3a7c76ef8a0 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Mon, 14 Sep 2026 11:46:41 -0400 Subject: [PATCH 14/15] fix(chart): harden globe interactions --- .changeset/warm-globes-drift.md | 2 +- .../components/demos/Chart/GlobeMapDemo.tsx | 2 +- .../src/pages/charts/maps.astro | 4 +- .../kumo/src/components/chart/GlobeMap.tsx | 295 ++++++++++++++---- .../kumo/src/components/chart/Maps.test.tsx | 50 ++- 5 files changed, 278 insertions(+), 75 deletions(-) diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md index 87ee2adeec..3c1e6e49e7 100644 --- a/.changeset/warm-globes-drift.md +++ b/.changeset/warm-globes-drift.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `GlobeMap`, an SVG orthographic globe with boundary-free dotted land, clipped geographic markers, horizon fading, optional geographic guides, drag and automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. +Add `GlobeMap`, an SVG orthographic globe with boundary-free hatched land, horizon-clipped geographic markers, optional geographic guides, pointer and keyboard rotation, automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx index 3746869559..823a942966 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -61,7 +61,7 @@ export function GlobeMapAvailabilityZonesDemo() { Cloudflare Availability Locations

- Plot geographic markers without WebGL. Hatched neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe to reveal points on the hidden hemisphere. + Plot geographic markers without WebGL. Hatched neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe or focus it and use the arrow keys to reveal points on the hidden hemisphere.

void; /** Initial globe rotation as `[longitude, latitude, roll]`. */ - rotation?: [number, number, number]; + defaultRotation?: [number, number, number]; /** Allow pointer dragging to rotate the globe. Default: `true`. */ draggable?: boolean; /** Continuously rotate the globe horizontally. Default: `false`. */ @@ -55,8 +55,8 @@ export interface GlobeMapProps { showGraticule?: boolean; /** Show the Kumo-styled marker tooltip. Default: `true`. */ showTooltip?: boolean; - /** Called after pointer dragging changes the globe rotation. */ - onRotationChange?: (rotation: [number, number, number]) => void; + /** Called after pointer or keyboard interaction changes the globe rotation. */ + onUserRotationChange?: (rotation: [number, number, number]) => void; /** Accessible label for the visualization. Default: `"Interactive globe map"`. */ "aria-label"?: string; /** Fixed component height. Otherwise the globe uses a square aspect ratio. */ @@ -75,6 +75,21 @@ interface GlobeTooltip { const GLOBE_VIEWBOX_SIZE = 640; const GLOBE_PADDING = 18; const GLOBE_RADIUS = GLOBE_VIEWBOX_SIZE / 2 - GLOBE_PADDING; +const AUTO_ROTATE_INTERVAL = 1000 / 30; + +function finiteNumber(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback; +} + +function normalizeRotation( + rotation: [number, number, number], +): [number, number, number] { + return [ + finiteNumber(rotation[0], -10), + Math.max(-90, Math.min(90, finiteNumber(rotation[1], -20))), + finiteNumber(rotation[2], 0), + ]; +} function createLandHatchPath( projection: ReturnType, @@ -119,19 +134,19 @@ export const GlobeMap = forwardRef( function GlobeMap( { landColor, - landDotSpacing = 10, + landHatchSpacing = 10, oceanColor = "var(--color-kumo-base)", markers = [], markerColor, markerRadius = 7, onMarkerClick, - rotation: initialRotation = [-10, -20, 0], + defaultRotation = [-10, -20, 0], draggable = true, autoRotate = false, autoRotateSpeed = 4, showGraticule = false, showTooltip = true, - onRotationChange, + onUserRotationChange, "aria-label": ariaLabel = "Interactive globe map", height, className, @@ -139,7 +154,9 @@ export const GlobeMap = forwardRef( }, ref, ) { - const [rotation, setRotation] = useState(initialRotation); + const [rotation, setRotation] = useState(() => + normalizeRotation(defaultRotation), + ); const [tooltip, setTooltip] = useState(null); const sphereClipId = useId(); const rotationRef = useRef(rotation); @@ -150,7 +167,14 @@ export const GlobeMap = forwardRef( y: number; rotation: [number, number, number]; } | null>(null); - const didDragRef = useRef(false); + const isFocusedRef = useRef(false); + const pointerMoveFrameRef = useRef(null); + const pendingPointerMoveRef = useRef<{ + pointerId: number; + x: number; + y: number; + } | null>(null); + const instructionsId = useId(); const palette = useMemo( () => ChartPalette.mapColors(isDarkMode), @@ -158,25 +182,47 @@ export const GlobeMap = forwardRef( ); const resolvedLandColor = landColor ?? palette.area; const resolvedMarkerColor = markerColor ?? palette.bubble; - const projection = geoOrthographic() - .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) - .scale(GLOBE_RADIUS) - .clipAngle(90) - .rotate(rotation); - const path = geoPath(projection); - const landHatchPath = createLandHatchPath(projection, landDotSpacing); - const center = projection.invert?.([ - GLOBE_VIEWBOX_SIZE / 2, - GLOBE_VIEWBOX_SIZE / 2, - ]); + const safeHatchSpacing = Math.max(3, finiteNumber(landHatchSpacing, 10)); + const safeMarkerRadius = Math.max(0, finiteNumber(markerRadius, 7)); + const safeAutoRotateSpeed = Math.max( + -60, + Math.min(60, finiteNumber(autoRotateSpeed, 4)), + ); + const projection = useMemo( + () => + geoOrthographic() + .translate([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]) + .scale(GLOBE_RADIUS) + .clipAngle(90) + .rotate(rotation), + [rotation], + ); + const path = useMemo(() => geoPath(projection), [projection]); + const spherePath = useMemo( + () => path({ type: "Sphere" }) ?? undefined, + [path], + ); + const graticulePath = useMemo( + () => path(geoGraticule10()) ?? undefined, + [path], + ); + const landHatchPath = useMemo( + () => createLandHatchPath(projection, safeHatchSpacing), + [projection, safeHatchSpacing], + ); + const center = useMemo( + () => + projection.invert?.([GLOBE_VIEWBOX_SIZE / 2, GLOBE_VIEWBOX_SIZE / 2]), + [projection], + ); const updateRotation = useCallback( (nextRotation: [number, number, number], notify = false) => { rotationRef.current = nextRotation; setRotation(nextRotation); - if (notify) onRotationChange?.(nextRotation); + if (notify) onUserRotationChange?.(nextRotation); }, - [onRotationChange], + [onUserRotationChange], ); useEffect(() => { @@ -191,16 +237,22 @@ export const GlobeMap = forwardRef( let frame: number | null = null; let previousTime: number | null = null; const rotate = (time: number) => { - if (previousTime !== null && !dragRef.current) { + if (dragRef.current || isFocusedRef.current) { + previousTime = time; + } else if ( + previousTime !== null && + time - previousTime >= AUTO_ROTATE_INTERVAL + ) { const deltaSeconds = Math.min((time - previousTime) / 1000, 0.1); const current = rotationRef.current; updateRotation([ - current[0] + autoRotateSpeed * deltaSeconds, + current[0] + safeAutoRotateSpeed * deltaSeconds, current[1], current[2], ]); + previousTime = time; } - previousTime = time; + if (previousTime === null) previousTime = time; frame = requestAnimationFrame(rotate); }; const start = () => { @@ -225,7 +277,7 @@ export const GlobeMap = forwardRef( observer?.disconnect(); stop(); }; - }, [autoRotate, autoRotateSpeed, updateRotation]); + }, [autoRotate, safeAutoRotateSpeed, updateRotation]); const moveTooltip = useCallback( (event: ReactPointerEvent, marker: GlobeMapMarker) => { @@ -247,15 +299,14 @@ export const GlobeMap = forwardRef( const handlePointerDown = (event: ReactPointerEvent) => { if (!draggable) return; + if (event.isPrimary === false || event.button !== 0) return; if ( event.target instanceof Element && - event.target.closest('circle[role="button"]') + event.target.closest('[data-globe-marker-interactive="true"]') ) { - didDragRef.current = false; return; } event.currentTarget.setPointerCapture(event.pointerId); - didDragRef.current = false; dragRef.current = { pointerId: event.pointerId, x: event.clientX, @@ -264,36 +315,88 @@ export const GlobeMap = forwardRef( }; setTooltip(null); }; + const applyPointerMove = useCallback( + (pointerId: number, x: number, y: number) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== pointerId) return; + const deltaX = x - drag.x; + const deltaY = y - drag.y; + updateRotation( + [ + drag.rotation[0] + deltaX * 0.3, + Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), + drag.rotation[2], + ], + true, + ); + }, + [updateRotation], + ); const handlePointerMove = (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; - const deltaX = event.clientX - drag.x; - const deltaY = event.clientY - drag.y; - if (Math.hypot(deltaX, deltaY) > 3) didDragRef.current = true; - updateRotation( - [ - drag.rotation[0] + deltaX * 0.3, - Math.max(-90, Math.min(90, drag.rotation[1] - deltaY * 0.3)), - drag.rotation[2], - ], - true, - ); + pendingPointerMoveRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + if (pointerMoveFrameRef.current !== null) return; + pointerMoveFrameRef.current = requestAnimationFrame(() => { + pointerMoveFrameRef.current = null; + const pending = pendingPointerMoveRef.current; + pendingPointerMoveRef.current = null; + if (pending) applyPointerMove(pending.pointerId, pending.x, pending.y); + }); }; - const handlePointerUp = (event: ReactPointerEvent) => { + const finishPointerDrag = (event: ReactPointerEvent) => { if (dragRef.current?.pointerId !== event.pointerId) return; + if (pointerMoveFrameRef.current !== null) { + cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + const pending = pendingPointerMoveRef.current; + pendingPointerMoveRef.current = null; + if (pending) applyPointerMove(pending.pointerId, pending.x, pending.y); dragRef.current = null; - event.currentTarget.releasePointerCapture(event.pointerId); + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } }; + useEffect( + () => () => { + if (pointerMoveFrameRef.current !== null) { + cancelAnimationFrame(pointerMoveFrameRef.current); + } + }, + [], + ); + return (
{ + isFocusedRef.current = true; + }} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + isFocusedRef.current = false; + } + }} > + {draggable ? ( + + Use the arrow keys to rotate the globe. + + ) : null} ( )} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} + onPointerUp={finishPointerDrag} + onPointerCancel={finishPointerDrag} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null; + } + }} + onKeyDown={(event) => { + if (!draggable || event.target !== event.currentTarget) return; + if ( + event.key !== "ArrowLeft" && + event.key !== "ArrowRight" && + event.key !== "ArrowUp" && + event.key !== "ArrowDown" + ) { + return; + } + event.preventDefault(); + const [longitude, latitude, roll] = rotationRef.current; + const nextRotation: [number, number, number] = + event.key === "ArrowLeft" + ? [longitude - 10, latitude, roll] + : event.key === "ArrowRight" + ? [longitude + 10, latitude, roll] + : event.key === "ArrowUp" + ? [longitude, Math.min(90, latitude + 10), roll] + : event.key === "ArrowDown" + ? [longitude, Math.max(-90, latitude - 10), roll] + : [longitude, latitude, roll]; + if (nextRotation[0] === longitude && nextRotation[1] === latitude) { + return; + } + updateRotation(nextRotation, true); + }} onPointerLeave={() => { if (!dragRef.current) setTooltip(null); }} > - + {showGraticule ? ( ( marker.description ?? `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`; const activateMarker = () => { - if (didDragRef.current) { - didDragRef.current = false; - return; - } onMarkerClick?.(marker); }; if (!position || !isVisible) return null; + const isInteractive = onMarkerClick !== undefined; return ( moveTooltip(event, marker)} onPointerMove={(event) => { if (!dragRef.current) moveTooltip(event, marker); @@ -373,33 +516,53 @@ export const GlobeMap = forwardRef( onPointerLeave={() => setTooltip(null)} onFocus={(event) => { if (!showTooltip) return; - const bounds = + const svgBounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; + if (!svgBounds) return; + const markerBounds = + event.currentTarget.getBoundingClientRect(); setTooltip({ name: marker.name, detail, - x: (position[0] / GLOBE_VIEWBOX_SIZE) * bounds.width, - y: (position[1] / GLOBE_VIEWBOX_SIZE) * bounds.height, + x: + markerBounds.left + + markerBounds.width / 2 - + svgBounds.left, + y: markerBounds.top - svgBounds.top, }); }} onBlur={() => setTooltip(null)} - onClick={activateMarker} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateMarker(); - }} + onClick={isInteractive ? activateMarker : undefined} + onKeyDown={ + isInteractive + ? (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateMarker(); + } + : undefined + } /> ); })} + {onMarkerClick === undefined && markers.length > 0 ? ( +
    + {markers.map((marker, index) => ( +
  • + {marker.name}:{" "} + {marker.description ?? + `${marker.latitude.toFixed(2)}, ${marker.longitude.toFixed(2)}`} +
  • + ))} +
+ ) : null} {tooltip ? (
{ const globe = getByLabelText("Traffic globe"); expect(globe.tagName).toBe("svg"); - expect(globe.getAttribute("role")).toBeNull(); + expect(getByRole("group", { name: "Traffic globe" })).toBe(globe); + expect(globe.getAttribute("aria-describedby")).toBeTruthy(); expect(globe.querySelectorAll("path").length).toBeGreaterThan(3); const landPath = globe .querySelector('[data-land-style="hatched"]') @@ -85,22 +86,61 @@ describe("GlobeMap", () => { }); it("updates rotation while dragging", async () => { - const onRotationChange = vi.fn(); + const onUserRotationChange = vi.fn(); const { getByLabelText } = render( , ); const globe = getByLabelText("Draggable globe"); const land = globe.querySelector('[data-land-style="hatched"]'); const initialPath = land?.getAttribute("d"); - fireEvent.pointerDown(globe, { pointerId: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerDown(globe, { + pointerId: 1, + clientX: 100, + clientY: 100, + button: 0, + isPrimary: true, + }); fireEvent.pointerMove(globe, { pointerId: 1, clientX: 140, clientY: 100 }); await waitFor(() => expect(land?.getAttribute("d")).not.toBe(initialPath)); - expect(onRotationChange).toHaveBeenCalledWith([2, -20, 0]); + expect(onUserRotationChange).toHaveBeenCalledWith([2, -20, 0]); + }); + + it("supports keyboard rotation", async () => { + const onUserRotationChange = vi.fn(); + const { getByRole } = render( + , + ); + const globe = getByRole("group", { name: "Keyboard globe" }); + + await userEvent.type(globe, "{ArrowRight}{ArrowUp}"); + + expect(onUserRotationChange).toHaveBeenNthCalledWith(1, [0, -20, 0]); + expect(onUserRotationChange).toHaveBeenNthCalledWith(2, [0, -10, 0]); + }); + + it("does not expose informational markers as buttons", () => { + const { container, getByRole, queryByRole } = render( + , + ); + + expect(queryByRole("button", { name: /London:/ })).toBeNull(); + const marker = container.querySelector("[data-globe-marker]"); + expect(marker?.getAttribute("aria-hidden")).toBe("true"); + expect(marker?.getAttribute("tabindex")).toBeNull(); + expect( + getByRole("list", { name: "Interactive globe map locations" }) + .textContent, + ).toContain("London: 51.50, -0.12"); }); it("calls onMarkerClick when a marker is clicked", async () => { From a83e99d5d81312dd687f321a60dc9179f816c4ca Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Mon, 14 Sep 2026 12:04:12 -0400 Subject: [PATCH 15/15] fix(chart): fade globe markers at horizon --- .changeset/warm-globes-drift.md | 2 +- .../src/pages/charts/maps.astro | 2 +- .../kumo/src/components/chart/GlobeMap.tsx | 30 ++++++++++++++----- .../kumo/src/components/chart/Maps.test.tsx | 16 ++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md index 3c1e6e49e7..4048fa6d72 100644 --- a/.changeset/warm-globes-drift.md +++ b/.changeset/warm-globes-drift.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `GlobeMap`, an SVG orthographic globe with boundary-free hatched land, horizon-clipped geographic markers, optional geographic guides, pointer and keyboard rotation, automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. +Add `GlobeMap`, an SVG orthographic globe with boundary-free hatched land, horizon-faded geographic markers, optional geographic guides, pointer and keyboard rotation, automatic rotation, Kumo-themed tooltips, and no WebGL or ECharts requirement. diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index a01c991eca..9b41c6b123 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -180,7 +180,7 @@ export default function Example() {
Cloudflare Availability Locations

- Plot geographic markers without WebGL. Hatched neutral land, a transparent ocean, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe or focus it and use the arrow keys to reveal points on the hidden hemisphere. + Plot geographic markers without WebGL. Hatched neutral land, a transparent ocean, horizon-faded markers, and dense geographic guides echo the globe styling on Cloudflare’s marketing homepage while keeping the blue locations prominent. These illustrative locations use major network metros and IATA identifiers; drag the globe or focus it and use the arrow keys to reveal points on the hidden hemisphere.

( clipPath={`url(#${sphereClipId})`} className="pointer-events-none" /> + {markers.map((marker, index) => { const position = projection([marker.longitude, marker.latitude]); const isVisible = @@ -486,6 +494,17 @@ export const GlobeMap = forwardRef( }; if (!position || !isVisible) return null; const isInteractive = onMarkerClick !== undefined; + const distanceFromCenter = Math.hypot( + position[0] - GLOBE_VIEWBOX_SIZE / 2, + position[1] - GLOBE_VIEWBOX_SIZE / 2, + ); + const edgeOpacity = Math.max( + 0, + Math.min( + 1, + (GLOBE_RADIUS - distanceFromCenter) / MARKER_EDGE_FADE_DISTANCE, + ), + ); return ( ( ), )} fill={marker.color ?? resolvedMarkerColor} - className="stroke-kumo-base transition-opacity outline-none hover:opacity-80 focus-visible:opacity-80" + opacity={edgeOpacity} + className="stroke-kumo-base transition-opacity outline-none" strokeWidth={2} data-globe-marker="" data-globe-marker-interactive={isInteractive} @@ -545,12 +565,6 @@ export const GlobeMap = forwardRef( /> ); })} - {onMarkerClick === undefined && markers.length > 0 ? (
    diff --git a/packages/kumo/src/components/chart/Maps.test.tsx b/packages/kumo/src/components/chart/Maps.test.tsx index e702985508..8994bc6f90 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -143,6 +143,22 @@ describe("GlobeMap", () => { ).toContain("London: 51.50, -0.12"); }); + it("renders markers above the outline and fades them at the horizon", () => { + const { container } = render( + , + ); + const outline = container.querySelector("[data-globe-outline]"); + const marker = container.querySelector("[data-globe-marker]"); + const opacity = Number(marker?.getAttribute("opacity")); + + expect(outline?.nextElementSibling).toBe(marker); + expect(opacity).toBeGreaterThan(0); + expect(opacity).toBeLessThan(1); + }); + it("calls onMarkerClick when a marker is clicked", async () => { const user = userEvent.setup(); const onMarkerClick = vi.fn();