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.
438 lines
11 KiB
TypeScript
438 lines
11 KiB
TypeScript
import type {
|
|
ExpressionSpecification,
|
|
Map as MapLibreMap,
|
|
StyleImageInterface
|
|
} from "maplibre-gl";
|
|
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
|
import { SOURCE_LAYERS } from "./sources";
|
|
|
|
export const FLOW_LINE_LAYER_ID = "workbench-network-flow-forward";
|
|
export const FLOW_REVERSE_LINE_LAYER_ID = "workbench-network-flow-reverse";
|
|
|
|
const FLOW_FORWARD_IMAGE_ID = "workbench-network-flow-pattern-forward";
|
|
const FLOW_REVERSE_IMAGE_ID = "workbench-network-flow-pattern-reverse";
|
|
const FLOW_STATE_DIRECTION = "flowDirection";
|
|
const FLOW_PATTERN_WIDTH = 64;
|
|
const FLOW_PATTERN_HEIGHT = 8;
|
|
const FLOW_PATTERN_DURATION_MS = 1_200;
|
|
|
|
export type PipeFlowCalculation = {
|
|
pipeId: string | number;
|
|
velocity: number;
|
|
};
|
|
|
|
export type PipeFlowDirection = -1 | 0 | 1;
|
|
|
|
export type PipeFlowSummary = {
|
|
total: number;
|
|
forward: number;
|
|
reverse: number;
|
|
stopped: number;
|
|
invalid: number;
|
|
};
|
|
|
|
type NormalizedPipeFlowCalculation = {
|
|
pipeId: string | number;
|
|
direction: PipeFlowDirection;
|
|
};
|
|
|
|
export type FlowOverlayState = {
|
|
calculations: Map<string, NormalizedPipeFlowCalculation>;
|
|
revision: number;
|
|
visible: boolean;
|
|
};
|
|
|
|
export function createFlowOverlayState(): FlowOverlayState {
|
|
return {
|
|
calculations: new Map(),
|
|
revision: 0,
|
|
visible: false
|
|
};
|
|
}
|
|
|
|
export async function showFlowOverlay(
|
|
map: MapLibreMap,
|
|
_state: FlowOverlayState,
|
|
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
) {
|
|
ensureFlowImages(map, reducedMotion);
|
|
ensureFlowLayers(map);
|
|
setFlowMockVisibility(map, _state.calculations.size === 0);
|
|
_state.visible = true;
|
|
setFlowLayerVisibility(map, "visible");
|
|
}
|
|
|
|
export function hideFlowOverlay(map: MapLibreMap, _state: FlowOverlayState) {
|
|
_state.visible = false;
|
|
setFlowLayerVisibility(map, "none");
|
|
}
|
|
|
|
export async function replacePipeFlowCalculations(
|
|
map: MapLibreMap,
|
|
state: FlowOverlayState,
|
|
records: readonly PipeFlowCalculation[]
|
|
): Promise<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;
|
|
};
|
|
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
};
|
|
applyBatch();
|
|
});
|
|
}
|
|
|
|
function removePipeFlowState(map: MapLibreMap, id: string | number) {
|
|
const target = {
|
|
source: "pipes",
|
|
sourceLayer: SOURCE_LAYERS.pipes,
|
|
id
|
|
};
|
|
map.removeFeatureState(target, FLOW_STATE_DIRECTION);
|
|
}
|
|
|
|
function ensureFlowImages(map: MapLibreMap, reducedMotion: boolean) {
|
|
if (!map.hasImage(FLOW_FORWARD_IMAGE_ID)) {
|
|
map.addImage(
|
|
FLOW_FORWARD_IMAGE_ID,
|
|
createFlowPatternImage(1, reducedMotion),
|
|
{ pixelRatio: 1 }
|
|
);
|
|
}
|
|
if (!map.hasImage(FLOW_REVERSE_IMAGE_ID)) {
|
|
map.addImage(
|
|
FLOW_REVERSE_IMAGE_ID,
|
|
createFlowPatternImage(-1, reducedMotion),
|
|
{ pixelRatio: 1 }
|
|
);
|
|
}
|
|
}
|
|
|
|
function createFlowPatternImage(
|
|
direction: -1 | 1,
|
|
reducedMotion: boolean
|
|
): StyleImageInterface {
|
|
let map: MapLibreMap | null = null;
|
|
let lastOffset = Number.NaN;
|
|
const data = new Uint8Array(FLOW_PATTERN_WIDTH * FLOW_PATTERN_HEIGHT * 4);
|
|
|
|
const draw = (offset: number) => {
|
|
for (let y = 0; y < FLOW_PATTERN_HEIGHT; y += 1) {
|
|
const edgeFade = Math.sin((Math.PI * (y + 0.5)) / FLOW_PATTERN_HEIGHT);
|
|
for (let x = 0; x < FLOW_PATTERN_WIDTH; x += 1) {
|
|
const wrapped = (
|
|
x - offset + FLOW_PATTERN_WIDTH
|
|
) % FLOW_PATTERN_WIDTH;
|
|
const pulse = wrapped < 18
|
|
? Math.sin((Math.PI * wrapped) / 18)
|
|
: 0;
|
|
const alpha = Math.round(255 * pulse * edgeFade);
|
|
const index = (y * FLOW_PATTERN_WIDTH + x) * 4;
|
|
data[index] = 94;
|
|
data[index + 1] = 234;
|
|
data[index + 2] = 212;
|
|
data[index + 3] = alpha;
|
|
}
|
|
}
|
|
};
|
|
|
|
draw(direction > 0 ? 0 : FLOW_PATTERN_WIDTH - 1);
|
|
|
|
return {
|
|
width: FLOW_PATTERN_WIDTH,
|
|
height: FLOW_PATTERN_HEIGHT,
|
|
data,
|
|
onAdd(nextMap) {
|
|
map = nextMap;
|
|
},
|
|
render() {
|
|
if (reducedMotion) return false;
|
|
const progress = (performance.now() % FLOW_PATTERN_DURATION_MS)
|
|
/ FLOW_PATTERN_DURATION_MS;
|
|
const offset = Math.floor(
|
|
progress * FLOW_PATTERN_WIDTH * direction
|
|
+ FLOW_PATTERN_WIDTH
|
|
) % FLOW_PATTERN_WIDTH;
|
|
if (offset === lastOffset) {
|
|
map?.triggerRepaint();
|
|
return false;
|
|
}
|
|
lastOffset = offset;
|
|
draw(offset);
|
|
map?.triggerRepaint();
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
const mockDirectionExpression: ExpressionSpecification = [
|
|
"case",
|
|
[
|
|
"==",
|
|
["%", ["to-number", ["id"], 0], 2],
|
|
0
|
|
],
|
|
1,
|
|
-1
|
|
];
|
|
|
|
const directionExpression = (
|
|
direction: -1 | 1,
|
|
visibleOpacity: number,
|
|
mockVisible = false
|
|
): ExpressionSpecification => [
|
|
"case",
|
|
[
|
|
"==",
|
|
[
|
|
"coalesce",
|
|
["feature-state", FLOW_STATE_DIRECTION],
|
|
mockVisible ? mockDirectionExpression : 0
|
|
],
|
|
direction
|
|
],
|
|
visibleOpacity,
|
|
0
|
|
];
|
|
|
|
function ensureFlowLayers(map: MapLibreMap) {
|
|
if (!map.getLayer(FLOW_LINE_LAYER_ID)) {
|
|
map.addLayer({
|
|
id: FLOW_LINE_LAYER_ID,
|
|
type: "line",
|
|
source: "pipes",
|
|
"source-layer": SOURCE_LAYERS.pipes,
|
|
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
|
layout: flowLineLayout(),
|
|
paint: {
|
|
"line-pattern": FLOW_FORWARD_IMAGE_ID,
|
|
"line-width": 4,
|
|
"line-opacity": directionExpression(1, 0.92)
|
|
}
|
|
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
|
}
|
|
|
|
if (!map.getLayer(FLOW_REVERSE_LINE_LAYER_ID)) {
|
|
map.addLayer({
|
|
id: FLOW_REVERSE_LINE_LAYER_ID,
|
|
type: "line",
|
|
source: "pipes",
|
|
"source-layer": SOURCE_LAYERS.pipes,
|
|
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
|
|
layout: flowLineLayout(),
|
|
paint: {
|
|
"line-pattern": FLOW_REVERSE_IMAGE_ID,
|
|
"line-width": 4,
|
|
"line-opacity": directionExpression(-1, 0.92)
|
|
}
|
|
}, WORKBENCH_INTERACTION_BEFORE_ID);
|
|
}
|
|
|
|
}
|
|
|
|
function flowLineLayout() {
|
|
return {
|
|
visibility: "visible" as const,
|
|
"line-cap": "round" as const,
|
|
"line-join": "round" as const
|
|
};
|
|
}
|
|
|
|
function setFlowLayerVisibility(
|
|
map: MapLibreMap,
|
|
visibility: "visible" | "none"
|
|
) {
|
|
[
|
|
FLOW_LINE_LAYER_ID,
|
|
FLOW_REVERSE_LINE_LAYER_ID
|
|
].forEach((layerId) => {
|
|
if (map.getLayer(layerId)) {
|
|
map.setLayoutProperty(layerId, "visibility", visibility);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setFlowMockVisibility(
|
|
map: MapLibreMap,
|
|
visible: boolean
|
|
) {
|
|
const opacityByLayer = [
|
|
[FLOW_LINE_LAYER_ID, "line-opacity", 1, 0.92],
|
|
[FLOW_REVERSE_LINE_LAYER_ID, "line-opacity", -1, 0.92]
|
|
] as const;
|
|
opacityByLayer.forEach(([layerId, property, direction, opacity]) => {
|
|
if (map.getLayer(layerId)) {
|
|
map.setPaintProperty(
|
|
layerId,
|
|
property,
|
|
directionExpression(direction, opacity, visible)
|
|
);
|
|
}
|
|
});
|
|
}
|