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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user