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
+135 -32
View File
@@ -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);
}