import type { Map as MapLibreMap } from "maplibre-gl"; import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals"; import { MAP_STYLE_TOKENS } from "./map-colors"; 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; }; export type ScadaLayerGroupStylePatch = PointLayerGroupStylePatch & { iconMultiplier?: number; }; export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch | ScadaLayerGroupStylePatch; export type LayerGroupGeometry = "line" | "point" | "scada"; export const LAYER_GROUP_GEOMETRIES = { conduits: "line", junctions: "point", orifices: "line", outfalls: "point", pumps: "line", scada: "scada" } as const satisfies Record; export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES; const ORIGINAL_DASH: Partial> = { conduits: undefined, orifices: [4, 2], pumps: [6, 2, 1.5, 2] }; const LINE_PREFIX: Record<"conduits" | "orifices" | "pumps", string> = { conduits: "pipes", orifices: "orifices", pumps: "pumps" }; 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"] : geometry === "scada" ? ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"] : ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"]; 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, 2)) 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 "conduits" | "orifices" | "pumps", patch as LineLayerGroupStylePatch); else if (geometry === "scada") applyScadaStyle(map, patch as ScadaLayerGroupStylePatch); else applyPointStyle(map, groupId as "junctions" | "outfalls", patch as PointLayerGroupStylePatch); } export function resetLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId) { const geometry = LAYER_GROUP_GEOMETRIES[groupId]; if (geometry === "line") resetLineStyle(map, groupId as "conduits" | "orifices" | "pumps"); else if (geometry === "scada") resetScadaStyle(map); else resetPointStyle(map, groupId as "junctions" | "outfalls"); } function applyLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps", patch: LineLayerGroupStylePatch) { const prefix = LINE_PREFIX[groupId]; const coreId = groupId === "conduits" ? "pipes-flow" : groupId; if (patch.color !== undefined) setPaint(map, coreId, "line-color", stateColor(patch.color)); if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", patch.opacity); if (patch.dash !== undefined) { const dash = patch.dash === "solid" ? [1, 0] : patch.dash === "dashed" ? [3, 2] : ORIGINAL_DASH[groupId]; setPaint(map, coreId, "line-dasharray", dash); } if (patch.widthMultiplier !== undefined) { const visuals = DRAINAGE_LAYER_VISUALS[groupId]; setPaint(map, coreId, "line-width", scale(visuals.width, patch.widthMultiplier)); setPaint(map, `${prefix}-casing`, "line-width", scale(visuals.casingWidth, patch.widthMultiplier)); setPaint(map, `${prefix}-hover-outline`, "line-width", scale(visuals.hoverOutlineWidth, patch.widthMultiplier)); setPaint(map, `${prefix}-hover`, "line-width", scale(visuals.hoverWidth, patch.widthMultiplier)); for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) { setPaint(map, id, "line-gap-width", scale(visuals.selectedWidth, patch.widthMultiplier)); } } } function resetLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps") { const prefix = LINE_PREFIX[groupId]; const coreId = groupId === "conduits" ? "pipes-flow" : groupId; const visuals = DRAINAGE_LAYER_VISUALS[groupId]; const colors = { conduits: MAP_STYLE_TOKENS.asset.conduit, orifices: MAP_STYLE_TOKENS.asset.orifice, pumps: MAP_STYLE_TOKENS.asset.pump } as const; setPaint(map, coreId, "line-color", stateColor(colors[groupId])); setPaint(map, coreId, "line-opacity", MAP_STYLE_TOKENS.opacity.core); setPaint(map, coreId, "line-dasharray", ORIGINAL_DASH[groupId]); setPaint(map, coreId, "line-width", visuals.width); setPaint(map, `${prefix}-casing`, "line-width", visuals.casingWidth); setPaint(map, `${prefix}-hover-outline`, "line-width", visuals.hoverOutlineWidth); setPaint(map, `${prefix}-hover`, "line-width", visuals.hoverWidth); for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) { setPaint(map, id, "line-gap-width", visuals.selectedWidth); } } function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls", patch: PointLayerGroupStylePatch) { const coreId = groupId; if (patch.fillColor !== undefined) setPaint(map, coreId, "circle-color", stateColor(patch.fillColor)); if (patch.strokeColor !== undefined) setPaint(map, coreId, "circle-stroke-color", stateColor(patch.strokeColor)); if (patch.opacity !== undefined) setPaint(map, coreId, "circle-opacity", patch.opacity); if (patch.radiusMultiplier !== undefined) { const visual = DRAINAGE_LAYER_VISUALS[groupId]; setPaint(map, coreId, "circle-radius", scale(visual.radius, patch.radiusMultiplier)); setPaint(map, `${groupId}-halo`, "circle-radius", scale(visual.haloRadius, patch.radiusMultiplier)); setPaint(map, `${groupId}-hover`, "circle-radius", scale(visual.hoverRadius, patch.radiusMultiplier)); setPaint(map, `${groupId}-selected`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier)); setPaint(map, `${groupId}-selected-outer`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier)); } if (patch.strokeMultiplier !== undefined) { setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier); } } function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls") { const visual = DRAINAGE_LAYER_VISUALS[groupId]; const colors = { junctions: MAP_STYLE_TOKENS.asset.junction, outfalls: MAP_STYLE_TOKENS.asset.outfall } as const; setPaint(map, groupId, "circle-color", groupId === "outfalls" ? MAP_STYLE_TOKENS.canvas.casing : stateColor(colors[groupId])); setPaint(map, groupId, "circle-stroke-color", groupId === "outfalls" ? stateColor(colors[groupId]) : undefined); setPaint(map, groupId, "circle-opacity", groupId === "outfalls" ? 1 : MAP_STYLE_TOKENS.opacity.core); 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); setPaint(map, groupId, "circle-stroke-width", groupId === "outfalls" ? 2 : undefined); } function applyScadaStyle(map: MapLibreMap, patch: ScadaLayerGroupStylePatch) { if (patch.iconMultiplier !== undefined) setLayout(map, "scada-symbol", "icon-size", 0.75 * patch.iconMultiplier); if (patch.opacity !== undefined) { setPaint(map, "scada-symbol", "icon-opacity", patch.opacity); setPaint(map, "scada-fallback", "circle-opacity", patch.opacity); } if (patch.fillColor !== undefined) setPaint(map, "scada-fallback", "circle-color", patch.fillColor); if (patch.strokeColor !== undefined) setPaint(map, "scada-fallback", "circle-stroke-color", patch.strokeColor); if (patch.radiusMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-radius", scale(["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], patch.radiusMultiplier)); if (patch.strokeMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-stroke-width", 2 * patch.strokeMultiplier); } function resetScadaStyle(map: MapLibreMap) { setLayout(map, "scada-symbol", "icon-size", 0.75); setPaint(map, "scada-symbol", "icon-opacity", 1); setPaint(map, "scada-fallback", "circle-color", "#0E7490"); setPaint(map, "scada-fallback", "circle-stroke-color", "#FFFFFF"); setPaint(map, "scada-fallback", "circle-opacity", 1); setPaint(map, "scada-fallback", "circle-radius", ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8]); setPaint(map, "scada-fallback", "circle-stroke-width", 2); } function scale(value: unknown, multiplier: number): unknown { return ["*", value, multiplier]; } function stateColor(color: string): unknown { return [ "case", ["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]], MAP_STYLE_TOKENS.state.inactive, color ]; } 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)) map.setLayoutProperty(layerId, property, value 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); }