Files
next-tjwater-frontend/src/features/workbench/map/value-label.ts
T
jiang f4318a9b9c 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.
2026-07-28 16:39:36 +08:00

145 lines
4.2 KiB
TypeScript

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_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 ?? {})
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))
.sort(([a], [b]) => a.localeCompare(b));
}
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);
}