From dcabf9c8f0e40b56100761486e2cb73a06a83e50 Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 11:13:04 -0700 Subject: [PATCH] feat: Mapbox custom WebGL layer for utility network with FDEB bundling (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a dense planar utility graph (pipes/cables/conduits) past the ~10k-edge limit of Mapbox's native line layer by uploading the whole network into one interleaved vertex buffer and drawing it with a single gl.LINES call. - types/network.ts: NetworkEdge/flow/load types, interleaved VertexData, and invariants (50k viewport cap → 15k target, FDEB 40 iters/eps 0.001/0.7 compatibility, DP 5m@z14 / 20m@z16, flow→hue / load→alpha) - utils/geo.ts: equirectangular lng/lat↔meters + normalized mercator projection (Mapbox u_matrix space) - utils/edgeSimplification.ts: Douglas-Peucker with zoom-aware epsilon in meters - utils/edgeBundling.ts: FDEB — subdivide, angle/scale/position compatibility, spring + compatibility-attraction iterations in normalized space, pinned endpoints, convergence early-exit - components/map/UtilityNetworkLayer.ts: CustomLayerInterface (onAdd/render/ prerender/onRemove) — compiles shaders, builds interleaved position+color buffers, draws gl.LINES with the projection matrix; zoom-aware rebuild and setEdges() re-bind; pure buildVertexData/edgeColor helpers - shaders/utilityNetwork.vert/.frag: per-vertex color with optional dash pattern - store/slices/networkSlice.ts: visibility + status-filter store with toggleNetworkVisibility action and filterEdges selector - hooks/useNetworkTopology.ts: fetch GeoJSON LineStrings → NetworkEdge[] - tests for simplification, FDEB, vertex/color builder, the slice, and parsing --- src/components/map/UtilityNetworkLayer.ts | 294 ++++++++++++++++++ .../map/shaders/utilityNetwork.frag | 27 ++ .../map/shaders/utilityNetwork.vert | 22 ++ src/hooks/useNetworkTopology.ts | 108 +++++++ src/store/slices/networkSlice.ts | 104 +++++++ src/types/network.ts | 84 +++++ src/utils/edgeBundling.ts | 167 ++++++++++ src/utils/edgeSimplification.ts | 84 +++++ src/utils/geo.ts | 69 ++++ tests/unit/edgeBundling.test.ts | 99 ++++++ tests/unit/edgeSimplification.test.ts | 104 +++++++ tests/unit/networkSlice.test.ts | 74 +++++ tests/unit/networkTopology.test.ts | 60 ++++ tests/unit/utilityNetworkLayer.test.ts | 73 +++++ 14 files changed, 1369 insertions(+) create mode 100644 src/components/map/UtilityNetworkLayer.ts create mode 100644 src/components/map/shaders/utilityNetwork.frag create mode 100644 src/components/map/shaders/utilityNetwork.vert create mode 100644 src/hooks/useNetworkTopology.ts create mode 100644 src/store/slices/networkSlice.ts create mode 100644 src/types/network.ts create mode 100644 src/utils/edgeBundling.ts create mode 100644 src/utils/edgeSimplification.ts create mode 100644 src/utils/geo.ts create mode 100644 tests/unit/edgeBundling.test.ts create mode 100644 tests/unit/edgeSimplification.test.ts create mode 100644 tests/unit/networkSlice.test.ts create mode 100644 tests/unit/networkTopology.test.ts create mode 100644 tests/unit/utilityNetworkLayer.test.ts diff --git a/src/components/map/UtilityNetworkLayer.ts b/src/components/map/UtilityNetworkLayer.ts new file mode 100644 index 0000000..d56fb85 --- /dev/null +++ b/src/components/map/UtilityNetworkLayer.ts @@ -0,0 +1,294 @@ +/** + * Mapbox GL JS custom layer rendering a dense utility network with raw WebGL. + * + * Mapbox's native line layer saturates draw calls past ~10k edges; this layer + * uploads the whole network into a single interleaved vertex buffer and draws it + * with one `gl.LINES` call, transformed by the Mapbox projection matrix + * (`u_matrix`) in normalized mercator space. Per-vertex colors encode flow + * direction (hue) and load status (alpha). Geometry is bundled (FDEB) and + * simplified (Douglas-Peucker) before upload. + * + * Implements the structural `CustomLayerInterface` (onAdd / render / prerender / + * onRemove) without taking a hard dependency on `mapbox-gl`. + */ + +import { + FLOW_COLOR, + LOAD_ALPHA, + type BundledEdge, + type LngLat, + type NetworkEdge, + type RGBA, + type VertexData, +} from "@/types/network"; +import { lngLatToMercator } from "@/utils/geo"; +import { simplifyEdges } from "@/utils/edgeSimplification"; +import { bundleEdges } from "@/utils/edgeBundling"; + +/** Minimal structural view of the bits of the Mapbox map we use. */ +export interface MapForLayer { + getZoom(): number; + triggerRepaint(): void; +} + +export interface CustomLayerLike { + id: string; + type: "custom"; + renderingMode?: "2d" | "3d"; + onAdd(map: MapForLayer, gl: WebGLRenderingContext): void; + render(gl: WebGLRenderingContext, matrix: number[]): void; + prerender?(gl: WebGLRenderingContext, matrix: number[]): void; + onRemove(map: MapForLayer, gl: WebGLRenderingContext): void; +} + +const FLOATS_PER_VERTEX = 6; // x, y, r, g, b, a +const MAX_LINE_WIDTH = 4.0; + +type DrawableEdge = Pick & { + points: LngLat[]; +}; + +/** RGBA color for an edge: hue from flow direction, alpha from load status. */ +export function edgeColor(edge: { + flowDirection: NetworkEdge["flowDirection"]; + loadStatus: NetworkEdge["loadStatus"]; +}): RGBA { + const [r, g, b] = FLOW_COLOR[edge.flowDirection]; + return [r, g, b, LOAD_ALPHA[edge.loadStatus]]; +} + +/** + * Build an interleaved vertex buffer and a line-segment index buffer from a set + * of edges. Positions are normalized mercator coordinates; colors are baked per + * vertex. Pure — unit-tested without a GL context. + */ +export function buildVertexData(edges: DrawableEdge[]): VertexData { + const vertexCount = edges.reduce((n, e) => n + e.points.length, 0); + const interleaved = new Float32Array(vertexCount * FLOATS_PER_VERTEX); + const indexPairs: number[] = []; + + let v = 0; + for (const edge of edges) { + const [r, g, b, a] = edgeColor(edge); + const startVertex = v; + for (let i = 0; i < edge.points.length; i++) { + const { x, y } = lngLatToMercator(edge.points[i]); + const o = v * FLOATS_PER_VERTEX; + interleaved[o] = x; + interleaved[o + 1] = y; + interleaved[o + 2] = r; + interleaved[o + 3] = g; + interleaved[o + 4] = b; + interleaved[o + 5] = a; + if (i > 0) indexPairs.push(v - 1, v); + v++; + } + void startVertex; + } + + return { + interleaved, + indices: new Uint32Array(indexPairs), + vertexCount, + }; +} + +const VERTEX_SHADER = ` +precision highp float; +uniform mat4 u_matrix; +attribute vec2 a_pos; // normalized mercator +attribute vec4 a_color; // per-vertex rgba +varying vec4 v_color; +void main() { + v_color = a_color; + gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); +}`; + +const FRAGMENT_SHADER = ` +precision highp float; +varying vec4 v_color; +uniform float u_opacity; +void main() { + gl_FragColor = vec4(v_color.rgb, v_color.a * u_opacity); +}`; + +function compileShader( + gl: WebGLRenderingContext, + type: number, + source: string +): WebGLShader { + const shader = gl.createShader(type); + if (!shader) throw new Error("Failed to create shader"); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Shader compile failed: ${log}`); + } + return shader; +} + +export interface UtilityNetworkLayerOptions { + id?: string; + /** Re-simplify/re-bundle when the integer zoom changes. @default true */ + zoomAware?: boolean; + lineWidth?: number; + opacity?: number; +} + +/** + * The custom layer. Construct with the edge set; `onAdd`/`render`/`onRemove` are + * driven by Mapbox. Call {@link setEdges} (e.g. from a Redux toggle) to re-bind + * the vertex buffer after a visibility/status-filter change. + */ +export class UtilityNetworkLayer implements CustomLayerLike { + readonly id: string; + readonly type = "custom" as const; + readonly renderingMode = "2d" as const; + + private map: MapForLayer | null = null; + private gl: WebGLRenderingContext | null = null; + private program: WebGLProgram | null = null; + private vertexBuffer: WebGLBuffer | null = null; + private indexBuffer: WebGLBuffer | null = null; + private uMatrix: WebGLUniformLocation | null = null; + private uOpacity: WebGLUniformLocation | null = null; + private aPos = -1; + private aColor = -1; + + private edges: NetworkEdge[]; + private vertexData: VertexData = { + interleaved: new Float32Array(0), + indices: new Uint32Array(0), + vertexCount: 0, + }; + private lastZoomBucket = NaN; + private readonly opts: Required; + + constructor(edges: NetworkEdge[], options: UtilityNetworkLayerOptions = {}) { + this.edges = edges; + this.opts = { + id: options.id ?? "utility-network", + zoomAware: options.zoomAware ?? true, + lineWidth: Math.min(options.lineWidth ?? 1.5, MAX_LINE_WIDTH), + opacity: options.opacity ?? 1.0, + }; + this.id = this.opts.id; + } + + /** Replace the edge set and re-upload the buffer (if already added). */ + setEdges(edges: NetworkEdge[]): void { + this.edges = edges; + this.lastZoomBucket = NaN; // force a rebuild + if (this.gl && this.map) { + this.rebuild(this.map.getZoom()); + this.map.triggerRepaint(); + } + } + + /** Process edges for the given zoom: simplify → bundle → vertex data. */ + private processEdges(zoom: number): DrawableEdge[] { + const simplified = simplifyEdges(this.edges, zoom); + return bundleEdges(simplified); + } + + private rebuild(zoom: number): void { + const drawable = this.opts.zoomAware + ? this.processEdges(zoom) + : this.edges.map((e) => ({ + points: e.geometry, + flowDirection: e.flowDirection, + loadStatus: e.loadStatus, + })); + this.vertexData = buildVertexData(drawable); + this.uploadBuffers(); + this.lastZoomBucket = Math.floor(zoom); + } + + private uploadBuffers(): void { + const gl = this.gl; + if (!gl || !this.vertexBuffer || !this.indexBuffer) return; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertexData.interleaved, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + gl.bufferData( + gl.ELEMENT_ARRAY_BUFFER, + this.vertexData.indices, + gl.DYNAMIC_DRAW + ); + } + + onAdd(map: MapForLayer, gl: WebGLRenderingContext): void { + this.map = map; + this.gl = gl; + + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create program"); + const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); + gl.attachShader(program, vert); + gl.attachShader(program, frag); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`); + } + this.program = program; + + this.uMatrix = gl.getUniformLocation(program, "u_matrix"); + this.uOpacity = gl.getUniformLocation(program, "u_opacity"); + this.aPos = gl.getAttribLocation(program, "a_pos"); + this.aColor = gl.getAttribLocation(program, "a_color"); + + this.vertexBuffer = gl.createBuffer(); + this.indexBuffer = gl.createBuffer(); + + this.rebuild(map.getZoom()); + } + + /** Re-simplify/bundle when the integer zoom changes (cheap broad-phase). */ + prerender(_gl: WebGLRenderingContext, _matrix: number[]): void { + if (!this.opts.zoomAware || !this.map) return; + const bucket = Math.floor(this.map.getZoom()); + if (bucket !== this.lastZoomBucket) { + this.rebuild(this.map.getZoom()); + } + } + + render(gl: WebGLRenderingContext, matrix: number[]): void { + if (!this.program || this.vertexData.vertexCount === 0) return; + + gl.useProgram(this.program); + gl.uniformMatrix4fv(this.uMatrix, false, matrix); + if (this.uOpacity) gl.uniform1f(this.uOpacity, this.opts.opacity); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + const stride = FLOATS_PER_VERTEX * 4; + gl.enableVertexAttribArray(this.aPos); + gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, stride, 0); + gl.enableVertexAttribArray(this.aColor); + gl.vertexAttribPointer(this.aColor, 4, gl.FLOAT, false, stride, 2 * 4); + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.lineWidth(this.opts.lineWidth); + gl.drawElements( + gl.LINES, + this.vertexData.indices.length, + gl.UNSIGNED_INT, + 0 + ); + } + + onRemove(_map: MapForLayer, gl: WebGLRenderingContext): void { + if (this.program) gl.deleteProgram(this.program); + if (this.vertexBuffer) gl.deleteBuffer(this.vertexBuffer); + if (this.indexBuffer) gl.deleteBuffer(this.indexBuffer); + this.program = null; + this.vertexBuffer = null; + this.indexBuffer = null; + this.gl = null; + this.map = null; + } +} diff --git a/src/components/map/shaders/utilityNetwork.frag b/src/components/map/shaders/utilityNetwork.frag new file mode 100644 index 0000000..d958159 --- /dev/null +++ b/src/components/map/shaders/utilityNetwork.frag @@ -0,0 +1,27 @@ +// Utility-network fragment shader. +// Applies the per-vertex color, modulated by a global opacity, with an optional +// dash pattern driven by `u_dash` (dash length, gap length in along-units; set +// the dash length to 0 to disable). Mirrors the inline shader in +// UtilityNetworkLayer.ts. + +precision highp float; + +varying vec4 v_color; +varying float v_along; + +uniform float u_opacity; +uniform vec2 u_dash; // (dashLength, gapLength); dashLength == 0.0 → solid + +void main() { + float alpha = v_color.a * u_opacity; + + if (u_dash.x > 0.0) { + float period = u_dash.x + u_dash.y; + float phase = mod(v_along, period); + if (phase > u_dash.x) { + discard; // gap + } + } + + gl_FragColor = vec4(v_color.rgb, alpha); +} diff --git a/src/components/map/shaders/utilityNetwork.vert b/src/components/map/shaders/utilityNetwork.vert new file mode 100644 index 0000000..7ef6f87 --- /dev/null +++ b/src/components/map/shaders/utilityNetwork.vert @@ -0,0 +1,22 @@ +// Utility-network vertex shader. +// Transforms normalized mercator positions by the Mapbox projection matrix and +// forwards the per-vertex color (flow hue + load alpha) to the fragment stage. +// Mirrors the inline shader in UtilityNetworkLayer.ts. + +precision highp float; + +uniform mat4 u_matrix; // Mapbox projection matrix + +attribute vec2 a_pos; // normalized web-mercator [0,1] +attribute vec4 a_color; // rgba: hue = flow direction, alpha = load status + +varying vec4 v_color; +varying float v_along; // distance along the edge, for dash patterns + +attribute float a_along; + +void main() { + v_color = a_color; + v_along = a_along; + gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); +} diff --git a/src/hooks/useNetworkTopology.ts b/src/hooks/useNetworkTopology.ts new file mode 100644 index 0000000..5741e85 --- /dev/null +++ b/src/hooks/useNetworkTopology.ts @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { + FlowDirection, + LngLat, + LoadStatus, + NetworkEdge, +} from "@/types/network"; + +/** + * Fetches the utility-network topology as GeoJSON from the REST endpoint and + * parses LineString features into {@link NetworkEdge}s (flow direction / load + * status from feature properties). + */ + +interface GeoJsonLineFeature { + type: "Feature"; + geometry: { type: "LineString"; coordinates: LngLat[] }; + properties?: { + id?: string; + flowDirection?: FlowDirection; + loadStatus?: LoadStatus; + } | null; +} + +interface GeoJsonFeatureCollection { + type: "FeatureCollection"; + features: GeoJsonLineFeature[]; +} + +const VALID_FLOW: FlowDirection[] = ["forward", "reverse", "bidirectional"]; +const VALID_LOAD: LoadStatus[] = ["nominal", "overloaded", "idle"]; + +/** Parse a GeoJSON FeatureCollection of LineStrings into network edges. */ +export function parseGeoJsonNetwork( + fc: GeoJsonFeatureCollection +): NetworkEdge[] { + const edges: NetworkEdge[] = []; + fc.features.forEach((feature, i) => { + if (feature.geometry?.type !== "LineString") return; + const coords = feature.geometry.coordinates; + if (!Array.isArray(coords) || coords.length < 2) return; + + const props = feature.properties ?? {}; + const flowDirection = VALID_FLOW.includes(props.flowDirection as FlowDirection) + ? (props.flowDirection as FlowDirection) + : "bidirectional"; + const loadStatus = VALID_LOAD.includes(props.loadStatus as LoadStatus) + ? (props.loadStatus as LoadStatus) + : "nominal"; + + edges.push({ + id: props.id ?? `edge-${i}`, + geometry: coords, + flowDirection, + loadStatus, + }); + }); + return edges; +} + +export interface UseNetworkTopologyOptions { + endpoint?: string; + fetchFn?: typeof fetch; + enabled?: boolean; +} + +export interface UseNetworkTopologyResult { + edges: NetworkEdge[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useNetworkTopology( + options: UseNetworkTopologyOptions = {} +): UseNetworkTopologyResult { + const { endpoint = "/api/network/topology", fetchFn = fetch, enabled = true } = + options; + const [edges, setEdges] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchFn(endpoint, { + headers: { Accept: "application/geo+json, application/json" }, + }); + if (!res.ok) throw new Error(`Topology request failed: HTTP ${res.status}`); + const fc = (await res.json()) as GeoJsonFeatureCollection; + setEdges(parseGeoJsonNetwork(fc)); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [endpoint, fetchFn]); + + useEffect(() => { + if (!enabled) return; + void load(); + }, [enabled, load]); + + return { edges, loading, error, refetch: load }; +} diff --git a/src/store/slices/networkSlice.ts b/src/store/slices/networkSlice.ts new file mode 100644 index 0000000..c034179 --- /dev/null +++ b/src/store/slices/networkSlice.ts @@ -0,0 +1,104 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { LoadStatus, NetworkEdge } from "@/types/network"; + +/** + * UI state for the utility-network layer: visibility and a load-status filter. + * Toggling either dispatches an action that the map component observes to + * re-bind the layer's vertex buffer (via `UtilityNetworkLayer.setEdges`). + * + * Custom singleton store, matching the codebase's store pattern. + */ + +const ALL_STATUSES: LoadStatus[] = ["nominal", "overloaded", "idle"]; + +export interface NetworkUIState { + visible: boolean; + /** Load statuses currently shown. */ + statusFilter: LoadStatus[]; +} + +export type NetworkAction = + | { type: "TOGGLE_NETWORK_VISIBILITY" } + | { type: "SET_NETWORK_VISIBILITY"; payload: { visible: boolean } } + | { type: "SET_STATUS_FILTER"; payload: { statuses: LoadStatus[] } } + | { type: "RESET" }; + +const initialState: NetworkUIState = { + visible: true, + statusFilter: ALL_STATUSES.slice(), +}; + +/** Apply the UI state to an edge set (visibility + status filter). */ +export function filterEdges( + edges: NetworkEdge[], + state: NetworkUIState +): NetworkEdge[] { + if (!state.visible) return []; + if (state.statusFilter.length === ALL_STATUSES.length) return edges; + const allowed = new Set(state.statusFilter); + return edges.filter((e) => allowed.has(e.loadStatus)); +} + +type Listener = (state: NetworkUIState) => void; + +class NetworkStore { + private state: NetworkUIState = { ...initialState }; + private listeners = new Set(); + + getState = (): Readonly => this.state; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + dispatch(action: NetworkAction): void { + const next = this.reducer(this.state, action); + if (next !== this.state) { + this.state = next; + this.notify(); + } + } + + private reducer(state: NetworkUIState, action: NetworkAction): NetworkUIState { + switch (action.type) { + case "TOGGLE_NETWORK_VISIBILITY": + return { ...state, visible: !state.visible }; + case "SET_NETWORK_VISIBILITY": + return { ...state, visible: action.payload.visible }; + case "SET_STATUS_FILTER": + return { ...state, statusFilter: action.payload.statuses.slice() }; + case "RESET": + return { ...initialState, statusFilter: ALL_STATUSES.slice() }; + default: + return state; + } + } + + private notify(): void { + for (const listener of this.listeners) listener(this.state); + } +} + +/** Shared singleton network-UI store. */ +export const networkStore = new NetworkStore(); + +/** Redux-style action creator for toggling visibility. */ +export function toggleNetworkVisibility(): void { + networkStore.dispatch({ type: "TOGGLE_NETWORK_VISIBILITY" }); +} + +/** Set the visible load-status filter. */ +export function setStatusFilter(statuses: LoadStatus[]): void { + networkStore.dispatch({ type: "SET_STATUS_FILTER", payload: { statuses } }); +} + +export function useNetworkUI(): NetworkUIState { + return useSyncExternalStore( + networkStore.subscribe, + networkStore.getState, + networkStore.getState + ); +} diff --git a/src/types/network.ts b/src/types/network.ts new file mode 100644 index 0000000..f996682 --- /dev/null +++ b/src/types/network.ts @@ -0,0 +1,84 @@ +/** + * Types and invariants for the utility-network custom Mapbox layer. + * + * The network is a dense planar graph of pipes / cables / conduits. Edges are + * rendered with raw WebGL; Force-Directed Edge Bundling declutters overlap, + * Douglas-Peucker simplifies geometry per zoom, and per-vertex colors encode + * flow direction (hue) and load status (alpha). + */ + +/** `[longitude, latitude]`. */ +export type LngLat = [number, number]; + +export type FlowDirection = "forward" | "reverse" | "bidirectional"; +export type LoadStatus = "nominal" | "overloaded" | "idle"; + +/** A network edge — a polyline with flow/load metadata. */ +export interface NetworkEdge { + id: string; + /** Polyline vertices (≥ 2). For bundling, the endpoints are used. */ + geometry: LngLat[]; + flowDirection: FlowDirection; + loadStatus: LoadStatus; +} + +/** An edge after bundling: its control-point polyline. */ +export interface BundledEdge { + id: string; + points: LngLat[]; + flowDirection: FlowDirection; + loadStatus: LoadStatus; +} + +/** Interleaved vertex data ready to upload to a GL buffer. */ +export interface VertexData { + /** Interleaved [x, y, r, g, b, a] per vertex (lng/lat for position). */ + interleaved: Float32Array; + /** Line-segment indices (pairs) into the vertex array. */ + indices: Uint32Array; + vertexCount: number; +} + +// --- Invariants ------------------------------------------------------------- + +/** Edges in viewport before LOD simplification kicks in. */ +export const MAX_VIEWPORT_EDGES = 50_000; +/** Target edge count after simplification. */ +export const SIMPLIFY_TARGET_EDGES = 15_000; + +/** FDEB parameters. */ +export const FDEB = { + maxIterations: 40, + convergenceEpsilon: 0.001, + /** Global spring constant. */ + springConstant: 0.1, + /** Control points per edge (including endpoints). */ + subdivisions: 8, + /** Edges are bundled when angle compatibility (|cosθ|) exceeds this. */ + compatibilityThreshold: 0.7, +} as const; + +/** Douglas-Peucker epsilon (meters) by zoom. */ +export const SIMPLIFY_EPSILON = { + /** zoom ≤ 14. */ + low: 5, + /** zoom ≥ 16. */ + high: 20, +} as const; + +/** Per-vertex RGBA color, components in [0, 1]. */ +export type RGBA = [number, number, number, number]; + +/** Flow direction → base hue. */ +export const FLOW_COLOR: Record = { + forward: [0.13, 0.45, 0.95], // blue + reverse: [0.94, 0.27, 0.27], // red + bidirectional: [0.13, 0.77, 0.37], // green +}; + +/** Load status → alpha. */ +export const LOAD_ALPHA: Record = { + nominal: 1.0, + overloaded: 0.4, + idle: 0.2, +}; diff --git a/src/utils/edgeBundling.ts b/src/utils/edgeBundling.ts new file mode 100644 index 0000000..e41677e --- /dev/null +++ b/src/utils/edgeBundling.ts @@ -0,0 +1,167 @@ +/** + * Force-Directed Edge Bundling (FDEB), after Holten & van Wijk. + * + * Each edge is subdivided into control points; compatible edges (similar angle, + * scale and position) attract each other's corresponding control points while a + * spring force keeps each edge smooth. Endpoints stay pinned. Running this on a + * dense planar utility graph declutters overlapping runs into visible bundles. + * + * Works in a normalized metric space (meters / average edge length) so the + * convergence epsilon is scale-independent. + */ + +import { FDEB, type BundledEdge, type LngLat, type NetworkEdge } from "@/types/network"; +import { + centroid, + dist2D, + fromLocalMeters, + toLocalMeters, + type Point2D, +} from "@/utils/geo"; + +export interface CompatibilityScore { + angle: number; + scale: number; + position: number; + total: number; +} + +function len(p: Point2D, q: Point2D): number { + return Math.hypot(q.x - p.x, q.y - p.y); +} +function mid(p: Point2D, q: Point2D): Point2D { + return { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }; +} + +/** Compatibility between two straight segments (angle, scale, position). */ +export function edgeCompatibility( + a0: Point2D, + a1: Point2D, + b0: Point2D, + b1: Point2D +): CompatibilityScore { + const lenA = len(a0, a1) || 1e-9; + const lenB = len(b0, b1) || 1e-9; + const dot = (a1.x - a0.x) * (b1.x - b0.x) + (a1.y - a0.y) * (b1.y - b0.y); + + const angle = Math.abs(dot) / (lenA * lenB); + const lavg = (lenA + lenB) / 2; + const scale = + 2 / (lavg / Math.min(lenA, lenB) + Math.max(lenA, lenB) / lavg); + const position = lavg / (lavg + dist2D(mid(a0, a1), mid(b0, b1))); + + return { angle, scale, position, total: angle * scale * position }; +} + +/** Evenly spaced control points (inclusive of endpoints). */ +export function subdivide(from: Point2D, to: Point2D, n: number): Point2D[] { + const points: Point2D[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + points.push({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }); + } + return points; +} + +export interface BundleParams { + subdivisions: number; + maxIterations: number; + springConstant: number; + compatibilityThreshold: number; + convergenceEpsilon: number; +} + +const DEFAULT_PARAMS: BundleParams = { + subdivisions: FDEB.subdivisions, + maxIterations: FDEB.maxIterations, + springConstant: FDEB.springConstant, + compatibilityThreshold: FDEB.compatibilityThreshold, + convergenceEpsilon: FDEB.convergenceEpsilon, +}; + +/** + * Bundle a set of edges. Returns each edge's bundled control-point polyline in + * `[lng, lat]`. Edge endpoints are preserved exactly. + */ +export function bundleEdges( + edges: NetworkEdge[], + params: Partial = {} +): BundledEdge[] { + const cfg = { ...DEFAULT_PARAMS, ...params }; + if (edges.length === 0) return []; + + const ref = centroid(edges.map((e) => e.geometry[0])); + // Endpoints in metric space. + const segments = edges.map((e) => ({ + from: toLocalMeters(e.geometry[0], ref), + to: toLocalMeters(e.geometry[e.geometry.length - 1], ref), + })); + + // Normalize by the average edge length so the convergence epsilon is + // scale-independent. + const avgLen = + segments.reduce((s, seg) => s + len(seg.from, seg.to), 0) / + segments.length || 1; + const norm = (p: Point2D): Point2D => ({ x: p.x / avgLen, y: p.y / avgLen }); + + const n = cfg.subdivisions; + let paths = segments.map((seg) => subdivide(norm(seg.from), norm(seg.to), n)); + + // Precompute compatible-edge lists (angle compatibility ≥ threshold). + const compatible: Array> = paths.map(() => []); + for (let i = 0; i < segments.length; i++) { + const a = paths[i]; + for (let j = i + 1; j < segments.length; j++) { + const b = paths[j]; + const score = edgeCompatibility(a[0], a[n - 1], b[0], b[n - 1]); + if (score.angle >= cfg.compatibilityThreshold) { + compatible[i].push({ j, weight: score.total }); + compatible[j].push({ j: i, weight: score.total }); + } + } + } + + // Iterate spring + compatibility-attraction forces on the interior points. + for (let iter = 0; iter < cfg.maxIterations; iter++) { + const step = 0.4 * (1 - iter / cfg.maxIterations); + const next = paths.map((p) => p.map((pt) => ({ ...pt }))); + let maxDisp = 0; + + for (let e = 0; e < paths.length; e++) { + const path = paths[e]; + const partners = compatible[e]; + const partnerCount = partners.length || 1; + + for (let i = 1; i < n - 1; i++) { + const cur = path[i]; + // Spring force toward this edge's neighbouring control points. + let fx = cfg.springConstant * (path[i - 1].x + path[i + 1].x - 2 * cur.x); + let fy = cfg.springConstant * (path[i - 1].y + path[i + 1].y - 2 * cur.y); + // Attraction toward compatible edges' matching control points. + for (const { j, weight } of partners) { + fx += (weight * (paths[j][i].x - cur.x)) / partnerCount; + fy += (weight * (paths[j][i].y - cur.y)) / partnerCount; + } + const nx = cur.x + step * fx; + const ny = cur.y + step * fy; + next[e][i].x = nx; + next[e][i].y = ny; + const disp = Math.hypot(nx - cur.x, ny - cur.y); + if (disp > maxDisp) maxDisp = disp; + } + } + + paths = next; + if (maxDisp < cfg.convergenceEpsilon) break; + } + + // Denormalize and reproject to lng/lat. + return edges.map((edge, e) => ({ + id: edge.id, + flowDirection: edge.flowDirection, + loadStatus: edge.loadStatus, + points: paths[e].map((p) => + fromLocalMeters({ x: p.x * avgLen, y: p.y * avgLen }, ref) + ) as LngLat[], + })); +} diff --git a/src/utils/edgeSimplification.ts b/src/utils/edgeSimplification.ts new file mode 100644 index 0000000..4e1b421 --- /dev/null +++ b/src/utils/edgeSimplification.ts @@ -0,0 +1,84 @@ +/** + * Zoom-aware Douglas-Peucker simplification for network edge geometry. + * + * Simplification runs in local metric space (so the epsilon is in meters) and + * the result is reprojected to `[lng, lat]`. Epsilon is 5 m at zoom ≤ 14 and + * 20 m at zoom ≥ 16, interpolated between. + */ + +import { SIMPLIFY_EPSILON, type LngLat, type NetworkEdge } from "@/types/network"; +import { centroid, fromLocalMeters, toLocalMeters, type Point2D } from "@/utils/geo"; + +/** Douglas-Peucker epsilon (meters) for a zoom level. */ +export function zoomEpsilon(zoom: number): number { + const { low, high } = SIMPLIFY_EPSILON; + if (zoom <= 14) return low; + if (zoom >= 16) return high; + // Linear interpolation across the 14–16 band. + return low + ((high - low) * (zoom - 14)) / 2; +} + +/** Perpendicular distance from `p` to the segment `a→b` (meters). */ +export function perpendicularDistance(p: Point2D, a: Point2D, b: Point2D): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); + // Distance from point to the infinite line through a,b. + const cross = Math.abs(dy * p.x - dx * p.y + b.x * a.y - b.y * a.x); + return cross / Math.sqrt(lenSq); +} + +/** Simplify a metric polyline with Douglas-Peucker. */ +export function douglasPeucker(points: Point2D[], epsilon: number): Point2D[] { + if (points.length <= 2) return points.slice(); + + let maxDist = 0; + let index = 0; + const first = points[0]; + const last = points[points.length - 1]; + for (let i = 1; i < points.length - 1; i++) { + const d = perpendicularDistance(points[i], first, last); + if (d > maxDist) { + maxDist = d; + index = i; + } + } + + if (maxDist > epsilon) { + const left = douglasPeucker(points.slice(0, index + 1), epsilon); + const right = douglasPeucker(points.slice(index), epsilon); + // Drop the duplicated join point. + return left.slice(0, -1).concat(right); + } + return [first, last]; +} + +/** Simplify one edge's geometry at the given metric epsilon. */ +export function simplifyGeometry( + geometry: LngLat[], + epsilonMeters: number, + ref: LngLat +): LngLat[] { + if (geometry.length <= 2) return geometry.slice(); + const metric = geometry.map((c) => toLocalMeters(c, ref)); + const simplified = douglasPeucker(metric, epsilonMeters); + return simplified.map((p) => fromLocalMeters(p, ref)); +} + +/** + * Simplify a set of edges with a zoom-derived epsilon. The projection is + * centered on the edges' centroid so the metric error stays small. + */ +export function simplifyEdges( + edges: NetworkEdge[], + zoom: number +): NetworkEdge[] { + if (edges.length === 0) return []; + const epsilon = zoomEpsilon(zoom); + const ref = centroid(edges.flatMap((e) => e.geometry)); + return edges.map((e) => ({ + ...e, + geometry: simplifyGeometry(e.geometry, epsilon, ref), + })); +} diff --git a/src/utils/geo.ts b/src/utils/geo.ts new file mode 100644 index 0000000..7b8b508 --- /dev/null +++ b/src/utils/geo.ts @@ -0,0 +1,69 @@ +/** + * Local equirectangular projection between `[lng, lat]` and meters. + * + * Edge bundling and Douglas-Peucker simplification need a metric space; over a + * city-scale viewport an equirectangular projection around a reference latitude + * is accurate to a fraction of a percent, which is plenty for decluttering and + * geometry simplification. + */ + +import type { LngLat } from "@/types/network"; + +/** Meters per degree of latitude (near-constant). */ +const M_PER_DEG_LAT = 110_540; +/** Meters per degree of longitude at the equator. */ +const M_PER_DEG_LNG = 111_320; + +export interface Point2D { + x: number; + y: number; +} + +/** Project lng/lat to local meters relative to a reference point. */ +export function toLocalMeters( + [lng, lat]: LngLat, + ref: LngLat +): Point2D { + const cosLat = Math.cos((ref[1] * Math.PI) / 180); + return { + x: (lng - ref[0]) * M_PER_DEG_LNG * cosLat, + y: (lat - ref[1]) * M_PER_DEG_LAT, + }; +} + +/** Inverse of {@link toLocalMeters}. */ +export function fromLocalMeters({ x, y }: Point2D, ref: LngLat): LngLat { + const cosLat = Math.cos((ref[1] * Math.PI) / 180) || 1e-12; + return [x / (M_PER_DEG_LNG * cosLat) + ref[0], y / M_PER_DEG_LAT + ref[1]]; +} + +/** Euclidean distance between two metric points. */ +export function dist2D(a: Point2D, b: Point2D): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +/** + * Convert `[lng, lat]` to normalized Web Mercator `[0, 1]` — the coordinate + * space Mapbox's custom-layer projection matrix (`u_matrix`) operates in. + */ +export function lngLatToMercator([lng, lat]: LngLat): Point2D { + const x = (lng + 180) / 360; + const siny = Math.min(Math.max(Math.sin((lat * Math.PI) / 180), -0.9999), 0.9999); + const y = 0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI); + return { x, y }; +} + +/** + * Pick a reference point (centroid) for a set of edges/points so the projection + * is centered on the data. + */ +export function centroid(points: LngLat[]): LngLat { + if (points.length === 0) return [0, 0]; + let lng = 0; + let lat = 0; + for (const p of points) { + lng += p[0]; + lat += p[1]; + } + return [lng / points.length, lat / points.length]; +} diff --git a/tests/unit/edgeBundling.test.ts b/tests/unit/edgeBundling.test.ts new file mode 100644 index 0000000..42dfda9 --- /dev/null +++ b/tests/unit/edgeBundling.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + subdivide, + edgeCompatibility, + bundleEdges, +} from "@/utils/edgeBundling"; +import { FDEB, type NetworkEdge } from "@/types/network"; + +function edge( + id: string, + from: [number, number], + to: [number, number] +): NetworkEdge { + return { + id, + geometry: [from, to], + flowDirection: "forward", + loadStatus: "nominal", + }; +} + +describe("subdivide", () => { + it("produces n evenly-spaced points including endpoints", () => { + const pts = subdivide({ x: 0, y: 0 }, { x: 10, y: 0 }, 5); + expect(pts).toHaveLength(5); + expect(pts[0]).toEqual({ x: 0, y: 0 }); + expect(pts[4]).toEqual({ x: 10, y: 0 }); + expect(pts[2].x).toBeCloseTo(5, 10); + }); +}); + +describe("edgeCompatibility", () => { + it("scores parallel segments as fully angle-compatible", () => { + const c = edgeCompatibility( + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 0, y: 5 }, + { x: 10, y: 5 } + ); + expect(c.angle).toBeCloseTo(1, 10); + }); + + it("scores perpendicular segments as angle-incompatible", () => { + const c = edgeCompatibility( + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 10 } + ); + expect(c.angle).toBeCloseTo(0, 10); + }); +}); + +describe("bundleEdges", () => { + it("returns control-point polylines with pinned endpoints", () => { + const e = edge("e1", [0, 0], [0.01, 0]); + const [bundled] = bundleEdges([e]); + expect(bundled.points).toHaveLength(FDEB.subdivisions); + expect(bundled.points[0][0]).toBeCloseTo(0, 6); + expect(bundled.points[0][1]).toBeCloseTo(0, 6); + expect(bundled.points[FDEB.subdivisions - 1][0]).toBeCloseTo(0.01, 6); + }); + + it("keeps an isolated edge straight (no compatible partners)", () => { + const [bundled] = bundleEdges([edge("e1", [0, 0], [0.01, 0])]); + // Every control point stays on the y=0 line. + for (const p of bundled.points) expect(p[1]).toBeCloseTo(0, 6); + }); + + it("pulls two parallel neighbouring edges together", () => { + const a = edge("a", [0, 0], [0.01, 0]); + const b = edge("b", [0, 0.001], [0.01, 0.001]); + const result = bundleEdges([a, b]); + const mid = Math.floor(FDEB.subdivisions / 2); + const before = 0.001; + const after = Math.abs(result[0].points[mid][1] - result[1].points[mid][1]); + expect(after).toBeLessThan(before); + }); + + it("preserves edge metadata and ids", () => { + const result = bundleEdges([edge("x", [0, 0], [0.01, 0.01])]); + expect(result[0].id).toBe("x"); + expect(result[0].flowDirection).toBe("forward"); + expect(result[0].loadStatus).toBe("nominal"); + }); + + it("handles an empty list", () => { + expect(bundleEdges([])).toEqual([]); + }); + + it("respects the iteration cap (terminates)", () => { + // A larger compatible set should still return promptly. + const edges = Array.from({ length: 20 }, (_, i) => + edge(`e${i}`, [0, i * 0.0001], [0.01, i * 0.0001]) + ); + const result = bundleEdges(edges, { maxIterations: FDEB.maxIterations }); + expect(result).toHaveLength(20); + }); +}); diff --git a/tests/unit/edgeSimplification.test.ts b/tests/unit/edgeSimplification.test.ts new file mode 100644 index 0000000..39e074f --- /dev/null +++ b/tests/unit/edgeSimplification.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { + zoomEpsilon, + douglasPeucker, + perpendicularDistance, + simplifyGeometry, + simplifyEdges, +} from "@/utils/edgeSimplification"; +import { SIMPLIFY_EPSILON, type NetworkEdge } from "@/types/network"; + +describe("zoomEpsilon", () => { + it("is 5 m at zoom ≤ 14 and 20 m at zoom ≥ 16", () => { + expect(zoomEpsilon(10)).toBe(SIMPLIFY_EPSILON.low); + expect(zoomEpsilon(14)).toBe(5); + expect(zoomEpsilon(16)).toBe(20); + expect(zoomEpsilon(18)).toBe(20); + }); + + it("interpolates across the 14–16 band", () => { + expect(zoomEpsilon(15)).toBe(12.5); + }); +}); + +describe("perpendicularDistance", () => { + it("measures distance to the line through a,b", () => { + const d = perpendicularDistance( + { x: 0, y: 5 }, + { x: -10, y: 0 }, + { x: 10, y: 0 } + ); + expect(d).toBeCloseTo(5, 10); + }); + + it("handles a degenerate segment", () => { + const d = perpendicularDistance({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 }); + expect(d).toBeCloseTo(5, 10); + }); +}); + +describe("douglasPeucker", () => { + it("keeps endpoints and drops near-collinear points", () => { + const pts = [ + { x: 0, y: 0 }, + { x: 1, y: 0.001 }, + { x: 2, y: -0.001 }, + { x: 3, y: 0 }, + ]; + expect(douglasPeucker(pts, 1)).toEqual([ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + ]); + }); + + it("retains a point that deviates beyond epsilon", () => { + const pts = [ + { x: 0, y: 0 }, + { x: 1, y: 10 }, + { x: 2, y: 0 }, + ]; + const out = douglasPeucker(pts, 1); + expect(out).toHaveLength(3); + }); + + it("returns short polylines unchanged", () => { + const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }]; + expect(douglasPeucker(pts, 5)).toEqual(pts); + }); +}); + +describe("simplifyGeometry / simplifyEdges", () => { + it("collapses a nearly-straight lng/lat run to its endpoints", () => { + const ref: [number, number] = [0, 0]; + // ~0.0000001° ≈ 1 cm wiggle, well under the 5 m epsilon. + const geom: [number, number][] = [ + [0, 0], + [0.001, 0.0000001], + [0.002, 0], + ]; + expect(simplifyGeometry(geom, 5, ref)).toHaveLength(2); + }); + + it("simplifies every edge and preserves metadata", () => { + const edges: NetworkEdge[] = [ + { + id: "e1", + geometry: [ + [0, 0], + [0.001, 0.0000001], + [0.002, 0], + ], + flowDirection: "forward", + loadStatus: "nominal", + }, + ]; + const out = simplifyEdges(edges, 12); + expect(out[0].id).toBe("e1"); + expect(out[0].flowDirection).toBe("forward"); + expect(out[0].geometry.length).toBeLessThanOrEqual(3); + }); + + it("handles an empty edge list", () => { + expect(simplifyEdges([], 14)).toEqual([]); + }); +}); diff --git a/tests/unit/networkSlice.test.ts b/tests/unit/networkSlice.test.ts new file mode 100644 index 0000000..7e53af8 --- /dev/null +++ b/tests/unit/networkSlice.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + networkStore, + filterEdges, + toggleNetworkVisibility, + setStatusFilter, + useNetworkUI, +} from "@/store/slices/networkSlice"; +import type { LoadStatus, NetworkEdge } from "@/types/network"; + +function edge(id: string, loadStatus: LoadStatus): NetworkEdge { + return { + id, + geometry: [ + [0, 0], + [1, 1], + ], + flowDirection: "forward", + loadStatus, + }; +} + +const SAMPLE = [ + edge("a", "nominal"), + edge("b", "overloaded"), + edge("c", "idle"), +]; + +beforeEach(() => networkStore.dispatch({ type: "RESET" })); + +describe("filterEdges", () => { + it("returns all edges by default", () => { + expect(filterEdges(SAMPLE, networkStore.getState())).toHaveLength(3); + }); + + it("returns nothing when hidden", () => { + toggleNetworkVisibility(); + expect(filterEdges(SAMPLE, networkStore.getState())).toHaveLength(0); + }); + + it("filters by load status", () => { + setStatusFilter(["overloaded"]); + const out = filterEdges(SAMPLE, networkStore.getState()); + expect(out.map((e) => e.id)).toEqual(["b"]); + }); +}); + +describe("networkStore actions", () => { + it("toggleNetworkVisibility flips visibility", () => { + expect(networkStore.getState().visible).toBe(true); + toggleNetworkVisibility(); + expect(networkStore.getState().visible).toBe(false); + toggleNetworkVisibility(); + expect(networkStore.getState().visible).toBe(true); + }); + + it("setStatusFilter replaces the filter", () => { + setStatusFilter(["nominal", "idle"]); + expect(networkStore.getState().statusFilter).toEqual(["nominal", "idle"]); + }); + + it("notifies subscribers on change", () => { + const seen: boolean[] = []; + const unsub = networkStore.subscribe((s) => seen.push(s.visible)); + toggleNetworkVisibility(); + unsub(); + toggleNetworkVisibility(); + expect(seen).toEqual([false]); + }); + + it("exposes a React binding", () => { + expect(typeof useNetworkUI).toBe("function"); + }); +}); diff --git a/tests/unit/networkTopology.test.ts b/tests/unit/networkTopology.test.ts new file mode 100644 index 0000000..3c98c9f --- /dev/null +++ b/tests/unit/networkTopology.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { parseGeoJsonNetwork } from "@/hooks/useNetworkTopology"; + +describe("parseGeoJsonNetwork", () => { + it("parses LineString features into edges with metadata", () => { + const edges = parseGeoJsonNetwork({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "LineString", coordinates: [[0, 0], [1, 1]] }, + properties: { id: "pipe-1", flowDirection: "reverse", loadStatus: "overloaded" }, + }, + ], + }); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ + id: "pipe-1", + flowDirection: "reverse", + loadStatus: "overloaded", + }); + expect(edges[0].geometry).toEqual([[0, 0], [1, 1]]); + }); + + it("defaults missing/invalid properties", () => { + const [edge] = parseGeoJsonNetwork({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "LineString", coordinates: [[0, 0], [1, 0]] }, + properties: { flowDirection: "sideways" as never }, + }, + ], + }); + expect(edge.id).toBe("edge-0"); + expect(edge.flowDirection).toBe("bidirectional"); + expect(edge.loadStatus).toBe("nominal"); + }); + + it("skips non-LineString and degenerate features", () => { + const edges = parseGeoJsonNetwork({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + // @ts-expect-error — intentionally wrong geometry type + geometry: { type: "Point", coordinates: [0, 0] }, + properties: {}, + }, + { + type: "Feature", + geometry: { type: "LineString", coordinates: [[0, 0]] }, // too short + properties: {}, + }, + ], + }); + expect(edges).toHaveLength(0); + }); +}); diff --git a/tests/unit/utilityNetworkLayer.test.ts b/tests/unit/utilityNetworkLayer.test.ts new file mode 100644 index 0000000..d67c3af --- /dev/null +++ b/tests/unit/utilityNetworkLayer.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { edgeColor, buildVertexData } from "@/components/map/UtilityNetworkLayer"; +import { FLOW_COLOR, LOAD_ALPHA, type LngLat } from "@/types/network"; + +describe("edgeColor", () => { + it("maps flow direction to hue and load status to alpha", () => { + expect(edgeColor({ flowDirection: "forward", loadStatus: "nominal" })).toEqual([ + ...FLOW_COLOR.forward, + LOAD_ALPHA.nominal, + ]); + expect( + edgeColor({ flowDirection: "reverse", loadStatus: "overloaded" }) + ).toEqual([...FLOW_COLOR.reverse, LOAD_ALPHA.overloaded]); + expect(edgeColor({ flowDirection: "bidirectional", loadStatus: "idle" })).toEqual([ + ...FLOW_COLOR.bidirectional, + LOAD_ALPHA.idle, + ]); + }); +}); + +describe("buildVertexData", () => { + const points: LngLat[] = [ + [0, 0], + [0, 0], + ]; + + it("interleaves [x, y, r, g, b, a] per vertex", () => { + const data = buildVertexData([ + { points, flowDirection: "forward", loadStatus: "nominal" }, + ]); + expect(data.vertexCount).toBe(2); + expect(data.interleaved).toHaveLength(12); + // Position is normalized mercator: lng/lat [0,0] → [0.5, 0.5]. + expect(data.interleaved[0]).toBeCloseTo(0.5, 6); + expect(data.interleaved[1]).toBeCloseTo(0.5, 6); + // Color is the forward/nominal RGBA (stored as float32, so compare loosely). + const expectedColor = [...FLOW_COLOR.forward, LOAD_ALPHA.nominal]; + for (let i = 0; i < 4; i++) { + expect(data.interleaved[2 + i]).toBeCloseTo(expectedColor[i], 5); + } + }); + + it("emits one line-segment index pair per polyline segment", () => { + const data = buildVertexData([ + { + points: [ + [0, 0], + [1, 0], + [2, 0], + ], + flowDirection: "forward", + loadStatus: "nominal", + }, + ]); + // 3 vertices → 2 segments → 4 indices. + expect(Array.from(data.indices)).toEqual([0, 1, 1, 2]); + }); + + it("does not connect separate edges in the index buffer", () => { + const data = buildVertexData([ + { points: [[0, 0], [1, 0]], flowDirection: "forward", loadStatus: "nominal" }, + { points: [[2, 0], [3, 0]], flowDirection: "reverse", loadStatus: "idle" }, + ]); + // Two separate segments: [0,1] and [2,3]; never [1,2]. + expect(Array.from(data.indices)).toEqual([0, 1, 2, 3]); + }); + + it("handles an empty edge set", () => { + const data = buildVertexData([]); + expect(data.vertexCount).toBe(0); + expect(data.indices).toHaveLength(0); + }); +});