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:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
+410 -72
View File
@@ -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)
);
}
});
}