Spaces:
Paused
Paused
| import { useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; | |
| import mapboxgl, { | |
| type GeoJSONSource, | |
| type LngLatBoundsLike, | |
| type MapboxGeoJSONFeature, | |
| type MapMouseEvent, | |
| } from "mapbox-gl"; | |
| import { CommandGlobe } from "./CommandGlobe"; | |
| import { getMapboxToken } from "../lib/env"; | |
| import type { MapSelection, ViewerMapEntity, ViewerMapFeature, ViewerMapLink, ViewerMapState } from "../lib/viewer-map"; | |
| type CommandMapProps = { | |
| entities: ViewerMapEntity[]; | |
| features: ViewerMapFeature[]; | |
| links?: ViewerMapLink[]; | |
| selectedEntity: MapSelection; | |
| onSelectEntity: (entityId: MapSelection) => void; | |
| worldSummary: ViewerMapState["worldSummary"] & { | |
| activeEventCount?: number; | |
| lastUpdatedLabel?: string; | |
| }; | |
| }; | |
| type GeoJsonFeatureProperties = { | |
| id: string; | |
| entityId: string; | |
| entityLabel: string; | |
| title: string; | |
| subtitle: string; | |
| category: string; | |
| importance: string; | |
| color: string; | |
| accent: string; | |
| selected: number; | |
| dimmed: number; | |
| interactive: number; | |
| }; | |
| type GeoJsonFeatureCollection = GeoJSON.FeatureCollection<GeoJSON.Geometry, GeoJsonFeatureProperties>; | |
| type MapFeatureMouseEvent = MapMouseEvent & { features?: MapboxGeoJSONFeature[] }; | |
| const DEFAULT_CENTER: [number, number] = [41.8, 27.8]; | |
| const DEFAULT_ZOOM = 2.35; | |
| const FALLBACK_ENTITY: MapSelection = "all"; | |
| const MAP_STYLE = "mapbox://styles/mapbox/dark-v11"; | |
| const INTEL_HIDDEN_LAYERS = [ | |
| "road-label", | |
| "settlement-label", | |
| "settlement-subdivision-label", | |
| "airport-label", | |
| "poi-label", | |
| "transit-label", | |
| "natural-label", | |
| ]; | |
| const LAYER_LABELS: Record<string, string> = { | |
| alliance_anchor: "Alliance Anchor", | |
| chokepoint: "Chokepoint", | |
| coalition_link: "Coalition Link", | |
| front: "Front", | |
| geospatial_anchor: "Geospatial Anchor", | |
| infrastructure: "Infrastructure", | |
| location: "Location", | |
| strategic_site: "Strategic Site", | |
| }; | |
| export function CommandMap({ | |
| entities, | |
| features, | |
| links = [], | |
| selectedEntity, | |
| onSelectEntity, | |
| worldSummary, | |
| }: CommandMapProps) { | |
| const mapContainerRef = useRef<HTMLDivElement | null>(null); | |
| const mapRef = useRef<mapboxgl.Map | null>(null); | |
| const popupRef = useRef<mapboxgl.Popup | null>(null); | |
| const [interactiveReady, setInteractiveReady] = useState(false); | |
| const [mapError, setMapError] = useState<string | null>(null); | |
| const mapId = useId().replace(/:/g, ""); | |
| const sourceId = `trenches-command-map-source-${mapId}`; | |
| const token = getMapboxToken(); | |
| const hasToken = typeof token === "string" && token.trim().length > 0; | |
| const entityLookup = useMemo( | |
| () => new Map(entities.map((entity) => [entity.id, entity])), | |
| [entities], | |
| ); | |
| const geoJson = useMemo<GeoJsonFeatureCollection>(() => { | |
| const pointFeatures: GeoJSON.Feature<GeoJSON.Point, GeoJsonFeatureProperties>[] = features.map((feature) => { | |
| const entity = entityLookup.get(feature.entityId); | |
| const isSelected = selectedEntity === FALLBACK_ENTITY || selectedEntity === feature.entityId; | |
| return { | |
| type: "Feature", | |
| id: feature.id, | |
| properties: { | |
| id: feature.id, | |
| entityId: feature.entityId, | |
| entityLabel: feature.entityName, | |
| title: feature.name, | |
| subtitle: feature.description, | |
| category: feature.layer, | |
| importance: feature.layer === "chokepoint" ? "critical" : feature.layer === "front" ? "high" : "medium", | |
| color: entity?.color ?? "#90d1ff", | |
| accent: entity?.accent ?? "#dff3fb", | |
| selected: isSelected ? 1 : 0, | |
| dimmed: isSelected ? 0 : 1, | |
| interactive: 1, | |
| }, | |
| geometry: { | |
| type: "Point", | |
| coordinates: [feature.longitude, feature.latitude], | |
| }, | |
| }; | |
| }); | |
| const linkFeatures: GeoJSON.Feature<GeoJSON.LineString, GeoJsonFeatureProperties>[] = links.map((link) => { | |
| const entity = entityLookup.get(link.fromAgentId); | |
| const isSelected = | |
| selectedEntity === FALLBACK_ENTITY || selectedEntity === link.fromAgentId || selectedEntity === link.toAgentId; | |
| const fromLabel = entityLookup.get(link.fromAgentId)?.displayName ?? link.fromAgentId; | |
| const toLabel = entityLookup.get(link.toAgentId)?.displayName ?? link.toAgentId; | |
| return { | |
| type: "Feature", | |
| id: link.id, | |
| properties: { | |
| id: link.id, | |
| entityId: link.fromAgentId, | |
| entityLabel: `${fromLabel} ↔ ${toLabel}`, | |
| title: "Coalition Link", | |
| subtitle: `${fromLabel} coordinated with ${toLabel}.`, | |
| category: "coalition_link", | |
| importance: "medium", | |
| color: entity?.color ?? "#90d1ff", | |
| accent: entity?.accent ?? "#dff3fb", | |
| selected: isSelected ? 1 : 0, | |
| dimmed: isSelected ? 0 : 1, | |
| interactive: 0, | |
| }, | |
| geometry: { | |
| type: "LineString", | |
| coordinates: [link.from, link.to], | |
| }, | |
| }; | |
| }); | |
| return { | |
| type: "FeatureCollection", | |
| features: [...linkFeatures, ...pointFeatures], | |
| }; | |
| }, [entityLookup, features, links, selectedEntity]); | |
| const focusFeatures = useMemo(() => { | |
| if (selectedEntity === FALLBACK_ENTITY) { | |
| return features; | |
| } | |
| return features.filter((feature) => feature.entityId === selectedEntity); | |
| }, [features, selectedEntity]); | |
| const focusLinks = useMemo(() => { | |
| if (selectedEntity === FALLBACK_ENTITY) { | |
| return links; | |
| } | |
| return links.filter((link) => link.fromAgentId === selectedEntity || link.toAgentId === selectedEntity); | |
| }, [links, selectedEntity]); | |
| const categorySummary = useMemo(() => { | |
| const counts = new Map<string, number>(); | |
| for (const feature of focusFeatures) { | |
| counts.set(feature.layer, (counts.get(feature.layer) ?? 0) + 1); | |
| } | |
| return [...counts.entries()] | |
| .sort((left, right) => right[1] - left[1]) | |
| .slice(0, 5) | |
| .map(([layer, count]) => ({ | |
| layer, | |
| label: LAYER_LABELS[layer] ?? startCase(layer), | |
| count, | |
| })); | |
| }, [focusFeatures]); | |
| const selectedEntityMeta = useMemo(() => { | |
| if (selectedEntity === FALLBACK_ENTITY) { | |
| return { | |
| label: "All Entities", | |
| color: "linear-gradient(135deg, #87f8c1 0%, #6bb8ff 100%)", | |
| headline: "Viewer-wide synthesis of every actor footprint, infrastructure chain, coalition line, and chokepoint.", | |
| priorities: ["Cross-entity route awareness", "Coalition edges", "Escalation geography", "Foreign basing"], | |
| featureCount: features.length, | |
| foreignBaseCount: 0, | |
| }; | |
| } | |
| const entity = | |
| selectedEntity === FALLBACK_ENTITY ? undefined : entityLookup.get(selectedEntity as ViewerMapEntity["id"]); | |
| return { | |
| label: entity?.displayName ?? selectedEntity, | |
| color: entity?.color ?? "#90d1ff", | |
| headline: entity?.headline ?? "Monitor the mapped footprint and strategic posture.", | |
| priorities: entity?.priorities ?? [], | |
| featureCount: entity?.featureCount ?? focusFeatures.length, | |
| foreignBaseCount: entity?.foreignBaseCount ?? 0, | |
| }; | |
| }, [entityLookup, features.length, focusFeatures.length, selectedEntity]); | |
| useEffect(() => { | |
| if (!hasToken || !mapContainerRef.current || mapRef.current) { | |
| return; | |
| } | |
| setInteractiveReady(false); | |
| setMapError(null); | |
| mapboxgl.accessToken = token; | |
| let map: mapboxgl.Map; | |
| try { | |
| map = new mapboxgl.Map({ | |
| container: mapContainerRef.current, | |
| style: MAP_STYLE, | |
| center: DEFAULT_CENTER, | |
| zoom: DEFAULT_ZOOM, | |
| attributionControl: false, | |
| projection: "globe", | |
| }); | |
| } catch (error) { | |
| setMapError(error instanceof Error ? error.message : "Failed to initialize WebGL."); | |
| return; | |
| } | |
| mapRef.current = map; | |
| popupRef.current = new mapboxgl.Popup({ | |
| closeButton: false, | |
| closeOnClick: false, | |
| offset: 18, | |
| className: "command-map-popup", | |
| }); | |
| map.addControl(new mapboxgl.NavigationControl({ visualizePitch: true }), "bottom-right"); | |
| map.on("style.load", () => { | |
| applyIntelligenceTheme(map); | |
| map.setFog({ | |
| color: "rgba(1, 5, 8, 0.96)", | |
| "high-color": "rgba(9, 27, 39, 0.34)", | |
| "space-color": "rgba(2, 5, 8, 1)", | |
| "star-intensity": 0.08, | |
| range: [-1, 2], | |
| }); | |
| }); | |
| map.on("load", () => { | |
| setInteractiveReady(true); | |
| if (!map.getSource(sourceId)) { | |
| map.addSource(sourceId, { | |
| type: "geojson", | |
| data: geoJson, | |
| }); | |
| } | |
| map.addLayer({ | |
| id: `${sourceId}-lines`, | |
| type: "line", | |
| source: sourceId, | |
| filter: ["==", ["geometry-type"], "LineString"], | |
| paint: { | |
| "line-color": ["get", "color"], | |
| "line-width": ["case", ["==", ["get", "selected"], 1], 3.2, 1.8], | |
| "line-opacity": ["case", ["==", ["get", "selected"], 1], 0.9, 0.24], | |
| "line-blur": ["case", ["==", ["get", "selected"], 1], 0.28, 0.85], | |
| }, | |
| }); | |
| map.addLayer({ | |
| id: `${sourceId}-point-halo`, | |
| type: "circle", | |
| source: sourceId, | |
| filter: ["==", ["geometry-type"], "Point"], | |
| paint: { | |
| "circle-radius": ["case", ["==", ["get", "selected"], 1], 15, 9], | |
| "circle-color": ["get", "color"], | |
| "circle-opacity": ["case", ["==", ["get", "selected"], 1], 0.2, 0.05], | |
| "circle-blur": 0.58, | |
| }, | |
| }); | |
| map.addLayer({ | |
| id: `${sourceId}-points`, | |
| type: "circle", | |
| source: sourceId, | |
| filter: ["==", ["geometry-type"], "Point"], | |
| paint: { | |
| "circle-radius": [ | |
| "case", | |
| ["==", ["get", "importance"], "critical"], | |
| 6.8, | |
| ["==", ["get", "importance"], "high"], | |
| 5.9, | |
| 4.9, | |
| ], | |
| "circle-color": ["get", "color"], | |
| "circle-stroke-color": ["case", ["==", ["get", "selected"], 1], "#f8fdff", "rgba(255,255,255,0.42)"], | |
| "circle-stroke-width": ["case", ["==", ["get", "selected"], 1], 1.45, 0.8], | |
| "circle-opacity": ["case", ["==", ["get", "selected"], 1], 0.98, 0.34], | |
| }, | |
| }); | |
| map.addLayer({ | |
| id: `${sourceId}-labels`, | |
| type: "symbol", | |
| source: sourceId, | |
| filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "selected"], 1]], | |
| layout: { | |
| "text-field": ["get", "title"], | |
| "text-size": 11, | |
| "text-offset": [0, 1.28], | |
| "text-anchor": "top", | |
| "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], | |
| }, | |
| paint: { | |
| "text-color": "rgba(233, 244, 248, 0.92)", | |
| "text-halo-color": "rgba(4, 10, 15, 0.95)", | |
| "text-halo-width": 1.2, | |
| "text-opacity": 0.9, | |
| }, | |
| }); | |
| bindInteraction(map, popupRef.current, onSelectEntity); | |
| fitMapToSelection(map, focusFeatures, focusLinks); | |
| }); | |
| map.on("error", (event) => { | |
| const nextError = event.error; | |
| setMapError(nextError instanceof Error ? nextError.message : "Map rendering error."); | |
| }); | |
| return () => { | |
| popupRef.current?.remove(); | |
| popupRef.current = null; | |
| setInteractiveReady(false); | |
| setMapError(null); | |
| map.remove(); | |
| mapRef.current = null; | |
| }; | |
| }, [focusFeatures, focusLinks, geoJson, hasToken, onSelectEntity, sourceId, token]); | |
| useEffect(() => { | |
| const map = mapRef.current; | |
| if (!map) { | |
| return; | |
| } | |
| const source = map.getSource(sourceId) as GeoJSONSource | undefined; | |
| if (source) { | |
| source.setData(geoJson); | |
| } | |
| fitMapToSelection(map, focusFeatures, focusLinks); | |
| }, [focusFeatures, focusLinks, geoJson, sourceId]); | |
| return ( | |
| <section className="command-map"> | |
| <div className="command-map__header"> | |
| <div> | |
| <p className="command-map__eyebrow">Viewer Layer</p> | |
| <h2>World Intelligence Layer</h2> | |
| <p className="command-map__lede"> | |
| This is a spectator command surface. Agents do not receive this spatial synthesis in their observation | |
| payload. | |
| </p> | |
| </div> | |
| <div className="command-map__metrics"> | |
| <MetricCard label="Turn" value={String(worldSummary.turn)} tone="neutral" /> | |
| <MetricCard label="Tension" value={worldSummary.tension.toFixed(1)} tone={scoreTone(worldSummary.tension)} /> | |
| <MetricCard | |
| label="Market Stress" | |
| value={worldSummary.marketStress.toFixed(1)} | |
| tone={scoreTone(worldSummary.marketStress)} | |
| /> | |
| <MetricCard label="Oil" value={worldSummary.oilPressure.toFixed(1)} tone={scoreTone(worldSummary.oilPressure)} /> | |
| <MetricCard label="Live" value={worldSummary.liveMode ? "Armed" : "Static"} tone="neutral" /> | |
| <MetricCard label="Events" value={String(worldSummary.activeEventCount ?? 0)} tone="neutral" /> | |
| </div> | |
| </div> | |
| <div className="command-map__viewport"> | |
| <div className="command-map__surface"> | |
| {hasToken && !mapError ? ( | |
| <div ref={mapContainerRef} className="command-map__canvas" aria-label="Operational asset map" /> | |
| ) : !hasToken ? ( | |
| <div className="command-map__fallback" role="status" aria-live="polite"> | |
| <div className="command-map__fallback-badge">Mapbox token missing</div> | |
| <h3>Spatial viewer is ready, but the tile layer is offline.</h3> | |
| <p> | |
| Set <code>NEXT_PUBLIC_MAPBOX_TOKEN</code> to render the theater map. Entity footprints, coalition links, and | |
| viewer controls remain available. | |
| </p> | |
| <div className="command-map__fallback-grid"> | |
| <div> | |
| <span>Tracked footprints</span> | |
| <strong>{features.length}</strong> | |
| </div> | |
| <div> | |
| <span>Coalition links</span> | |
| <strong>{links.length}</strong> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <CommandGlobe | |
| entities={entities} | |
| features={focusFeatures} | |
| links={focusLinks} | |
| selectedEntity={selectedEntity} | |
| onSelectEntity={onSelectEntity} | |
| /> | |
| )} | |
| {hasToken && mapError ? ( | |
| <div className="command-map__render-badge">WebGL unavailable, globe fallback active</div> | |
| ) : hasToken && !interactiveReady ? ( | |
| <div className="command-map__render-badge">Initializing interactive globe</div> | |
| ) : null} | |
| <div className="command-map__overlay command-map__overlay--top"> | |
| <div className="command-map__viewer-chip">Operator intelligence wall</div> | |
| {worldSummary.lastUpdatedLabel ? <span className="command-map__timestamp">{worldSummary.lastUpdatedLabel}</span> : null} | |
| </div> | |
| <div className="command-map__overlay command-map__overlay--bottom"> | |
| <div className="command-map__legend-line"> | |
| <span className="command-map__legend-title">Legend</span> | |
| <span>Signals mark assets, fronts, chokepoints, and foreign basing. Lines trace active coalition edges.</span> | |
| </div> | |
| <div className="command-map__legend-swatches"> | |
| {entities.map((entity) => ( | |
| <button | |
| key={entity.id} | |
| type="button" | |
| className={`command-map__swatch${selectedEntity === entity.id ? " is-active" : ""}`} | |
| onClick={() => onSelectEntity(entity.id)} | |
| style={cssVarStyle("--command-swatch", entity.color)} | |
| > | |
| <span className="command-map__swatch-color" /> | |
| <span>{entity.displayName}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <aside className="command-map__panel"> | |
| <div className="command-map__notice"> | |
| <span className="command-map__notice-label">Fog-of-war boundary</span> | |
| <p> | |
| The viewer sees the merged theater footprint. Models only receive text briefs, scoped source bundles, and | |
| role-limited actions. | |
| </p> | |
| </div> | |
| <div className="command-map__selector"> | |
| <label htmlFor={`${mapId}-entity-selector`}>Focus Entity</label> | |
| <div className="command-map__selector-grid"> | |
| <button | |
| type="button" | |
| className={`command-map__entity-button${selectedEntity === FALLBACK_ENTITY ? " is-active" : ""}`} | |
| onClick={() => onSelectEntity(FALLBACK_ENTITY)} | |
| > | |
| All Entities | |
| </button> | |
| {entities.map((entity) => ( | |
| <button | |
| key={entity.id} | |
| type="button" | |
| className={`command-map__entity-button${selectedEntity === entity.id ? " is-active" : ""}`} | |
| onClick={() => onSelectEntity(entity.id)} | |
| style={cssVarStyle("--command-entity-color", entity.color)} | |
| > | |
| {entity.displayName} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="command-map__focus-card"> | |
| <div className="command-map__focus-bar" style={{ background: selectedEntityMeta.color }} /> | |
| <p className="command-map__focus-label">Active Focus</p> | |
| <h3>{selectedEntityMeta.label}</h3> | |
| <p>{selectedEntityMeta.headline}</p> | |
| {selectedEntityMeta.priorities.length > 0 ? ( | |
| <ul className="command-map__priority-list"> | |
| {selectedEntityMeta.priorities.slice(0, 4).map((priority) => ( | |
| <li key={priority}>{priority}</li> | |
| ))} | |
| </ul> | |
| ) : null} | |
| <div className="command-map__focus-stats"> | |
| <div> | |
| <span>Footprints</span> | |
| <strong>{selectedEntityMeta.featureCount}</strong> | |
| </div> | |
| <div> | |
| <span>Foreign Bases</span> | |
| <strong>{selectedEntityMeta.foreignBaseCount}</strong> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="command-map__category-stack"> | |
| <div className="command-map__section-head"> | |
| <h3>Top Layers</h3> | |
| <span>{selectedEntity === FALLBACK_ENTITY ? "Entire theater" : "Focused entity"}</span> | |
| </div> | |
| {categorySummary.length > 0 ? ( | |
| categorySummary.map((item) => ( | |
| <div key={item.layer} className="command-map__category-row"> | |
| <span>{item.label}</span> | |
| <strong>{item.count}</strong> | |
| </div> | |
| )) | |
| ) : ( | |
| <p className="command-map__empty">No mapped footprints yet.</p> | |
| )} | |
| </div> | |
| <div className="command-map__entity-list"> | |
| <div className="command-map__section-head"> | |
| <h3>Entities</h3> | |
| <span>Mapped routing</span> | |
| </div> | |
| {entities.map((entity) => ( | |
| <button | |
| key={entity.id} | |
| type="button" | |
| className={`command-map__entity-row${selectedEntity === entity.id ? " is-active" : ""}`} | |
| onClick={() => onSelectEntity(entity.id)} | |
| style={cssVarStyle("--command-entity-color", entity.color)} | |
| > | |
| <span className="command-map__entity-color" /> | |
| <span className="command-map__entity-copy"> | |
| <strong>{entity.displayName}</strong> | |
| <small>{entity.headline}</small> | |
| </span> | |
| <span className="command-map__entity-count">{entity.featureCount}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </aside> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| function MetricCard({ | |
| label, | |
| value, | |
| tone, | |
| }: { | |
| label: string; | |
| value: string; | |
| tone: "neutral" | "warn" | "hot"; | |
| }) { | |
| return ( | |
| <div className={`command-map__metric command-map__metric--${tone}`}> | |
| <span>{label}</span> | |
| <strong>{value}</strong> | |
| </div> | |
| ); | |
| } | |
| function scoreTone(value: number): "neutral" | "warn" | "hot" { | |
| if (value >= 75) { | |
| return "hot"; | |
| } | |
| if (value >= 50) { | |
| return "warn"; | |
| } | |
| return "neutral"; | |
| } | |
| function applyIntelligenceTheme(map: mapboxgl.Map) { | |
| for (const layerId of INTEL_HIDDEN_LAYERS) { | |
| if (map.getLayer(layerId)) { | |
| map.setLayoutProperty(layerId, "visibility", "none"); | |
| } | |
| } | |
| const paintUpdates: Array<{ | |
| id: string; | |
| prop: string; | |
| value: string | number; | |
| }> = [ | |
| { id: "background", prop: "background-color", value: "#02070a" }, | |
| { id: "land", prop: "fill-color", value: "#071117" }, | |
| { id: "water", prop: "fill-color", value: "#040b10" }, | |
| { id: "waterway", prop: "line-color", value: "#0d2732" }, | |
| { id: "admin-0-boundary", prop: "line-color", value: "rgba(90, 150, 170, 0.18)" }, | |
| { id: "admin-1-boundary", prop: "line-color", value: "rgba(90, 150, 170, 0.1)" }, | |
| { id: "road-primary", prop: "line-color", value: "rgba(65, 96, 108, 0.16)" }, | |
| { id: "road-secondary-tertiary", prop: "line-color", value: "rgba(55, 78, 88, 0.1)" }, | |
| { id: "airport", prop: "circle-color", value: "rgba(120, 170, 188, 0.18)" }, | |
| { id: "country-label", prop: "text-color", value: "rgba(174, 204, 214, 0.38)" }, | |
| { id: "marine-label", prop: "text-color", value: "rgba(79, 121, 138, 0.32)" }, | |
| ]; | |
| for (const update of paintUpdates) { | |
| if (!map.getLayer(update.id)) { | |
| continue; | |
| } | |
| try { | |
| map.setPaintProperty(update.id, update.prop as never, update.value as never); | |
| } catch { | |
| continue; | |
| } | |
| } | |
| } | |
| function bindInteraction( | |
| map: mapboxgl.Map, | |
| popup: mapboxgl.Popup | null, | |
| onSelectEntity: (entityId: MapSelection) => void, | |
| ) { | |
| const interactiveLayers = map | |
| .getStyle() | |
| .layers?.filter((layer) => layer.id.endsWith("-points") || layer.id.endsWith("-lines")) | |
| .map((layer) => layer.id); | |
| if (!interactiveLayers || interactiveLayers.length === 0) { | |
| return; | |
| } | |
| for (const layerId of interactiveLayers) { | |
| map.on("mouseenter", layerId, () => { | |
| map.getCanvas().style.cursor = "pointer"; | |
| }); | |
| map.on("mouseleave", layerId, () => { | |
| map.getCanvas().style.cursor = ""; | |
| popup?.remove(); | |
| }); | |
| map.on("mousemove", layerId, (event) => { | |
| renderPopup(map, popup, event); | |
| }); | |
| map.on("click", layerId, (event) => { | |
| const feature = event.features?.[0]; | |
| if (!feature || !feature.properties) { | |
| return; | |
| } | |
| const properties = feature.properties as GeoJsonFeatureProperties; | |
| if (properties.interactive === 1) { | |
| onSelectEntity(properties.entityId as MapSelection); | |
| } | |
| }); | |
| } | |
| } | |
| function renderPopup(map: mapboxgl.Map, popup: mapboxgl.Popup | null, event: MapFeatureMouseEvent) { | |
| if (!popup) { | |
| return; | |
| } | |
| const feature = event.features?.[0]; | |
| if (!feature || !feature.properties) { | |
| popup.remove(); | |
| return; | |
| } | |
| const properties = feature.properties as GeoJsonFeatureProperties; | |
| popup | |
| .setLngLat(resolvePopupCoordinates(feature.geometry)) | |
| .setHTML(buildPopupMarkup(properties)) | |
| .addTo(map); | |
| } | |
| function buildPopupMarkup(properties: GeoJsonFeatureProperties) { | |
| return [ | |
| `<div class="command-map-popup__card">`, | |
| `<span class="command-map-popup__category">${escapeHtml(LAYER_LABELS[properties.category] ?? startCase(properties.category))}</span>`, | |
| `<strong>${escapeHtml(properties.title)}</strong>`, | |
| `<p>${escapeHtml(properties.entityLabel)}</p>`, | |
| properties.subtitle ? `<p class="command-map-popup__subtitle">${escapeHtml(properties.subtitle)}</p>` : "", | |
| `</div>`, | |
| ].join(""); | |
| } | |
| function resolvePopupCoordinates(geometry: GeoJSON.Geometry): [number, number] { | |
| if (geometry.type === "Point") { | |
| return geometry.coordinates as [number, number]; | |
| } | |
| if (geometry.type === "LineString") { | |
| return (geometry.coordinates[0] as [number, number]) ?? DEFAULT_CENTER; | |
| } | |
| return DEFAULT_CENTER; | |
| } | |
| function fitMapToSelection(map: mapboxgl.Map, features: ViewerMapFeature[], links: ViewerMapLink[]) { | |
| const coordinates: Array<[number, number]> = [ | |
| ...features.map((feature) => [feature.longitude, feature.latitude] as [number, number]), | |
| ...links.flatMap((link) => [link.from, link.to]), | |
| ]; | |
| if (coordinates.length === 0) { | |
| map.easeTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration: 650 }); | |
| return; | |
| } | |
| if (coordinates.length === 1) { | |
| map.easeTo({ center: coordinates[0], zoom: 4.2, duration: 650 }); | |
| return; | |
| } | |
| const bounds = new mapboxgl.LngLatBounds(coordinates[0], coordinates[0]); | |
| for (const coordinate of coordinates.slice(1)) { | |
| bounds.extend(coordinate); | |
| } | |
| map.fitBounds(bounds as LngLatBoundsLike, { | |
| padding: { top: 80, right: 420, bottom: 80, left: 80 }, | |
| duration: 850, | |
| maxZoom: 5.3, | |
| }); | |
| } | |
| function startCase(value: string) { | |
| return value | |
| .replace(/[_-]+/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| .replace(/\b\w/g, (char) => char.toUpperCase()); | |
| } | |
| function escapeHtml(value: string) { | |
| return value | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function cssVarStyle(name: string, value: string): CSSProperties { | |
| return { | |
| [name]: value, | |
| } as CSSProperties; | |
| } | |
| export default CommandMap; | |