"use client"; import { geoGraticule10, geoOrthographic, geoPath } from "d3-geo"; import { feature } from "topojson-client"; import worldAtlas from "world-atlas/countries-110m.json"; import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import type { MapSelection, ViewerMapEntity, ViewerMapFeature, ViewerMapLink } from "../lib/viewer-map"; type CommandGlobeProps = { entities: ViewerMapEntity[]; features: ViewerMapFeature[]; links?: ViewerMapLink[]; selectedEntity: MapSelection; onSelectEntity: (entityId: MapSelection) => void; }; const SVG_WIDTH = 1280; const SVG_HEIGHT = 720; const countries = feature( worldAtlas as unknown as Parameters[0], (worldAtlas as { objects: { countries: object } }).objects.countries as Parameters[1], ) as unknown as GeoJSON.FeatureCollection; export function CommandGlobe({ entities, features, links = [], selectedEntity, onSelectEntity, }: CommandGlobeProps) { const [rotation, setRotation] = useState<[number, number, number]>([-20, -18, 0]); const dragRef = useRef<{ x: number; y: number; rotation: [number, number, number] } | null>(null); const entityLookup = useMemo(() => new Map(entities.map((entity) => [entity.id, entity])), [entities]); const projection = useMemo(() => { return geoOrthographic() .translate([SVG_WIDTH / 2, SVG_HEIGHT / 2]) .scale(300) .clipAngle(90) .rotate(rotation); }, [rotation]); const path = useMemo(() => geoPath(projection), [projection]); const spherePath = path({ type: "Sphere" }); const graticulePath = path(geoGraticule10()); const projectedPoints = useMemo(() => { return features .map((item) => { const point = projection([item.longitude, item.latitude]); const entity = entityLookup.get(item.entityId); const isSelected = selectedEntity === "all" || selectedEntity === item.entityId; return { ...item, point, color: entity?.color ?? "#90d1ff", accent: entity?.accent ?? "#dff3fb", isSelected, }; }) .filter((item) => item.point); }, [entityLookup, features, projection, selectedEntity]); const projectedLinks = useMemo(() => { return links .map((link) => { const linePath = path({ type: "Feature", geometry: { type: "LineString", coordinates: [link.from, link.to], }, properties: {}, }); const isSelected = selectedEntity === "all" || selectedEntity === link.fromAgentId || selectedEntity === link.toAgentId; return { ...link, linePath, color: entityLookup.get(link.fromAgentId)?.color ?? "#90d1ff", isSelected, }; }) .filter((link) => Boolean(link.linePath)); }, [entityLookup, links, path, selectedEntity]); function handlePointerDown(event: ReactPointerEvent) { dragRef.current = { x: event.clientX, y: event.clientY, rotation, }; event.currentTarget.setPointerCapture(event.pointerId); } function handlePointerMove(event: ReactPointerEvent) { if (!dragRef.current) { return; } const deltaX = event.clientX - dragRef.current.x; const deltaY = event.clientY - dragRef.current.y; setRotation([ dragRef.current.rotation[0] + deltaX * 0.25, Math.max(-55, Math.min(55, dragRef.current.rotation[1] - deltaY * 0.2)), 0, ]); } function handlePointerUp(event: ReactPointerEvent) { dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); } return (
{spherePath ? : null} {graticulePath ? : null} {countries.features.map((country, index) => { const countryPath = path(country); if (!countryPath) { return null; } return ; })} {projectedLinks.map((link) => ( ))} {projectedPoints.map((item) => { const [x, y] = item.point as [number, number]; return ( onSelectEntity(item.entityId)} > ); })}
Interactive globe fallback Drag to rotate
); }