feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling. Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
@@ -72,7 +72,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -89,7 +89,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 14,
|
||||
"text-font": ["Open Sans Bold"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [0, 0],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": ["get", "label"],
|
||||
"text-size": 13,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
@@ -124,7 +124,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
||||
layout: {
|
||||
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-offset": [5.2, -3.4],
|
||||
"text-anchor": "left",
|
||||
"text-allow-overlap": true
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: false,
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 484 }
|
||||
expected: { top: 72, right: 72, bottom: 32, left: 524 }
|
||||
},
|
||||
{
|
||||
agentOpen: false,
|
||||
@@ -33,7 +33,7 @@ describe("workbench camera padding", () => {
|
||||
{
|
||||
agentOpen: true,
|
||||
conditionOpen: true,
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 484 }
|
||||
expected: { top: 72, right: 504, bottom: 32, left: 524 }
|
||||
}
|
||||
])(
|
||||
"covers agent=$agentOpen and condition=$conditionOpen",
|
||||
@@ -47,7 +47,7 @@ describe("workbench camera padding", () => {
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 32,
|
||||
left: 644
|
||||
left: 540
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./map-colors";
|
||||
import { createBaseStyle } from "./sources";
|
||||
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||
const sourceColors = Object.values(MAP_STYLE_TOKENS.supply);
|
||||
const mapBackgrounds = [
|
||||
MAP_STYLE_TOKENS.canvas.primary,
|
||||
MAP_STYLE_TOKENS.canvas.secondary,
|
||||
@@ -47,18 +47,35 @@ describe("map color contrast", () => {
|
||||
});
|
||||
|
||||
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||
sourceColors.forEach(() => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps operational states above 3:1 on every map background", () => {
|
||||
const operationalColors = [
|
||||
MAP_STYLE_TOKENS.state.success,
|
||||
MAP_STYLE_TOKENS.state.risk,
|
||||
MAP_STYLE_TOKENS.state.incident,
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
MAP_STYLE_TOKENS.state.agent
|
||||
];
|
||||
|
||||
operationalColors.forEach((stateColor) => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||
expect(
|
||||
getContrastRatio(selectedColor, background),
|
||||
createCoolorsContrastUrl(selectedColor, background)
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
getContrastRatio(stateColor, background),
|
||||
createCoolorsContrastUrl(stateColor, background)
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||
it("keeps inactive supply assets above 3:1 and applies the primary map background", () => {
|
||||
mapBackgrounds.forEach((background) => {
|
||||
const renderedColor = compositeHexColor(
|
||||
MAP_STYLE_TOKENS.state.inactive,
|
||||
|
||||
@@ -1,21 +1,177 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
|
||||
import {
|
||||
FLOW_LINE_LAYER_ID,
|
||||
FLOW_REVERSE_LINE_LAYER_ID,
|
||||
clearPipeFlowCalculations,
|
||||
createFlowOverlayState,
|
||||
getPipeFlowDirection,
|
||||
replacePipeFlowCalculations,
|
||||
showFlowOverlay,
|
||||
updatePipeFlowCalculation
|
||||
} from "./flow-overlay";
|
||||
|
||||
describe("flow overlay lifecycle", () => {
|
||||
it("starts empty so conduit data can be loaded once per map session", () => {
|
||||
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
|
||||
describe("flow overlay", () => {
|
||||
it("classifies signed velocities relative to pipe geometry", () => {
|
||||
expect(getPipeFlowDirection(2.5)).toBe(1);
|
||||
expect(getPipeFlowDirection(-0.01)).toBe(-1);
|
||||
expect(getPipeFlowDirection(0)).toBe(0);
|
||||
expect(getPipeFlowDirection(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels an active animation frame without clearing cached data", () => {
|
||||
const cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("window", { cancelAnimationFrame });
|
||||
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
|
||||
const { map, layers } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
state.data = [];
|
||||
state.animationFrameId = 42;
|
||||
stopFlowAnimation(state);
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
|
||||
expect(state.animationFrameId).toBeNull();
|
||||
expect(state.data).toBeTruthy();
|
||||
|
||||
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
|
||||
|
||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||
source: "pipes",
|
||||
"source-layer": "geo_pipes_mat",
|
||||
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
|
||||
});
|
||||
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
|
||||
source: "pipes",
|
||||
"source-layer": "geo_pipes_mat",
|
||||
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
|
||||
});
|
||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||
expect(
|
||||
JSON.stringify((layers.get(FLOW_LINE_LAYER_ID) as { paint: Record<string, unknown> }).paint["line-opacity"])
|
||||
).toContain('["%",["to-number",["id"],0],2]');
|
||||
});
|
||||
|
||||
it("updates one pipe direction without rebuilding its vector source", () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
|
||||
const first = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: -2.4
|
||||
});
|
||||
const second = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: 1.2
|
||||
});
|
||||
|
||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
|
||||
source: "pipes",
|
||||
sourceLayer: "geo_pipes_mat",
|
||||
id: "P-1"
|
||||
}, {
|
||||
flowDirection: -1
|
||||
});
|
||||
expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
flowDirection: 1
|
||||
});
|
||||
expect(first).toMatchObject({ total: 1, reverse: 1 });
|
||||
expect(second).toMatchObject({ total: 1, forward: 1 });
|
||||
expect(map.getSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a snapshot and clears only stale flow state keys", async () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-old",
|
||||
velocity: 1
|
||||
});
|
||||
map.removeFeatureState.mockClear();
|
||||
|
||||
const summary = await replacePipeFlowCalculations(
|
||||
map as unknown as MapLibreMap,
|
||||
state,
|
||||
[
|
||||
{ pipeId: "P-new", velocity: -3 },
|
||||
{ pipeId: "P-new", velocity: 4 },
|
||||
{ pipeId: "", velocity: 1 }
|
||||
]
|
||||
);
|
||||
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "P-old"
|
||||
}), "flowDirection");
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted");
|
||||
expect(summary).toEqual({
|
||||
total: 1,
|
||||
forward: 1,
|
||||
reverse: 0,
|
||||
stopped: 0,
|
||||
invalid: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to the all-pipe mock after clearing explicit calculations", async () => {
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
|
||||
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
|
||||
pipeId: "P-1",
|
||||
velocity: 1
|
||||
});
|
||||
|
||||
await clearPipeFlowCalculations(map as unknown as MapLibreMap, state);
|
||||
|
||||
const latestOpacity = map.setPaintProperty.mock.calls
|
||||
.filter(([, property]) => property === "line-opacity")
|
||||
.at(-1)?.[2];
|
||||
expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]');
|
||||
});
|
||||
|
||||
it("applies large snapshots in animation-frame batches", async () => {
|
||||
const callbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback);
|
||||
return callbacks.length;
|
||||
}),
|
||||
cancelAnimationFrame: vi.fn()
|
||||
});
|
||||
const { map } = createMap();
|
||||
const state = createFlowOverlayState();
|
||||
const records = Array.from({ length: 2_001 }, (_, index) => ({
|
||||
pipeId: index,
|
||||
velocity: index % 2 === 0 ? 1 : -1
|
||||
}));
|
||||
|
||||
const pending = replacePipeFlowCalculations(
|
||||
map as unknown as MapLibreMap,
|
||||
state,
|
||||
records
|
||||
);
|
||||
expect(map.setFeatureState).toHaveBeenCalledTimes(2_000);
|
||||
callbacks.shift()?.(16);
|
||||
const summary = await pending;
|
||||
|
||||
expect(map.setFeatureState).toHaveBeenCalledTimes(2_001);
|
||||
expect(summary.total).toBe(2_001);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
function createMap() {
|
||||
const layers = new Map<string, unknown>();
|
||||
const images = new Set<string>();
|
||||
const map = {
|
||||
addImage: vi.fn((id: string) => {
|
||||
images.add(id);
|
||||
}),
|
||||
addLayer: vi.fn((layer: { id: string }) => {
|
||||
layers.set(layer.id, layer);
|
||||
return map;
|
||||
}),
|
||||
getLayer: vi.fn((id: string) => layers.get(id)),
|
||||
getSource: vi.fn(),
|
||||
hasImage: vi.fn((id: string) => images.has(id)),
|
||||
removeFeatureState: vi.fn(),
|
||||
setFeatureState: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
setPaintProperty: vi.fn((id: string, property: string, value: unknown) => {
|
||||
const layer = layers.get(id) as { paint?: Record<string, unknown> } | undefined;
|
||||
if (layer?.paint) layer.paint[property] = value;
|
||||
}),
|
||||
triggerRepaint: vi.fn()
|
||||
};
|
||||
return { map, layers };
|
||||
}
|
||||
|
||||
@@ -1,99 +1,437 @@
|
||||
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
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";
|
||||
|
||||
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
|
||||
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 = {
|
||||
data: FlowFeature[] | null;
|
||||
animationFrameId: number | null;
|
||||
overlay: MapboxOverlay | null;
|
||||
calculations: Map<string, NormalizedPipeFlowCalculation>;
|
||||
revision: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function createFlowOverlayState(): FlowOverlayState {
|
||||
return { data: null, animationFrameId: null, overlay: null };
|
||||
return {
|
||||
calculations: new Map(),
|
||||
revision: 0,
|
||||
visible: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function showFlowOverlay(
|
||||
map: MapLibreMap,
|
||||
state: FlowOverlayState,
|
||||
loadData: () => Promise<FeatureCollection>,
|
||||
_state: FlowOverlayState,
|
||||
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
if (!state.data) {
|
||||
const collection = await loadData();
|
||||
state.data = collection.features.filter(isFlowFeature);
|
||||
}
|
||||
ensureFlowImages(map, reducedMotion);
|
||||
ensureFlowLayers(map);
|
||||
setFlowMockVisibility(map, _state.calculations.size === 0);
|
||||
_state.visible = true;
|
||||
setFlowLayerVisibility(map, "visible");
|
||||
}
|
||||
|
||||
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
|
||||
import("@deck.gl/mapbox"),
|
||||
import("@deck.gl/layers"),
|
||||
import("./flow-path-extension")
|
||||
]);
|
||||
const extension = new FlowPathExtension();
|
||||
let renderingError: Error | null = null;
|
||||
const render = (phase: number) => {
|
||||
const layer = new PathLayer<FlowFeature>({
|
||||
id: "workbench-network-flow",
|
||||
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
|
||||
data: state.data ?? [],
|
||||
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
|
||||
getColor: [15, 118, 110, 210],
|
||||
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
|
||||
widthUnits: "pixels",
|
||||
widthMinPixels: 1,
|
||||
widthMaxPixels: 5,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
pickable: false,
|
||||
parameters: { depthTest: false },
|
||||
extensions: [extension],
|
||||
flowPhase: phase
|
||||
} as never);
|
||||
state.overlay?.setProps({
|
||||
layers: [layer],
|
||||
onError: (error) => {
|
||||
renderingError = error;
|
||||
}
|
||||
});
|
||||
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<PipeFlowSummary> {
|
||||
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;
|
||||
};
|
||||
|
||||
if (!state.overlay) {
|
||||
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
|
||||
map.addControl(overlay);
|
||||
state.overlay = overlay;
|
||||
}
|
||||
function normalizePipeFlowCalculations(records: readonly PipeFlowCalculation[]) {
|
||||
const calculations = new Map<string, NormalizedPipeFlowCalculation>();
|
||||
let invalid = 0;
|
||||
records.forEach((record) => {
|
||||
const normalized = normalizePipeFlowCalculation(record);
|
||||
if (!normalized) {
|
||||
invalid += 1;
|
||||
return;
|
||||
}
|
||||
calculations.set(String(normalized.pipeId), normalized);
|
||||
});
|
||||
return { calculations, invalid };
|
||||
}
|
||||
|
||||
stopFlowAnimation(state);
|
||||
render(0.2);
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
if (renderingError) throw renderingError;
|
||||
if (!reducedMotion) {
|
||||
const startedAt = performance.now();
|
||||
const tick = (now: number) => {
|
||||
render(((now - startedAt) / 1800) % 1);
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
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<NormalizedPipeFlowCalculation>,
|
||||
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<void>((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);
|
||||
};
|
||||
state.animationFrameId = window.requestAnimationFrame(tick);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
|
||||
stopFlowAnimation(state);
|
||||
if (state.overlay) {
|
||||
map.removeControl(state.overlay as never);
|
||||
state.overlay = null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function stopFlowAnimation(state: FlowOverlayState) {
|
||||
if (state.animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(state.animationFrameId);
|
||||
state.animationFrameId = null;
|
||||
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 isFlowFeature(feature: Feature): feature is FlowFeature {
|
||||
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
|
||||
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)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LayerExtension, type Layer } from "@deck.gl/core";
|
||||
|
||||
const flowPathUniforms = {
|
||||
name: "flowPath",
|
||||
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
|
||||
uniformTypes: { phase: "f32" }
|
||||
} as const;
|
||||
|
||||
export class FlowPathExtension extends LayerExtension {
|
||||
static extensionName = "FlowPathExtension";
|
||||
static defaultProps = { flowPhase: { type: "number", value: 0 } };
|
||||
|
||||
getShaders() {
|
||||
return {
|
||||
modules: [flowPathUniforms],
|
||||
inject: {
|
||||
"fs:DECKGL_FILTER_COLOR": `
|
||||
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
|
||||
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
|
||||
color.a *= 0.22 + flowPulse * 0.78;
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
draw(this: Layer) {
|
||||
const state = this.state as unknown as {
|
||||
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
|
||||
};
|
||||
state.model?.shaderInputs?.setProps({
|
||||
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type PointLayerGroupStylePatch = {
|
||||
opacity?: number;
|
||||
radiusMultiplier?: number;
|
||||
strokeMultiplier?: number;
|
||||
iconMultiplier?: number;
|
||||
};
|
||||
|
||||
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
|
||||
@@ -73,7 +74,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
const values = patch as Record<string, unknown>;
|
||||
const allowed = geometry === "line"
|
||||
? ["color", "opacity", "widthMultiplier", "dash"]
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
|
||||
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
|
||||
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
|
||||
if ("color" in values && !isColor(values.color)) return false;
|
||||
if ("fillColor" in values && !isColor(values.fillColor)) return false;
|
||||
@@ -82,6 +83,7 @@ export function validateLayerGroupStylePatch(groupId: string, patch: unknown): p
|
||||
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
|
||||
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
|
||||
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
|
||||
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
|
||||
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +153,14 @@ function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
if (patch.strokeMultiplier !== undefined) {
|
||||
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
|
||||
}
|
||||
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
|
||||
setLayout(
|
||||
map,
|
||||
`${groupId}-symbol`,
|
||||
"icon-size",
|
||||
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
|
||||
@@ -163,6 +173,9 @@ function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "re
|
||||
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
|
||||
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
|
||||
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
|
||||
if ("iconSize" in visual) {
|
||||
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
function scale(value: unknown, multiplier: number): unknown {
|
||||
@@ -173,6 +186,12 @@ function setPaint(map: MapLibreMap, layerId: string, property: string, value: un
|
||||
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
|
||||
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
|
||||
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
|
||||
map.setLayoutProperty(layerId, property, (value ?? null) as never);
|
||||
}
|
||||
}
|
||||
|
||||
function inRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
@@ -7,30 +7,31 @@ export const MAP_STYLE_TOKENS = {
|
||||
casing: "#FFFFFF",
|
||||
transparent: "rgba(0,0,0,0)"
|
||||
},
|
||||
asset: {
|
||||
conduit: "#0F766E",
|
||||
junction: "#0369A1",
|
||||
orifice: "#475569",
|
||||
pump: "#155E75",
|
||||
outfall: "#334155"
|
||||
},
|
||||
supply: {
|
||||
pipeMinor: "#5A9E8A",
|
||||
pipeMinor: "#3F7F70",
|
||||
pipeMedium: "#217C83",
|
||||
pipeMajor: "#245E75",
|
||||
junction: "#5B8E91",
|
||||
valve: "#8A8A8A",
|
||||
junction: "#47787B",
|
||||
valve: "#6B7280",
|
||||
reservoir: "#195F84",
|
||||
scadaFlow: "#0094BC",
|
||||
scadaPressure: "#FFB624"
|
||||
scadaFlow: "#007A9E",
|
||||
scadaPressure: "#9A6700"
|
||||
},
|
||||
// Map overlays stay more chromatic than UI status surfaces so they remain legible over imagery.
|
||||
state: {
|
||||
selected: "#2563EB",
|
||||
inactive: "#64748B",
|
||||
risk: "#C2410C",
|
||||
incident: "#B91C1C",
|
||||
success: "#15803D",
|
||||
agent: "#6D28D9"
|
||||
risk: "#C66A16",
|
||||
incident: "#C34A4A",
|
||||
success: "#2E8B64",
|
||||
agent: "#7656C7"
|
||||
},
|
||||
stateLabel: {
|
||||
neutral: "#334155",
|
||||
warning: "#8C470D",
|
||||
danger: "#9C3036",
|
||||
success: "#1F6F4D",
|
||||
agent: "#573E9C"
|
||||
},
|
||||
tool: {
|
||||
geometry: "#334155"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_CONDUIT_FEATURES,
|
||||
MAX_PIPE_FEATURES,
|
||||
MAP_FEATURE_ID_FIELDS,
|
||||
createMapFeatureWfsUrl,
|
||||
escapeCqlLiteral,
|
||||
@@ -20,7 +20,7 @@ describe("map feature WFS query", () => {
|
||||
tanks: "id"
|
||||
});
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "junctions", featureIds: ["J-1"] }).searchParams.get("cql_filter"))
|
||||
.toBe("id IN ('J-1')");
|
||||
.toBe("\"id\" IN ('J-1')");
|
||||
});
|
||||
|
||||
it("escapes CQL literals and uses materialized WFS layers", () => {
|
||||
@@ -28,7 +28,7 @@ describe("map feature WFS query", () => {
|
||||
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
|
||||
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
|
||||
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
|
||||
expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')");
|
||||
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
|
||||
});
|
||||
|
||||
it("uses the authoritative supply asset WFS layers", () => {
|
||||
@@ -39,7 +39,7 @@ describe("map feature WFS query", () => {
|
||||
|
||||
it("limits ids and permits full-network requests only for pipes", () => {
|
||||
expect(parseMapFeatureQuery({ sourceId: "pipes" })).toEqual({ ok: true, value: { sourceId: "pipes" } });
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_FEATURES));
|
||||
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
|
||||
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
|
||||
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
|
||||
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
|
||||
|
||||
export const MAX_MAP_FEATURE_IDS = 100;
|
||||
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
|
||||
export const MAX_CONDUIT_FEATURES = 5000;
|
||||
export const MAX_PIPE_FEATURES = 5000;
|
||||
|
||||
export type MapFeatureQuery = {
|
||||
sourceId: WaterNetworkSourceId;
|
||||
@@ -75,11 +75,11 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
outputFormat: "application/json",
|
||||
srsName: "EPSG:4326",
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES)
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
|
||||
});
|
||||
|
||||
if (query.featureIds) {
|
||||
params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
}
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
|
||||
@@ -15,10 +15,10 @@ describe("SCADA analysis overlay", () => {
|
||||
const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator);
|
||||
const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label);
|
||||
expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({
|
||||
high: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
high: "#C34A4A",
|
||||
medium: "#C66A16",
|
||||
low: "#2E8B64",
|
||||
unrated: "#7656C7"
|
||||
});
|
||||
expect(casing).toMatchObject({
|
||||
type: "circle",
|
||||
@@ -49,9 +49,10 @@ describe("SCADA analysis overlay", () => {
|
||||
},
|
||||
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 4, "text-halo-blur": 0.5 }
|
||||
});
|
||||
expect(JSON.stringify(label)).toContain("#991B1B");
|
||||
expect(JSON.stringify(label)).toContain("#92400E");
|
||||
expect(JSON.stringify(label)).toContain("#166534");
|
||||
expect(JSON.stringify(label)).toContain("#9C3036");
|
||||
expect(JSON.stringify(label)).toContain("#8C470D");
|
||||
expect(JSON.stringify(label)).toContain("#1F6F4D");
|
||||
expect(JSON.stringify(label)).toContain("#573E9C");
|
||||
});
|
||||
|
||||
it("validates one to one hundred unique IDs and fixed levels", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
||||
export const SCADA_ANALYSIS_LAYER_IDS = {
|
||||
@@ -21,17 +22,17 @@ export type ScadaAnalysisRenderResult = {
|
||||
};
|
||||
|
||||
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
||||
high: "#F43F5E",
|
||||
medium: "#F59E0B",
|
||||
low: "#10B981",
|
||||
unrated: "#8B5CF6"
|
||||
high: MAP_STYLE_TOKENS.state.incident,
|
||||
medium: MAP_STYLE_TOKENS.state.risk,
|
||||
low: MAP_STYLE_TOKENS.state.success,
|
||||
unrated: MAP_STYLE_TOKENS.state.agent
|
||||
};
|
||||
|
||||
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
|
||||
high: "#991B1B",
|
||||
medium: "#92400E",
|
||||
low: "#166534",
|
||||
unrated: "#5B21B6"
|
||||
high: MAP_STYLE_TOKENS.stateLabel.danger,
|
||||
medium: MAP_STYLE_TOKENS.stateLabel.warning,
|
||||
low: MAP_STYLE_TOKENS.stateLabel.success,
|
||||
unrated: MAP_STYLE_TOKENS.stateLabel.agent
|
||||
};
|
||||
|
||||
const levelColorExpression = (
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
||||
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection
|
||||
} from "./scada-analysis";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
bbox3857: [
|
||||
13508801.930066336,
|
||||
3608163.3499832638,
|
||||
13555650.638648884,
|
||||
3633685.137885742
|
||||
]
|
||||
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
|
||||
} as const;
|
||||
|
||||
export const SOURCE_LAYERS = {
|
||||
@@ -102,21 +101,27 @@ export const SUPPLY_LAYER_CATALOG = [
|
||||
}
|
||||
] as const satisfies readonly SupplyLayerCatalogItem[];
|
||||
|
||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.available)
|
||||
.map((layer) => layer.id) as Extract<WaterNetworkSourceId, "pipes" | "junctions" | "valves" | "reservoirs" | "scada">[];
|
||||
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
|
||||
(layer) => layer.available
|
||||
).map((layer) => layer.id) as Extract<
|
||||
WaterNetworkSourceId,
|
||||
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
|
||||
>[];
|
||||
|
||||
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
|
||||
|
||||
export function isAvailableWaterNetworkSourceId(value: string): value is AvailableWaterNetworkSourceId {
|
||||
export function isAvailableWaterNetworkSourceId(
|
||||
value: string
|
||||
): value is AvailableWaterNetworkSourceId {
|
||||
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function createWaterNetworkSources() {
|
||||
return Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG
|
||||
.filter((layer) => layer.available)
|
||||
.map((layer) => [layer.id, createGeoServerVectorSource(layer.sourceLayer)])
|
||||
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
|
||||
layer.id,
|
||||
createGeoServerVectorSource(layer.sourceLayer)
|
||||
])
|
||||
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
|
||||
}
|
||||
|
||||
@@ -135,7 +140,12 @@ function createGeoServerVectorSource(
|
||||
}
|
||||
|
||||
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
const sources: StyleSpecification["sources"] = {};
|
||||
const sources: StyleSpecification["sources"] = {
|
||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||
type: "geojson",
|
||||
data: createEmptyScadaAnalysisCollection()
|
||||
}
|
||||
};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
{
|
||||
id: "background",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createValueLabelCollection, formatFeatureValue, getNumericFeatureProperties } from "./value-label";
|
||||
import { getNumericFeatureProperties } from "./value-label";
|
||||
|
||||
describe("workbench value labels", () => {
|
||||
const feature = {
|
||||
@@ -11,14 +11,4 @@ describe("workbench value labels", () => {
|
||||
it("lists finite numeric properties only", () => {
|
||||
expect(getNumericFeatureProperties(feature.properties)).toEqual([["depth", 2], ["diameter", 600.125]]);
|
||||
});
|
||||
|
||||
it("formats precision and unit with hard limits", () => {
|
||||
expect(formatFeatureValue(12.3456, 2, "mm")).toBe("12.35 mm");
|
||||
expect(formatFeatureValue(12.3456, 8, "")).toBe("12.346");
|
||||
});
|
||||
|
||||
it("creates a single controlled label feature", () => {
|
||||
expect(createValueLabelCollection(feature, "diameter", 1, "mm").features[0]?.properties?.label).toBe("600.1 mm");
|
||||
expect(() => createValueLabelCollection(feature, "name", 1)).toThrow("INVALID_VALUE_PROPERTY");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,127 @@
|
||||
import type { Feature, FeatureCollection, Geometry } from "geojson";
|
||||
import type {
|
||||
ExpressionSpecification,
|
||||
Map as MapLibreMap,
|
||||
SymbolLayerSpecification
|
||||
} from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { SOURCE_LAYERS } from "./sources";
|
||||
|
||||
export const VALUE_LABEL_SOURCE_ID = "workbench-value-label";
|
||||
export const VALUE_LABEL_LAYER_IDS = ["workbench-value-label-point", "workbench-value-label-line"] as const;
|
||||
export const VALUE_LABEL_LAYER_IDS = {
|
||||
pipes: "workbench-value-label-pipes",
|
||||
junctions: "workbench-value-label-junctions",
|
||||
valves: "workbench-value-label-valves",
|
||||
reservoirs: "workbench-value-label-reservoirs"
|
||||
} as const;
|
||||
|
||||
export type ValueLabelSourceId = keyof typeof VALUE_LABEL_LAYER_IDS;
|
||||
|
||||
export type ValueLabelStyle = {
|
||||
sourceId: ValueLabelSourceId;
|
||||
property: string;
|
||||
precision: number;
|
||||
unit: string;
|
||||
showValue: boolean;
|
||||
showId: boolean;
|
||||
textSize: number;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
const LABEL_SOURCE_IDS = Object.keys(VALUE_LABEL_LAYER_IDS) as ValueLabelSourceId[];
|
||||
|
||||
export function createValueLabelLayers(): SymbolLayerSpecification[] {
|
||||
return LABEL_SOURCE_IDS.map((sourceId) => {
|
||||
const line = sourceId === "pipes";
|
||||
return {
|
||||
id: VALUE_LABEL_LAYER_IDS[sourceId],
|
||||
type: "symbol",
|
||||
source: sourceId,
|
||||
"source-layer": SOURCE_LAYERS[sourceId],
|
||||
minzoom: sourceId === "junctions" ? 15 : 10,
|
||||
layout: {
|
||||
visibility: "none",
|
||||
"symbol-placement": line ? "line-center" : "point",
|
||||
"text-field": "",
|
||||
"text-size": 12,
|
||||
"text-offset": line ? [0, 0] : [0, -1.35],
|
||||
"text-anchor": line ? "center" : "bottom",
|
||||
"text-padding": 5,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false,
|
||||
"text-keep-upright": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": MAP_STYLE_TOKENS.label.primary,
|
||||
"text-halo-color": MAP_STYLE_TOKENS.label.inverse,
|
||||
"text-halo-width": 2,
|
||||
"text-halo-blur": 0.4,
|
||||
"text-opacity": 0.96
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function applyValueLabelStyle(
|
||||
map: MapLibreMap,
|
||||
style: ValueLabelStyle
|
||||
) {
|
||||
if (!validateValueLabelStyle(style)) {
|
||||
throw new Error("INVALID_VALUE_LABEL_STYLE");
|
||||
}
|
||||
const layerId = VALUE_LABEL_LAYER_IDS[style.sourceId];
|
||||
if (!map.getLayer(layerId)) {
|
||||
throw new Error("VALUE_LABEL_LAYER_UNAVAILABLE");
|
||||
}
|
||||
map.setFilter(
|
||||
layerId,
|
||||
style.showValue ? ["has", style.property] : null
|
||||
);
|
||||
map.setLayoutProperty(
|
||||
layerId,
|
||||
"text-field",
|
||||
createValueLabelTextExpression(style)
|
||||
);
|
||||
map.setLayoutProperty(layerId, "text-size", style.textSize);
|
||||
map.setPaintProperty(layerId, "text-color", style.textColor);
|
||||
map.setLayoutProperty(layerId, "visibility", "visible");
|
||||
}
|
||||
|
||||
export function clearValueLabels(
|
||||
map: MapLibreMap,
|
||||
targetSourceId?: ValueLabelSourceId
|
||||
) {
|
||||
const sourceIds = targetSourceId ? [targetSourceId] : LABEL_SOURCE_IDS;
|
||||
sourceIds.forEach((sourceId) => {
|
||||
const layerId = VALUE_LABEL_LAYER_IDS[sourceId];
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", "none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createValueLabelTextExpression(
|
||||
style: ValueLabelStyle
|
||||
): ExpressionSpecification {
|
||||
const idExpression: ExpressionSpecification = [
|
||||
"to-string",
|
||||
["coalesce", ["get", "id"], ["id"]]
|
||||
];
|
||||
const valueExpression: ExpressionSpecification = [
|
||||
"concat",
|
||||
[
|
||||
"number-format",
|
||||
["to-number", ["get", style.property], 0],
|
||||
{
|
||||
"min-fraction-digits": style.precision,
|
||||
"max-fraction-digits": style.precision
|
||||
}
|
||||
],
|
||||
style.unit ? ` ${style.unit}` : ""
|
||||
];
|
||||
if (style.showId && style.showValue) {
|
||||
return ["concat", idExpression, " · ", valueExpression];
|
||||
}
|
||||
return style.showId ? idExpression : valueExpression;
|
||||
}
|
||||
|
||||
export function getNumericFeatureProperties(properties: Record<string, unknown> | null | undefined) {
|
||||
return Object.entries(properties ?? {})
|
||||
@@ -9,33 +129,16 @@ export function getNumericFeatureProperties(properties: Record<string, unknown>
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function formatFeatureValue(value: number, precision: number, unit = "") {
|
||||
const safePrecision = Math.min(3, Math.max(0, Math.trunc(precision)));
|
||||
const safeUnit = unit.trim().slice(0, 16);
|
||||
return `${value.toFixed(safePrecision)}${safeUnit ? ` ${safeUnit}` : ""}`;
|
||||
}
|
||||
|
||||
export function createValueLabelCollection(
|
||||
feature: Feature,
|
||||
property: string,
|
||||
precision: number,
|
||||
unit = ""
|
||||
): FeatureCollection {
|
||||
const value = feature.properties?.[property];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("INVALID_VALUE_PROPERTY");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
geometry: feature.geometry as Geometry,
|
||||
properties: { label: formatFeatureValue(value, precision, unit) }
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyValueLabelCollection(): FeatureCollection {
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
function validateValueLabelStyle(style: ValueLabelStyle) {
|
||||
return style.sourceId in VALUE_LABEL_LAYER_IDS
|
||||
&& /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(style.property)
|
||||
&& Number.isInteger(style.precision)
|
||||
&& style.precision >= 0
|
||||
&& style.precision <= 3
|
||||
&& style.unit.length <= 16
|
||||
&& (style.showValue || style.showId)
|
||||
&& Number.isFinite(style.textSize)
|
||||
&& style.textSize >= 10
|
||||
&& style.textSize <= 18
|
||||
&& /^#[\dA-F]{6}$/i.test(style.textColor);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FLOW_LINE_LAYER_ID,
|
||||
createFlowOverlayState,
|
||||
showFlowOverlay
|
||||
} from "./flow-overlay";
|
||||
import { WorkbenchMapController } from "./workbench-map-controller";
|
||||
|
||||
describe("WorkbenchMapController", () => {
|
||||
@@ -10,7 +15,8 @@ describe("WorkbenchMapController", () => {
|
||||
await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true });
|
||||
controller.clearHighlight();
|
||||
const clearHighlight = controller.clearHighlight;
|
||||
clearHighlight();
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
||||
});
|
||||
@@ -23,6 +29,201 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 }));
|
||||
});
|
||||
|
||||
it("zooms to a feature without changing its highlight state", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
await controller.zoomToFeature({ sourceId: "junctions", featureId: "J-1" });
|
||||
|
||||
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
|
||||
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().target).toBeNull();
|
||||
});
|
||||
|
||||
it("queries GeoServer WFS directly when no feature adapter is supplied", async () => {
|
||||
const map = createMap();
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify(pointCollection), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap,
|
||||
isReady: () => true,
|
||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
|
||||
});
|
||||
|
||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
|
||||
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows");
|
||||
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
|
||||
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
|
||||
expect(map.setFeatureState).toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders flow from the complete pipes vector source", async () => {
|
||||
const map = createMap();
|
||||
const layers = new Map<string, unknown>();
|
||||
map.getLayer.mockImplementation((id: string) => layers.get(id));
|
||||
map.addLayer.mockImplementation((layer: { id: string }) => {
|
||||
layers.set(layer.id, layer);
|
||||
return map as never;
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: () => ({ matches: true }),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn()
|
||||
});
|
||||
|
||||
await showFlowOverlay(map as unknown as MapLibreMap, createFlowOverlayState(), true);
|
||||
|
||||
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
|
||||
source: "pipes",
|
||||
"source-layer": "geo_pipes_mat"
|
||||
});
|
||||
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("updates signed pipe velocity and exposes direction counts", () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
controller.updatePipeFlowCalculation({ pipeId: "P-1", velocity: -1.8 });
|
||||
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "pipes",
|
||||
sourceLayer: "geo_pipes_mat",
|
||||
id: "P-1"
|
||||
}),
|
||||
{ flowDirection: -1 }
|
||||
);
|
||||
expect(controller.getSnapshot().flowSummary).toMatchObject({
|
||||
total: 1,
|
||||
forward: 0,
|
||||
reverse: 1,
|
||||
stopped: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces complete pipe calculation snapshots", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
await controller.replacePipeFlowCalculations([
|
||||
{ pipeId: "P-1", velocity: 2 },
|
||||
{ pipeId: "P-2", velocity: 0 },
|
||||
{ pipeId: "", velocity: 4 }
|
||||
]);
|
||||
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
pending: false,
|
||||
flowSummary: {
|
||||
total: 2,
|
||||
forward: 1,
|
||||
reverse: 0,
|
||||
stopped: 1,
|
||||
invalid: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MVT labels per group and supports group or global clearing", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockImplementation((id: string) => id.startsWith("workbench-value-label-") ? {} as never : undefined);
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
await controller.showValueLabel({
|
||||
sourceId: "pipes",
|
||||
property: "diameter",
|
||||
precision: 0,
|
||||
unit: "mm",
|
||||
showValue: true,
|
||||
showId: false,
|
||||
textSize: 12,
|
||||
textColor: "#0F172A"
|
||||
});
|
||||
map.setLayoutProperty.mockClear();
|
||||
|
||||
await controller.showValueLabel({
|
||||
sourceId: "junctions",
|
||||
property: "pressure",
|
||||
precision: 1,
|
||||
unit: "m",
|
||||
showValue: true,
|
||||
showId: true,
|
||||
textSize: 13,
|
||||
textColor: "#0F172A"
|
||||
});
|
||||
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(
|
||||
"workbench-value-label-junctions",
|
||||
"text-field",
|
||||
expect.arrayContaining(["concat"])
|
||||
);
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(
|
||||
"workbench-value-label-junctions",
|
||||
"visibility",
|
||||
"visible"
|
||||
);
|
||||
expect(map.setFilter).toHaveBeenCalledWith(
|
||||
"workbench-value-label-junctions",
|
||||
["has", "pressure"]
|
||||
);
|
||||
expect(controller.getSnapshot().valueLabels.junctions).toMatchObject({
|
||||
sourceId: "junctions",
|
||||
property: "pressure",
|
||||
showId: true
|
||||
});
|
||||
expect(controller.getSnapshot().valueLabels.pipes).toMatchObject({
|
||||
sourceId: "pipes",
|
||||
property: "diameter"
|
||||
});
|
||||
expect(map.setLayoutProperty).not.toHaveBeenCalledWith(
|
||||
"workbench-value-label-pipes",
|
||||
"visibility",
|
||||
"none"
|
||||
);
|
||||
|
||||
controller.clearValueLabel("junctions");
|
||||
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(
|
||||
"workbench-value-label-junctions",
|
||||
"visibility",
|
||||
"none"
|
||||
);
|
||||
expect(controller.getSnapshot().valueLabels).toEqual({
|
||||
pipes: expect.objectContaining({ property: "diameter" })
|
||||
});
|
||||
|
||||
const clearValueLabel = controller.clearValueLabel;
|
||||
clearValueLabel();
|
||||
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(
|
||||
"workbench-value-label-pipes",
|
||||
"visibility",
|
||||
"none"
|
||||
);
|
||||
expect(controller.getSnapshot().valueLabels).toEqual({});
|
||||
});
|
||||
|
||||
it("applies icon scaling to symbol-backed point groups", () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
const controller = createController(map, pointCollection);
|
||||
|
||||
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.8 });
|
||||
|
||||
expect(map.setLayoutProperty).toHaveBeenCalledWith(
|
||||
"valves-symbol",
|
||||
"icon-size",
|
||||
expect.arrayContaining(["*", expect.anything(), 1.8])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear the last valid highlight when a target is missing", async () => {
|
||||
const map = createMap();
|
||||
let collection = pointCollection;
|
||||
@@ -40,11 +241,36 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports SCADA analysis as unavailable for the water-network data source", async () => {
|
||||
it("renders SCADA analysis through the registered GeoJSON source", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND");
|
||||
expect(controller.getSnapshot().errorCode).toBe("SCADA_FEATURES_NOT_FOUND");
|
||||
const source = { setData: vi.fn() };
|
||||
map.getSource.mockReturnValue(source);
|
||||
const controller = createController(map, {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
||||
properties: { sensor_id: "MP01" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
|
||||
).resolves.toMatchObject({
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
});
|
||||
expect(source.setData).toHaveBeenCalled();
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
errorCode: null,
|
||||
pending: false,
|
||||
scadaAnalysis: {
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,9 +278,15 @@ const pointCollection: FeatureCollection = { type: "FeatureCollection", features
|
||||
const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "P-1" } }] };
|
||||
|
||||
function createMap() {
|
||||
const images = new Set<string>();
|
||||
return {
|
||||
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
||||
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn()
|
||||
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
|
||||
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
||||
hasImage: vi.fn((id: string) => images.has(id)),
|
||||
addImage: vi.fn((id: string) => {
|
||||
images.add(id);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
|
||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
import {
|
||||
@@ -9,11 +9,29 @@ import {
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "./layer-group-style";
|
||||
import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay";
|
||||
import {
|
||||
VALUE_LABEL_SOURCE_ID,
|
||||
createEmptyValueLabelCollection,
|
||||
createValueLabelCollection
|
||||
clearPipeFlowCalculations as clearFlowCalculations,
|
||||
createFlowOverlayState,
|
||||
hideFlowOverlay,
|
||||
replacePipeFlowCalculations as replaceFlowCalculations,
|
||||
showFlowOverlay,
|
||||
updatePipeFlowCalculation as updateFlowCalculation,
|
||||
type FlowOverlayState,
|
||||
type PipeFlowCalculation,
|
||||
type PipeFlowSummary
|
||||
} from "./flow-overlay";
|
||||
import {
|
||||
MAX_PIPE_FEATURES,
|
||||
MAX_MAP_FEATURE_IDS,
|
||||
createMapFeatureWfsUrl,
|
||||
normalizeMapFeatureCollection,
|
||||
parseMapFeatureQuery
|
||||
} from "./map-feature-query";
|
||||
import {
|
||||
applyValueLabelStyle,
|
||||
clearValueLabels,
|
||||
type ValueLabelSourceId,
|
||||
type ValueLabelStyle
|
||||
} from "./value-label";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
@@ -32,25 +50,32 @@ export type WorkbenchMapErrorCode =
|
||||
| "SCADA_FEATURES_NOT_FOUND"
|
||||
| "ACTION_SUPERSEDED"
|
||||
| "FLOW_UNAVAILABLE"
|
||||
| "INVALID_FLOW_CALCULATION"
|
||||
| "INVALID_STYLE_PATCH";
|
||||
|
||||
export type WorkbenchMapControllerState = {
|
||||
target: FeatureTarget | null;
|
||||
valueLabels: Partial<Record<ValueLabelSourceId, ValueLabelStyle>>;
|
||||
pending: boolean;
|
||||
errorCode: WorkbenchMapErrorCode | null;
|
||||
flowVisible: boolean;
|
||||
flowSummary: PipeFlowSummary;
|
||||
styledGroups: LayerGroupId[];
|
||||
scadaAnalysis: ScadaAnalysisRenderResult | null;
|
||||
};
|
||||
|
||||
export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string };
|
||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||
export type WorkbenchMapCommands = {
|
||||
zoomToFeature: (target: FeatureTarget) => Promise<void>;
|
||||
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
|
||||
highlight: (target: FeatureTarget) => Promise<void>;
|
||||
clearHighlight: () => void;
|
||||
setFlowVisible: (visible: boolean) => Promise<void>;
|
||||
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
|
||||
clearValueLabel: () => void;
|
||||
replacePipeFlowCalculations: (records: readonly PipeFlowCalculation[]) => Promise<PipeFlowSummary | null>;
|
||||
updatePipeFlowCalculation: (record: PipeFlowCalculation) => PipeFlowSummary | null;
|
||||
clearPipeFlowCalculations: () => Promise<void>;
|
||||
showValueLabel: (request: ValueLabelRequest) => Promise<void>;
|
||||
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
||||
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
||||
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
||||
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
|
||||
@@ -67,11 +92,21 @@ export type WorkbenchMapControllerOptions = {
|
||||
};
|
||||
|
||||
export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null };
|
||||
private state: WorkbenchMapControllerState = {
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private highlightedTarget: FeatureTarget | null = null;
|
||||
private cachedFeatures = new Map<string, Feature>();
|
||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||
private flowUpdateRevision = 0;
|
||||
private scadaAnalysisRevision = 0;
|
||||
|
||||
constructor(private readonly options: WorkbenchMapControllerOptions) {}
|
||||
@@ -83,22 +118,48 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
getSnapshot = () => this.state;
|
||||
|
||||
async zoomToFeature(target: FeatureTarget) {
|
||||
const feature = await this.resolveTarget(target);
|
||||
const map = this.requireMap();
|
||||
if (!feature || !map) return;
|
||||
this.moveCameraToFeature(map, feature);
|
||||
}
|
||||
|
||||
async locateAndHighlight(target: FeatureTarget) {
|
||||
const feature = await this.resolveTarget(target);
|
||||
const map = this.requireMap();
|
||||
if (!feature || !map) return;
|
||||
if (!this.moveCameraToFeature(map, feature)) return;
|
||||
this.setHighlight(target);
|
||||
}
|
||||
|
||||
private moveCameraToFeature(map: MapLibreMap, feature: Feature) {
|
||||
const coordinates = collectCoordinates(feature.geometry);
|
||||
if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND");
|
||||
if (!coordinates.length) {
|
||||
this.setError("FEATURE_NOT_FOUND");
|
||||
return false;
|
||||
}
|
||||
const duration = this.options.reducedMotion?.() ? 0 : 600;
|
||||
if (feature.geometry.type === "Point") {
|
||||
map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 });
|
||||
map.easeTo({
|
||||
center: coordinates[0] as [number, number],
|
||||
zoom: 18,
|
||||
padding: this.options.getPadding(),
|
||||
duration
|
||||
});
|
||||
} else {
|
||||
const bounds = coordinates.reduce(
|
||||
(acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])],
|
||||
[Infinity, Infinity, -Infinity, -Infinity]
|
||||
);
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 });
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration
|
||||
});
|
||||
}
|
||||
this.setHighlight(target);
|
||||
this.patchState({ errorCode: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
async highlight(target: FeatureTarget) {
|
||||
@@ -106,14 +167,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
if (feature) this.setHighlight(target);
|
||||
}
|
||||
|
||||
clearHighlight() {
|
||||
clearHighlight = () => {
|
||||
const map = this.options.getMap();
|
||||
if (map && this.highlightedTarget) {
|
||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
}
|
||||
this.highlightedTarget = null;
|
||||
this.patchState({ target: null, errorCode: null });
|
||||
}
|
||||
};
|
||||
|
||||
async setFlowVisible(visible: boolean) {
|
||||
const map = this.requireMap();
|
||||
@@ -128,7 +189,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
await showFlowOverlay(
|
||||
map,
|
||||
this.flowState,
|
||||
() => this.fetchFeatures("pipes"),
|
||||
this.options.reducedMotion?.()
|
||||
);
|
||||
this.patchState({ pending: false, flowVisible: true });
|
||||
@@ -138,25 +198,96 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) {
|
||||
if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH");
|
||||
const feature = await this.resolveTarget(target);
|
||||
async replacePipeFlowCalculations(
|
||||
records: readonly PipeFlowCalculation[]
|
||||
): Promise<PipeFlowSummary | null> {
|
||||
const map = this.requireMap();
|
||||
if (!feature || !map) return;
|
||||
if (!map) return null;
|
||||
const revision = ++this.flowUpdateRevision;
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
try {
|
||||
(map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
createValueLabelCollection(feature, property, precision, unit)
|
||||
);
|
||||
this.patchState({ target, errorCode: null });
|
||||
const summary = await replaceFlowCalculations(map, this.flowState, records);
|
||||
if (revision === this.flowUpdateRevision) {
|
||||
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
|
||||
}
|
||||
return summary;
|
||||
} catch {
|
||||
if (revision === this.flowUpdateRevision) {
|
||||
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
updatePipeFlowCalculation(
|
||||
record: PipeFlowCalculation
|
||||
): PipeFlowSummary | null {
|
||||
const map = this.requireMap();
|
||||
if (!map) return null;
|
||||
this.flowUpdateRevision += 1;
|
||||
try {
|
||||
const summary = updateFlowCalculation(map, this.flowState, record);
|
||||
this.patchState({ pending: false, flowSummary: summary, errorCode: null });
|
||||
return summary;
|
||||
} catch {
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: "INVALID_FLOW_CALCULATION"
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async clearPipeFlowCalculations() {
|
||||
const map = this.requireMap();
|
||||
if (!map) return;
|
||||
const revision = ++this.flowUpdateRevision;
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
try {
|
||||
await clearFlowCalculations(map, this.flowState);
|
||||
if (revision === this.flowUpdateRevision) {
|
||||
this.patchState({
|
||||
pending: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
errorCode: null
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (revision === this.flowUpdateRevision) {
|
||||
this.patchState({ pending: false, errorCode: "FLOW_UNAVAILABLE" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async showValueLabel(request: ValueLabelRequest) {
|
||||
const map = this.requireMap();
|
||||
if (!map) return;
|
||||
const style: ValueLabelStyle = { ...request, unit: request.unit ?? "" };
|
||||
try {
|
||||
applyValueLabelStyle(map, style);
|
||||
this.patchState({
|
||||
valueLabels: {
|
||||
...this.state.valueLabels,
|
||||
[style.sourceId]: style
|
||||
},
|
||||
errorCode: null
|
||||
});
|
||||
} catch {
|
||||
this.setError("INVALID_STYLE_PATCH");
|
||||
}
|
||||
}
|
||||
|
||||
clearValueLabel() {
|
||||
clearValueLabel = (sourceId?: ValueLabelSourceId) => {
|
||||
const map = this.options.getMap();
|
||||
(map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection());
|
||||
}
|
||||
if (map) clearValueLabels(map, sourceId);
|
||||
if (!sourceId) {
|
||||
this.patchState({ valueLabels: {} });
|
||||
return;
|
||||
}
|
||||
const valueLabels = { ...this.state.valueLabels };
|
||||
delete valueLabels[sourceId];
|
||||
this.patchState({ valueLabels });
|
||||
};
|
||||
|
||||
applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) {
|
||||
const map = this.requireMap();
|
||||
@@ -173,12 +304,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
||||
}
|
||||
|
||||
async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise<ScadaAnalysisRenderResult> {
|
||||
void items;
|
||||
void signal;
|
||||
this.setError("SCADA_FEATURES_NOT_FOUND");
|
||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
||||
/*
|
||||
async renderScadaAnalysis(
|
||||
items: ScadaAnalysisItem[],
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaAnalysisRenderResult> {
|
||||
const revision = ++this.scadaAnalysisRevision;
|
||||
const map = this.options.getMap();
|
||||
if (!map || !this.options.isReady()) {
|
||||
@@ -189,21 +318,36 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
let sourceCollection: FeatureCollection;
|
||||
try {
|
||||
sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal);
|
||||
sourceCollection = await this.fetchFeatures(
|
||||
"scada",
|
||||
items.map((item) => item.sensor_id),
|
||||
signal
|
||||
);
|
||||
} catch {
|
||||
if (revision === this.scadaAnalysisRevision) {
|
||||
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
|
||||
}
|
||||
throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED");
|
||||
throw new Error(
|
||||
revision === this.scadaAnalysisRevision
|
||||
? "WFS_UNAVAILABLE"
|
||||
: "ACTION_SUPERSEDED"
|
||||
);
|
||||
}
|
||||
if (revision !== this.scadaAnalysisRevision) {
|
||||
throw new Error("ACTION_SUPERSEDED");
|
||||
}
|
||||
if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED");
|
||||
|
||||
const next = createScadaAnalysisCollection(sourceCollection, items);
|
||||
if (next.collection.features.length === 0) {
|
||||
this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" });
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: "SCADA_FEATURES_NOT_FOUND"
|
||||
});
|
||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
||||
}
|
||||
const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined;
|
||||
const source = map.getSource(
|
||||
SCADA_ANALYSIS_SOURCE_ID
|
||||
) as GeoJSONSource | undefined;
|
||||
if (!source) {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
@@ -216,9 +360,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true };
|
||||
this.patchState({ pending: false, errorCode: null, scadaAnalysis: result });
|
||||
const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates);
|
||||
|
||||
const result: ScadaAnalysisRenderResult = {
|
||||
...next.result,
|
||||
fitted: true
|
||||
};
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
scadaAnalysis: result
|
||||
});
|
||||
const coordinates = next.collection.features.map(
|
||||
(feature) => feature.geometry.coordinates
|
||||
);
|
||||
if (coordinates.length === 1) {
|
||||
map.easeTo({
|
||||
center: [coordinates[0][0], coordinates[0][1]],
|
||||
@@ -236,14 +390,19 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
],
|
||||
[Infinity, Infinity, -Infinity, -Infinity]
|
||||
);
|
||||
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
});
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
],
|
||||
{
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
}
|
||||
);
|
||||
}
|
||||
return result;
|
||||
*/
|
||||
}
|
||||
|
||||
clearScadaAnalysis() {
|
||||
@@ -256,19 +415,31 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
resetAll = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
const map = this.options.getMap();
|
||||
if (map) {
|
||||
hideFlowOverlay(map, this.flowState);
|
||||
this.flowUpdateRevision += 1;
|
||||
void clearFlowCalculations(map, this.flowState);
|
||||
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
|
||||
}
|
||||
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
|
||||
}
|
||||
this.patchState({
|
||||
target: null,
|
||||
valueLabels: {},
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
});
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.flowUpdateRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.listeners.clear();
|
||||
@@ -299,15 +470,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
|
||||
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
|
||||
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
|
||||
const response = await fetch("/api/map-features", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId, featureIds }),
|
||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||
if (!parsed.ok) throw new Error(parsed.code);
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||
headers: { Accept: "application/geo+json, application/json" },
|
||||
signal
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
|
||||
return body as FeatureCollection;
|
||||
if (!response.ok) throw new Error("WFS_UNAVAILABLE");
|
||||
const maxFeatures = featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES;
|
||||
const collection = normalizeMapFeatureCollection(await response.json(), maxFeatures);
|
||||
if (!collection) throw new Error("WFS_UNAVAILABLE");
|
||||
return collection;
|
||||
}
|
||||
|
||||
private setHighlight(target: FeatureTarget) {
|
||||
@@ -316,7 +489,6 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||
this.highlightedTarget = target;
|
||||
this.clearValueLabel();
|
||||
this.patchState({ target, errorCode: null });
|
||||
}
|
||||
|
||||
@@ -339,6 +511,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyPipeFlowSummary(): PipeFlowSummary {
|
||||
return { total: 0, forward: 0, reverse: 0, stopped: 0, invalid: 0 };
|
||||
}
|
||||
|
||||
function toFeatureStateTarget(target: FeatureTarget) {
|
||||
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user