import type { FeatureCollection, Geometry, Position } from "geojson"; import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions, LayerSpecification } from "maplibre-gl"; import type { InvestigationOverlay } from "../api/investigation-presentation"; import { MAP_STYLE_TOKENS } from "./map-colors"; export const INVESTIGATION_HIGHLIGHT_SOURCE = "investigation-presentation-highlights"; export const INVESTIGATION_OVERLAY_SOURCE = "investigation-presentation-overlay"; const empty = (): FeatureCollection => ({ type: "FeatureCollection", features: [] }); const color = MAP_STYLE_TOKENS.state.selected; const layers: LayerSpecification[] = [ { id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-fill`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": color, "fill-opacity": 0.18 } }, { id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "line", filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": color, "line-width": 5, "line-opacity": 0.85 } }, { id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": color, "circle-radius": 11, "circle-opacity": 0.65, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } }, { id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } }, { id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } }, { id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } }, { id: `${INVESTIGATION_OVERLAY_SOURCE}-point`, source: INVESTIGATION_OVERLAY_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": MAP_STYLE_TOKENS.supply.waterQuality, "circle-radius": 6, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } }, { id: `${INVESTIGATION_OVERLAY_SOURCE}-heatmap`, source: INVESTIGATION_OVERLAY_SOURCE, type: "heatmap", paint: { "heatmap-weight": ["coalesce", ["get", "weight"], 1], "heatmap-radius": 28, "heatmap-opacity": 0.65, "heatmap-color": ["interpolate", ["linear"], ["heatmap-density"], 0, "rgba(15,118,110,0)", 0.3, MAP_STYLE_TOKENS.supply.waterQuality, 0.65, MAP_STYLE_TOKENS.state.risk, 1, MAP_STYLE_TOKENS.state.incident] } } ]; export function investigationGeometryCoordinates(geometry: Geometry): Position[] { if (geometry.type === "GeometryCollection") return geometry.geometries.flatMap(investigationGeometryCoordinates); const flatten = (value: unknown): Position[] => { if (!Array.isArray(value)) return []; if (typeof value[0] === "number" && typeof value[1] === "number") return [value as Position]; return value.flatMap(flatten); }; return flatten(geometry.coordinates); } /** All sources and animation state are owned by the currently followed investigation. */ export class InvestigationMapPresentation { private pulseFrame: number | null = null; private pulseDeadline: number | null = null; private highlighted = empty(); private overlay: InvestigationOverlay | null = null; constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions) {} private ensure() { const map = this.getMap(); if (!map) throw new Error("MAP_NOT_READY"); for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) { if (!map.getSource(id)) map.addSource(id, { type: "geojson", data: empty() }); } for (const layer of layers) { if (!map.getLayer(layer.id)) map.addLayer({ ...layer, layout: { visibility: "none" } }); } return map; } highlight(data: FeatureCollection, pulse: boolean, fit: boolean) { this.stopPulse(); const map = this.ensure(); this.highlighted = data; (map.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource).setData(data); this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, data.features.length > 0); if (fit) this.fit(data); if (pulse && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { const animate = (time: number) => { if (!map.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) return; const wave = (Math.sin(time / 300) + 1) / 2; map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 9 + wave * 5); map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.45 + wave * 0.4); this.pulseFrame = requestAnimationFrame(animate); }; this.pulseFrame = requestAnimationFrame(animate); this.pulseDeadline = window.setTimeout(() => this.stopPulse(), 2500); } } stopPulse() { if (this.pulseFrame !== null) cancelAnimationFrame(this.pulseFrame); this.pulseFrame = null; if (this.pulseDeadline !== null) window.clearTimeout(this.pulseDeadline); this.pulseDeadline = null; const map = this.getMap(); if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 11); if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.85); } clearHighlights() { this.stopPulse(); this.highlighted = empty(); (this.getMap()?.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource | undefined)?.setData(empty()); this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, false); } setOverlay(overlay: InvestigationOverlay, fit: boolean) { const map = this.ensure(); this.overlay = overlay; (map.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource).setData(overlay.data); this.showOverlay(overlay.visible); if (fit) this.fit(overlay.data); } showOverlay(visible: boolean) { if (!this.overlay) return; this.overlay = { ...this.overlay, visible }; const map = this.getMap(); for (const layer of layers.filter((item) => "source" in item && item.source === INVESTIGATION_OVERLAY_SOURCE)) { if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap") ? "visible" : "none"); } } clearOverlay(id?: string) { if (id && id !== this.overlay?.overlay_id) return; this.overlay = null; (this.getMap()?.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource | undefined)?.setData(empty()); this.visibility(INVESTIGATION_OVERLAY_SOURCE, false); } fitOverlay() { if (this.overlay) this.fit(this.overlay.data); } fitHighlights() { this.fit(this.highlighted); } private visibility(source: string, visible: boolean) { const map = this.getMap(); for (const layer of layers.filter((item) => "source" in item && item.source === source)) { if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none"); } } private fit(data: FeatureCollection) { const positions = data.features.flatMap((feature) => feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []); if (!positions.length) return; const bounds = positions.reduce((value, point) => [Math.min(value[0], point[0]), Math.min(value[1], point[1]), Math.max(value[2], point[0]), Math.max(value[3], point[1])], [Infinity, Infinity, -Infinity, -Infinity]); this.getMap()?.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.getPadding(), maxZoom: 18, duration: 0 }); } clear() { this.clearHighlights(); this.clearOverlay(); } destroy() { this.stopPulse(); const map = this.getMap(); if (!map) return; for (const layer of [...layers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id); for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) if (map.getSource(id)) map.removeSource(id); } }