export type TrustedMapAction = | { type: "locate_features"; featureIds: string[]; layer?: string; fallbackText?: string; } | { type: "zoom_to_map"; center: [number, number]; zoom?: number; durationMs?: number; fallbackText?: string; } | { type: "apply_layer_style"; layerGroupId?: string; layerId?: string; visible?: boolean; fallbackText?: string; } | { type: "render_junctions"; renderRef: string; fallbackText?: string; }; export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null { if (!isRecord(params)) { return null; } if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) { const featureIds = readLocateIds(params); if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null; return { type: "locate_features", featureIds, layer: stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ?? LEGACY_LOCATE_ACTION_LAYERS[action], fallbackText }; } if (action === "zoom_to_map") { const center = parseCenter(params); if (!center) { return null; } const zoom = numberValue(params.zoom); const durationMs = numberValue(params.duration_ms ?? params.durationMs); if (center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 || (zoom !== undefined && (zoom < 0 || zoom > 24))) return null; return { type: "zoom_to_map", center, zoom, ...(durationMs === undefined ? {} : { durationMs }), fallbackText }; } if (action === "apply_layer_style") { const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId); const layerId = stringValue(params.layer_id ?? params.layerId); const visible = booleanValue(params.visible); if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null; return { type: "apply_layer_style", layerGroupId, layerId, visible, fallbackText }; } if (action === "render_junctions") { const renderRef = stringValue(params.render_ref ?? params.renderRef); if (!renderRef || !RESULT_REF_PATTERN.test(renderRef)) { return null; } return { type: "render_junctions", renderRef, fallbackText }; } return null; } function parseCenter(params: Record): [number, number] | null { const sourceCrs = params.source_crs ?? params.sourceCrs; if (Array.isArray(params.center) && params.center.length >= 2) { const lng = numberValue(params.center[0]); const lat = numberValue(params.center[1]); return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs); } const rawCoordinate = params.coordinate ?? params.coordinates; if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) { const lng = numberValue(rawCoordinate[0]); const lat = numberValue(rawCoordinate[1]); return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs); } const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x); const lat = numberValue(params.lat ?? params.latitude ?? params.y); return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs); } function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, number] { if (sourceCrs === "EPSG:3857") { return webMercatorToLngLat(x, y); } return [x, y]; } function webMercatorToLngLat(x: number, y: number): [number, number] { const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI); const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI); return [lng, lat]; } function stringValue(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function numberValue(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function booleanValue(value: unknown) { return typeof value === "boolean" ? value : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } const LEGACY_LOCATE_ACTION_LAYERS: Record = { locate_junctions: "geo_junctions_mat", locate_pipes: "geo_pipes_mat", locate_valves: "geo_valves", locate_reservoirs: "geo_reservoirs", locate_pumps: "geo_pumps", locate_tanks: "geo_tanks" }; const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]); const WEB_MERCATOR_RADIUS = 6378137; const LOCATE_ID_PARAM_KEYS = [ "feature_ids", "feature_id", "featureIds", "featureId", "ids", "id", "node_ids", "node_id", "junction_ids", "junction_id", "pipe_ids", "pipe_id", "valve_ids", "valve_id", "reservoir_ids", "reservoir_id", "pump_ids", "pump_id", "tank_ids", "tank_id" ] as const; function readLocateIds(params: Record) { for (const key of LOCATE_ID_PARAM_KEYS) { const value = params[key]; const ids = normalizeIds(value); if (ids.length > 0) { return ids; } } return []; } function normalizeIds(value: unknown) { if (Array.isArray(value)) { return value.map((item) => String(item).trim()).filter(Boolean); } if (typeof value === "string" || typeof value === "number") { return String(value) .split(",") .map((item) => item.trim()) .filter(Boolean); } return []; }