| 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; |
|
|