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; export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES; const LINE_CORE_LAYERS: Record, 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; 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); }