import type { ExpressionSpecification, Map as MapLibreMap, StyleImageInterface } from "maplibre-gl"; import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers"; import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals"; import { SOURCE_LAYERS } from "./sources"; export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward"; export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse"; const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward"; const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse"; const FLOW_STATE_DIRECTION = "flowDirection"; const FLOW_PATTERN_WIDTH = 64; const FLOW_PATTERN_HEIGHT = 8; const FLOW_PATTERN_DURATION_MS = 1_200; export type PipeFlowCalculation = { pipeId: string | number; velocity: number; }; export type PipeFlowDirection = -1 | 0 | 1; export type PipeFlowSummary = { total: number; forward: number; reverse: number; stopped: number; invalid: number; }; type NormalizedPipeFlowCalculation = { pipeId: string | number; direction: PipeFlowDirection; }; export type FlowOverlayState = { calculations: Map; revision: number; visible: boolean; }; export function createFlowOverlayState(): FlowOverlayState { return { calculations: new Map(), revision: 0, visible: false }; } export async function showFlowOverlay( map: MapLibreMap, _state: FlowOverlayState, reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches ) { ensureFlowImages(map, reducedMotion); ensureFlowLayers(map); setFlowMockVisibility(map, _state.calculations.size === 0); _state.visible = true; setFlowLayerVisibility(map, "visible"); } export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) { _state.visible = false; setFlowLayerVisibility(map, "none"); } export async function replacePipeFlowCalculations( map: MapLibreMap, state: FlowOverlayState, records: readonly PipeFlowCalculation[] ): Promise { const revision = ++state.revision; const { calculations: next, invalid } = normalizePipeFlowCalculations(records); const changes: FlowStateChange[] = []; setFlowMockVisibility(map, false); state.calculations.forEach((_, key) => { if (!next.has(key)) changes.push({ type: "remove", key }); }); next.forEach((calculation, key) => { const previous = state.calculations.get(key); if ( previous?.direction !== calculation.direction ) { changes.push({ type: "set", key, calculation }); } }); if (state.visible && changes.length > 0) { setFlowLayerVisibility(map, "none"); } await applyFlowStateChanges(map, state, changes, revision); if (state.visible && revision === state.revision) { setFlowLayerVisibility(map, "visible"); } return summarizePipeFlowCalculations(state.calculations.values(), invalid); } export function updatePipeFlowCalculation( map: MapLibreMap, state: FlowOverlayState, record: PipeFlowCalculation ): PipeFlowSummary { const calculation = normalizePipeFlowCalculation(record); if (!calculation) throw new Error("INVALID_FLOW_CALCULATION"); state.revision += 1; const key = String(calculation.pipeId); const previous = state.calculations.get(key); if ( previous?.direction !== calculation.direction ) { setPipeFlowState(map, calculation); state.calculations.set(key, calculation); } if (state.visible) setFlowLayerVisibility(map, "visible"); return summarizePipeFlowCalculations(state.calculations.values(), 0); } export function clearPipeFlowCalculations( map: MapLibreMap, state: FlowOverlayState ) { return replacePipeFlowCalculations(map, state, []).then((summary) => { if (state.visible) { setFlowMockVisibility(map, true); } return summary; }); } export function getPipeFlowDirection(velocity: number): PipeFlowDirection { if (!Number.isFinite(velocity) || velocity === 0) return 0; return velocity < 0 ? -1 : 1; } type FlowStateChange = | { type: "remove"; key: string } | { type: "set"; key: string; calculation: NormalizedPipeFlowCalculation; }; function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) { const calculations = new Map(); let invalid = 0; records.forEach((record) => { const normalized = normalizePipeFlowCalculation(record); if (!normalized) { invalid += 1; return; } calculations.set(String(normalized.pipeId), normalized); }); return { calculations, invalid }; } function normalizePipeFlowCalculation( record: PipeFlowCalculation ): NormalizedPipeFlowCalculation | null { if ( (typeof record.pipeId !== "string" && typeof record.pipeId !== "number") || String(record.pipeId).trim().length === 0 || !Number.isFinite(record.velocity) ) { return null; } return { pipeId: record.pipeId, direction: getPipeFlowDirection(record.velocity) }; } function summarizePipeFlowCalculations( calculations: Iterable, invalid: number ): PipeFlowSummary { const summary: PipeFlowSummary = { total: 0, forward: 0, reverse: 0, stopped: 0, invalid }; for (const calculation of calculations) { summary.total += 1; if (calculation.direction > 0) summary.forward += 1; else if (calculation.direction < 0) summary.reverse += 1; else summary.stopped += 1; } return summary; } function setPipeFlowState( map: MapLibreMap, calculation: NormalizedPipeFlowCalculation ) { map.setFeatureState( { source: "pipes", sourceLayer: SOURCE_LAYERS.pipes, id: calculation.pipeId }, { [FLOW_STATE_DIRECTION]: calculation.direction } ); } async function applyFlowStateChanges( map: MapLibreMap, state: FlowOverlayState, changes: readonly FlowStateChange[], revision: number ) { const batchSize = 2_000; let offset = 0; await new Promise((resolve) => { const applyBatch = () => { if (revision !== state.revision) { resolve(); return; } const end = Math.min(offset + batchSize, changes.length); for (; offset < end; offset += 1) { const change = changes[offset]; if (change.type === "remove") { removePipeFlowState(map, change.key); state.calculations.delete(change.key); } else { setPipeFlowState(map, change.calculation); state.calculations.set(change.key, change.calculation); } } if (offset >= changes.length) { resolve(); return; } window.requestAnimationFrame(applyBatch); }; applyBatch(); }); } function removePipeFlowState(map: MapLibreMap, id: string | number) { const target = { source: "pipes", sourceLayer: SOURCE_LAYERS.pipes, id }; map.removeFeatureState(target, FLOW_STATE_DIRECTION); } function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) { if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) { map.addImage( FLOW_FORWARD_IMAGE_ID, createFlowPatternImage(1, reducedMotion), { pixelRatio: 1 } ); } if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) { map.addImage( FLOW_REVERSE_IMAGE_ID, createFlowPatternImage(-1, reducedMotion), { pixelRatio: 1 } ); } } function createFlowPatternImage( direction: -1 | 1, reducedMotion: boolean ): StyleImageInterface { let map: MapLibreMap | null = null; let lastOffset = Number.NaN; const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4); const draw = (offset: number) => { for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) { const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT); for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) { const wrapped = ( x - offset + FLOW_PATTERN_WIDTH ) % FLOW_PATTERN_WIDTH; const pulse = wrapped < 18 ? Math.sin((Math.PI * wrapped) / 18) : 0; const alpha = Math.round(255 * pulse * edgeFade); const index = (y * FLOW_PATTERN_WIDTH + x) * 4; data[index] = 94; data[index + 1] = 234; data[index + 2] = 212; data[index + 3] = alpha; } } }; draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1); return { width: FLOW_PATTERN_WIDTH, height: FLOW_PATTERN_HEIGHT, data, onAdd(nextMap) { map = nextMap; }, render() { if (reducedMotion) return false; const progress = (performance.now() % FLOW_PATTERN_DURATION_MS) / FLOW_PATTERN_DURATION_MS; const offset = Math.floor( progress * FLOW_PATTERN_WIDTH * direction + FLOW_PATTERN_WIDTH ) % FLOW_PATTERN_WIDTH; if (offset === lastOffset) { map?.triggerRepaint(); return false; } lastOffset = offset; draw(offset); map?.triggerRepaint(); return true; } }; } const mockDirectionExpression: ExpressionSpecification = [ "case", [ "==", ["%", ["to-number", ["id"], 0], 2], 0 ], 1, -1 ]; const directionExpression = ( direction: -1 | 1, visibleOpacity: number, mockVisible = false ): ExpressionSpecification => [ "case", [ "==", [ "coalesce", ["feature-state", FLOW_STATE_DIRECTION], mockVisible ? mockDirectionExpression : 0 ], direction ], visibleOpacity, 0 ]; function ensureFlowLayers(map: MapLibreMap) { if (!map.getLayer(FLOW_LINE_LAYER_ID)) { map.addLayer({ id: FLOW_LINE_LAYER_ID, type: "line", source: "pipes", "source-layer": SOURCE_LAYERS.pipes, minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom, layout: flowLineLayout(), paint: { "line-pattern": FLOW_FORWARD_IMAGE_ID, "line-width": 4, "line-opacity": directionExpression(1, 0.92) } }, WORKBENCH_INTERACTION_BEFORE_ID); } if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) { map.addLayer({ id: FLOW_REVERSE_LINE_LAYER_ID, type: "line", source: "pipes", "source-layer": SOURCE_LAYERS.pipes, minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom, layout: flowLineLayout(), paint: { "line-pattern": FLOW_REVERSE_IMAGE_ID, "line-width": 4, "line-opacity": directionExpression(-1, 0.92) } }, WORKBENCH_INTERACTION_BEFORE_ID); } } function flowLineLayout() { return { visibility: "visible" as const, "line-cap": "round" as const, "line-join": "round" as const }; } function setFlowLayerVisibility( map: MapLibreMap, visibility: "visible" | "none" ) { [ FLOW_LINE_LAYER_ID, FLOW_REVERSE_LINE_LAYER_ID ].forEach((layerId) => { if (map.getLayer(layerId)) { map.setLayoutProperty(layerId, "visibility", visibility); } }); } function setFlowMockVisibility( map: MapLibreMap, visible: boolean ) { const opacityByLayer = [ [FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92], [FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92] ] as const; opacityByLayer.forEach(([layerId, property, direction, opacity]) => { if (map.getLayer(layerId)) { map.setPaintProperty( layerId, property, directionExpression(direction, opacity, visible) ); } }); }