Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-globes-drift.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/kumo-docs-astro/src/components/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CHART_COMPONENT_URLS: Record<string, string> = {
TimeseriesChart: "/charts/timeseries",
BubbleMap: "/charts/maps#bubble-map",
ChoroplethMap: "/charts/maps#choropleth-map",
GlobeMap: "/charts/maps#cloudflare-availability-locations",
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-xl">
<GlobeMap
markers={cloudflareAvailabilityLocations}
landColor="var(--text-color-kumo-inactive)"
landHatchSpacing={8}
oceanColor="transparent"
showGraticule
markerColor="var(--color-kumo-brand)"
markerRadius={8}
autoRotate
aria-label="Cloudflare availability locations"
isDarkMode={isDarkMode}
/>
</div>
);
}
28 changes: 25 additions & 3 deletions packages/kumo-docs-astro/src/pages/charts/maps.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -67,15 +68,15 @@ try {
<ComponentSection>
<Heading level={2}>Installation</Heading>
<p class="mb-4 text-kumo-subtle">
<code class="text-kumo-default">BubbleMap</code> and <code class="text-kumo-default">ChoroplethMap</code> require <code class="text-kumo-default">echarts</code> as a peer dependency. Consumers provide the GeoJSON feature collection; map components do not fetch map data or use map tiles.
<code class="text-kumo-default">BubbleMap</code> and <code class="text-kumo-default">ChoroplethMap</code> require <code class="text-kumo-default">echarts</code> as a peer dependency. <code class="text-kumo-default">GlobeMap</code> 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.
</p>
<CodeBlock code={`npm install echarts`} lang="bash" />

<Heading level={3} class="mb-2 mt-6 text-lg">Barrel</Heading>
<CodeBlock code={`import { BubbleMap, ChoroplethMap } from "@cloudflare/kumo";`} lang="tsx" />
<CodeBlock code={`import { BubbleMap, ChoroplethMap, GlobeMap } from "@cloudflare/kumo";`} lang="tsx" />
<Heading level={3} class="mb-2 mt-4 text-lg">Granular</Heading>
<CodeBlock
code={`import { BubbleMap, ChoroplethMap } from "@cloudflare/kumo/components/chart";`}
code={`import { BubbleMap, ChoroplethMap, GlobeMap } from "@cloudflare/kumo/components/chart";`}
lang="tsx"
/>
</ComponentSection>
Expand Down Expand Up @@ -176,6 +177,25 @@ export default function Example() {
</ComponentExample>
</div>

<div>
<Heading level={3}>Cloudflare Availability Locations</Heading>
<p class="mb-4 text-kumo-subtle">
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.
</p>
<ComponentExample code={`<GlobeMap
markers={cloudflareAvailabilityLocations}
landColor="var(--text-color-kumo-inactive)"
landHatchSpacing={8}
oceanColor="transparent"
showGraticule
markerColor="var(--color-kumo-brand)"
markerRadius={8}
autoRotate
/>`}>
<GlobeMapAvailabilityZonesDemo client:visible />
</ComponentExample>
</div>

<div>
<Heading level={3}>Custom Tooltips</Heading>
<p class="mb-4 text-kumo-subtle">
Expand Down Expand Up @@ -206,5 +226,7 @@ export default function Example() {
<PropsTable component="BubbleMap" />
<Heading level={3} class="mb-2 mt-6 text-lg">ChoroplethMap</Heading>
<PropsTable component="ChoroplethMap" />
<Heading level={3} class="mb-2 mt-6 text-lg">GlobeMap</Heading>
<PropsTable component="GlobeMap" />
</ComponentSection>
</DocLayout>
80 changes: 80 additions & 0 deletions packages/kumo/scripts/generate-globe-land-mask.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
Loading
Loading