diff --git a/.changeset/warm-globes-drift.md b/.changeset/warm-globes-drift.md new file mode 100644 index 000000000..4048fa6d7 --- /dev/null +++ b/.changeset/warm-globes-drift.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +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/components/SearchDialog.tsx b/packages/kumo-docs-astro/src/components/SearchDialog.tsx index 63a2cf34a..ccd2e6464 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", }; /** 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 000000000..823a94296 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/Chart/GlobeMapDemo.tsx @@ -0,0 +1,75 @@ +import { GlobeMap, type GlobeMapMarker } from "@cloudflare/kumo"; +import { useIsDarkMode } from "~/lib/use-is-dark-mode"; + +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() { + const isDarkMode = useIsDarkMode(); + + return ( +
+ +
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/charts/maps.astro b/packages/kumo-docs-astro/src/pages/charts/maps.astro index ffff8c77d..9b41c6b12 100644 --- a/packages/kumo-docs-astro/src/pages/charts/maps.astro +++ b/packages/kumo-docs-astro/src/pages/charts/maps.astro @@ -10,6 +10,7 @@ import { BubbleMapCloudflareLocationsDemo, } from "~/components/demos/Chart/BubbleMapDemo"; import { ChoroplethMapBasicDemo } from "~/components/demos/Chart/ChoroplethMapDemo"; +import { GlobeMapAvailabilityZonesDemo } from "~/components/demos/Chart/GlobeMapDemo"; import type { MapGeoJson } from "@cloudflare/kumo"; const WORLD_GEO_JSON_URL = @@ -67,15 +68,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 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.

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

+ 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. +

+ `}> + + +
+
Custom Tooltips

@@ -206,5 +226,7 @@ export default function Example() { ChoroplethMap + GlobeMap + 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 000000000..ba230901f --- /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 000000000..4bf4ab7a5 --- /dev/null +++ b/packages/kumo/src/components/chart/GlobeMap.tsx @@ -0,0 +1,595 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useId, + 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"; + +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 { + /** Stroke color for the hatched land. Defaults to the neutral Kumo map area color. */ + landColor?: string; + /** Spacing between land hatch lines in view-box pixels. Default: `10`. */ + landHatchSpacing?: number; + /** Fill behind the land and graticule. Default: the Kumo base surface. */ + oceanColor?: string; + /** Geographic points drawn above the land. Points fade at the horizon and back-facing points are hidden. */ + 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]`. */ + defaultRotation?: [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 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. */ + 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; +const MARKER_EDGE_FADE_DISTANCE = 24; +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, + spacing: number, +): string | undefined { + if (spacing <= 0) return undefined; + if (!projection.invert) return undefined; + + 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; + } + } + } + return commands.join("") || undefined; +} + +/** + * GlobeMap — an SVG orthographic globe with hatched land and geographic + * markers. Rendering is SVG-only and does not use WebGL. + */ +export const GlobeMap = forwardRef( + function GlobeMap( + { + landColor, + landHatchSpacing = 10, + oceanColor = "var(--color-kumo-base)", + markers = [], + markerColor, + markerRadius = 7, + onMarkerClick, + defaultRotation = [-10, -20, 0], + draggable = true, + autoRotate = false, + autoRotateSpeed = 4, + showGraticule = false, + showTooltip = true, + onUserRotationChange, + "aria-label": ariaLabel = "Interactive globe map", + height, + className, + isDarkMode, + }, + ref, + ) { + const [rotation, setRotation] = useState(() => + normalizeRotation(defaultRotation), + ); + const [tooltip, setTooltip] = useState(null); + const sphereClipId = useId(); + const rotationRef = useRef(rotation); + const svgRef = useRef(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + rotation: [number, number, number]; + } | null>(null); + 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), + [isDarkMode], + ); + const resolvedLandColor = landColor ?? palette.area; + const resolvedMarkerColor = markerColor ?? palette.bubble; + 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) onUserRotationChange?.(nextRotation); + }, + [onUserRotationChange], + ); + + useEffect(() => { + if (!autoRotate) return; + if ( + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + + let frame: number | null = null; + let previousTime: number | null = null; + const rotate = (time: number) => { + 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] + safeAutoRotateSpeed * deltaSeconds, + current[1], + current[2], + ]); + previousTime = time; + } + if (previousTime === null) 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, safeAutoRotateSpeed, updateRotation]); + + 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 handlePointerDown = (event: ReactPointerEvent) => { + if (!draggable) return; + if (event.isPrimary === false || event.button !== 0) return; + if ( + event.target instanceof Element && + event.target.closest('[data-globe-marker-interactive="true"]') + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + rotation: rotationRef.current, + }; + 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; + 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 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; + 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} + { + 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 ? ( + + ) : 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 = () => { + onMarkerClick?.(marker); + }; + 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 ( + moveTooltip(event, marker)} + onPointerMove={(event) => { + if (!dragRef.current) moveTooltip(event, marker); + }} + onPointerLeave={() => setTooltip(null)} + onFocus={(event) => { + if (!showTooltip) return; + const svgBounds = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!svgBounds) return; + const markerBounds = + event.currentTarget.getBoundingClientRect(); + setTooltip({ + name: marker.name, + detail, + x: + markerBounds.left + + markerBounds.width / 2 - + svgBounds.left, + y: markerBounds.top - svgBounds.top, + }); + }} + onBlur={() => setTooltip(null)} + 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 ? ( +
+ {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 52ec321e2..8994bc6f9 100644 --- a/packages/kumo/src/components/chart/Maps.test.tsx +++ b/packages/kumo/src/components/chart/Maps.test.tsx @@ -1,6 +1,8 @@ import { createRef } from "react"; -import { render, waitFor } from "@testing-library/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"; const createMockChart = () => ({ @@ -33,6 +35,147 @@ const data = [ { city: "London", lat: 51.5, lon: -0.12, requests: 20 }, ]; +describe("GlobeMap", () => { + it("renders an accessible SVG globe without ECharts", () => { + const onMarkerClick = vi.fn(); + const { getByLabelText, getByRole } = render( + , + ); + + const globe = getByLabelText("Traffic globe"); + expect(globe.tagName).toBe("svg"); + 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"]') + ?.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(); + + fireEvent.keyDown( + getByRole("button", { name: "London: Availability location" }), + { key: "Enter" }, + ); + expect(onMarkerClick).toHaveBeenCalledWith( + expect.objectContaining({ name: "London" }), + ); + }); + + it("updates rotation while dragging", async () => { + 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, + button: 0, + isPrimary: true, + }); + fireEvent.pointerMove(globe, { pointerId: 1, clientX: 140, clientY: 100 }); + + await waitFor(() => expect(land?.getAttribute("d")).not.toBe(initialPath)); + 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("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(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: /London:/ })); + expect(onMarkerClick).toHaveBeenCalledWith( + expect.objectContaining({ name: "London" }), + ); + }); +}); + 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/globe-land-mask.ts b/packages/kumo/src/components/chart/globe-land-mask.ts new file mode 100644 index 000000000..a74d1874d --- /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 7faf795e4..1875b9bf4 100644 --- a/packages/kumo/src/components/chart/index.ts +++ b/packages/kumo/src/components/chart/index.ts @@ -31,5 +31,6 @@ export { type BubbleMapProps, type ChoroplethMapProps, } 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"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 2d57ce434..7599991e4 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,