Files
next-tjwater-frontend/src/features/workbench/map/layer-group-style.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

202 lines
8.4 KiB
TypeScript

import type { Map as MapLibreMap } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyActiveOpacity,
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
export type LineLayerGroupStylePatch = {
color?: string;
opacity?: number;
widthMultiplier?: number;
dash?: "original" | "solid" | "dashed";
};
export type PointLayerGroupStylePatch = {
fillColor?: string;
strokeColor?: string;
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
iconMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
export type LayerGroupGeometry = "line" | "point";
export const LAYER_GROUP_GEOMETRIES = {
pipes: "line",
junctions: "point",
valves: "point",
reservoirs: "point"
} as const satisfies Record<string, LayerGroupGeometry>;
export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES;
const LINE_CORE_LAYERS: Record<Extract<LayerGroupId, "pipes">, string> = {
pipes: "pipes-flow"
};
const LINE_DEFAULT_WIDTHS = {
"pipes-flow": SUPPLY_LAYER_VISUALS.pipes.width,
"pipes-casing": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"pipes-inactive": SUPPLY_LAYER_VISUALS.pipes.width,
"pipes-inactive-casing": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"pipes-hover-outline": SUPPLY_LAYER_VISUALS.pipes.hoverOutlineWidth,
"pipes-hover": SUPPLY_LAYER_VISUALS.pipes.hoverWidth
} as const;
const POINT_DEFAULT_COLORS = {
junctions: MAP_STYLE_TOKENS.supply.junction,
valves: MAP_STYLE_TOKENS.supply.valve,
reservoirs: MAP_STYLE_TOKENS.supply.reservoir
} as const;
const POINT_DEFAULT_OPACITIES = {
junctions: 0.9,
valves: supplyFacilityOpacityExpression,
reservoirs: 0
} as const;
const POINT_DEFAULT_VISUALS = {
junctions: SUPPLY_LAYER_VISUALS.junctions,
valves: SUPPLY_LAYER_VISUALS.valves,
reservoirs: SUPPLY_LAYER_VISUALS.reservoirs
};
export function validateLayerGroupStylePatch(groupId: string, patch: unknown): patch is LayerGroupStylePatch {
if (!(groupId in LAYER_GROUP_GEOMETRIES) || !patch || typeof patch !== "object" || Array.isArray(patch)) return false;
const geometry = LAYER_GROUP_GEOMETRIES[groupId as LayerGroupId];
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
if ("strokeColor" in values && !isColor(values.strokeColor)) return false;
if ("opacity" in values && !inRange(values.opacity, 0, 1)) return false;
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 3)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
export function applyLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") applyLineStyle(map, groupId as "pipes", patch as LineLayerGroupStylePatch);
else applyPointStyle(map, groupId as "junctions" | "valves" | "reservoirs", patch as PointLayerGroupStylePatch);
}
export function resetLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") resetLineStyle(map, groupId as "pipes");
else resetPointStyle(map, groupId as "junctions" | "valves" | "reservoirs");
}
function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGroupStylePatch) {
const prefix = groupId;
const coreId = LINE_CORE_LAYERS[groupId];
if (patch.color !== undefined) setPaint(map, coreId, "line-color", createSupplyStateColor(patch.color));
if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(patch.opacity));
if (patch.dash !== undefined) {
const dash = patch.dash === "solid"
? [1, 0]
: patch.dash === "dashed"
? [3, 2]
: supplyPipeDashExpression;
setPaint(map, coreId, "line-dasharray", dash);
setPaint(map, `${prefix}-casing`, "line-dasharray", dash);
}
if (patch.widthMultiplier !== undefined) {
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
setPaint(map, id, "line-width", scale(value, patch.widthMultiplier));
}
}
}
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
const coreId = LINE_CORE_LAYERS[groupId];
setPaint(map, coreId, "line-color", supplyPipeColorExpression);
setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(0.92));
setPaint(map, coreId, "line-dasharray", supplyPipeDashExpression);
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
setPaint(map, id, "line-width", value);
}
}
function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs", patch: PointLayerGroupStylePatch) {
const coreId = groupId;
if (patch.fillColor !== undefined) setPaint(map, coreId, "circle-color", createSupplyStateColor(patch.fillColor));
if (patch.strokeColor !== undefined) setPaint(map, coreId, "circle-stroke-color", createSupplyStateColor(patch.strokeColor));
if (patch.opacity !== undefined) setPaint(map, coreId, "circle-opacity", patch.opacity);
if (patch.radiusMultiplier !== undefined) {
const visual = POINT_DEFAULT_VISUALS[groupId];
const radii = {
[coreId]: visual.radius,
[`${groupId}-halo`]: visual.haloRadius,
[`${groupId}-hover`]: visual.hoverRadius,
[`${groupId}-selected`]: visual.selectedRadius,
[`${groupId}-selected-outer`]: visual.selectedRadius
};
for (const [id, value] of Object.entries(radii)) {
setPaint(map, id, "circle-radius", scale(value, patch.radiusMultiplier));
}
}
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
if (patch.iconMultiplier !== undefined && "iconSize" in POINT_DEFAULT_VISUALS[groupId]) {
setLayout(
map,
`${groupId}-symbol`,
"icon-size",
scale(POINT_DEFAULT_VISUALS[groupId].iconSize, patch.iconMultiplier)
);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
const visual = POINT_DEFAULT_VISUALS[groupId];
setPaint(map, groupId, "circle-color", createSupplyStateColor(POINT_DEFAULT_COLORS[groupId]));
setPaint(map, groupId, "circle-opacity", POINT_DEFAULT_OPACITIES[groupId]);
setPaint(map, groupId, "circle-stroke-width", 0);
setPaint(map, groupId, "circle-radius", visual.radius);
setPaint(map, `${groupId}-halo`, "circle-radius", visual.haloRadius);
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
if ("iconSize" in visual) {
setLayout(map, `${groupId}-symbol`, "icon-size", visual.iconSize);
}
}
function scale(value: unknown, multiplier: number): unknown {
return ["*", value, multiplier];
}
function setPaint(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId) && typeof map.setLayoutProperty === "function") {
map.setLayoutProperty(layerId, property, (value ?? null) as never);
}
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
function isColor(value: unknown): value is string {
return typeof value === "string" && /^#[\da-f]{6}$/i.test(value);
}