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 | 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); }