import type { ExploreQuery } from "@dci/core"; import { CONFIDENCE_LEVELS, FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, PROJECT_CLASSES } from "@dci/core"; /** URL search params ↔ ExploreQuery (/explore). Unknown values are dropped so the API never sees an invalid key. */ export type ExploreParamsInput = Record | URLSearchParams; export const EXPLORE_PER_PAGE = 24; export const EXPLORE_SORTS = ["updated", "name", "mw", "opened", "completeness", "announced", "opening"] as const; export const EXPLORE_VIEWS = ["table", "map", "charts"] as const; export const AI_FILTERS = ["any", "confirmed", "likely", "associated"] as const; function get(sp: ExploreParamsInput, key: string): string | undefined { if (sp instanceof URLSearchParams) return sp.get(key) ?? undefined; const v = sp[key]; const s = Array.isArray(v) ? v[0] : v; return s === undefined || s === "" ? undefined : s; } const num = (v: string | undefined): number | undefined => { if (v === undefined) return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const int = (v: string | undefined): number | undefined => { const n = num(v); return n === undefined ? undefined : Math.trunc(n); }; const bool = (v: string | undefined): boolean | undefined => (v === "1" || v === "true" ? true : v === "0" || v === "false" ? false : undefined); const csv = (v: string | undefined, allowed: ReadonlySet): string | undefined => { if (!v) return undefined; const out = v .split(",") .map((s) => s.trim()) .filter((s) => s && allowed.has(s)); return out.length ? out.join(",") : undefined; }; const STATUS_SET = new Set(FACILITY_STATUSES); const TYPE_SET = new Set(FACILITY_TYPES); const CONF_SET = new Set(CONFIDENCE_LEVELS); const PREC_SET = new Set(GEO_PRECISIONS); const CLASS_SET = new Set(PROJECT_CLASSES); export function parseExploreQuery(sp: ExploreParamsInput): ExploreQuery { const q: ExploreQuery = {}; const entity = get(sp, "entity"); q.entity = entity === "projects" ? "projects" : "facilities"; const status = csv(get(sp, "status"), STATUS_SET); if (status) q.status = status; const country = get(sp, "country"); if (country) { const cc = country .toUpperCase() .split(",") .map((s) => s.trim()) .filter((s) => /^[A-Z]{2}$/.test(s)); if (cc.length) q.country = cc.join(","); } const metro = get(sp, "metro"); if (metro && /^[a-z0-9-]+$/i.test(metro)) q.metro = metro; const operator = get(sp, "operator"); if (operator && /^[a-z0-9-]+$/i.test(operator)) q.operator = operator; if (q.entity === "facilities") { const type = csv(get(sp, "type"), TYPE_SET); if (type) q.type = type; const of = int(get(sp, "opened_from")); if (of !== undefined) q.opened_from = of; const ot = int(get(sp, "opened_to")); if (ot !== undefined) q.opened_to = ot; const hs = bool(get(sp, "hyperscale")); if (hs !== undefined) q.hyperscale = hs; const lp = csv(get(sp, "location_precision"), PREC_SET); if (lp) q.location_precision = lp; } else { const eb = int(get(sp, "expected_before")); if (eb !== undefined) q.expected_before = eb; const ea = int(get(sp, "expected_after")); if (ea !== undefined) q.expected_after = ea; const as = get(sp, "announced_since"); if (as && /^\d{4}(-\d{2}(-\d{2})?)?$/.test(as)) q.announced_since = as; const pc = csv(get(sp, "project_class"), CLASS_SET); if (pc) q.project_class = pc; } const minMw = num(get(sp, "min_mw")); if (minMw !== undefined && minMw >= 0) q.min_mw = minMw; const maxMw = num(get(sp, "max_mw")); if (maxMw !== undefined && maxMw >= 0) q.max_mw = maxMw; const ai = get(sp, "ai"); if (ai && (AI_FILTERS as readonly string[]).includes(ai)) q.ai = ai as ExploreQuery["ai"]; const conf = csv(get(sp, "confidence"), CONF_SET); if (conf) q.confidence = conf; const hasMw = bool(get(sp, "has_mw")); if (hasMw !== undefined) q.has_mw = hasMw; const text = get(sp, "q"); if (text) q.q = text.slice(0, 120); const sort = get(sp, "sort"); if (sort && (EXPLORE_SORTS as readonly string[]).includes(sort)) q.sort = sort; const order = get(sp, "order"); if (order === "asc" || order === "desc") q.order = order; const page = int(get(sp, "page")); if (page && page > 1) q.page = page; const per = int(get(sp, "per_page")); if (per && per > 0 && per !== EXPLORE_PER_PAGE) q.per_page = Math.min(100, per); const view = get(sp, "view"); if (view && (EXPLORE_VIEWS as readonly string[]).includes(view)) q.view = view as ExploreQuery["view"]; return q; } /** Serialize (defaults skipped: entity=facilities, view=table, page=1, per_page=24). */ export function exploreToParams(q: ExploreQuery, patch?: Partial & Record): URLSearchParams { const merged: Record = { ...q, ...(patch ?? {}) }; const sp = new URLSearchParams(); for (const [k, v] of Object.entries(merged)) { if (v === undefined || v === null || v === "" || v === false) continue; if (k === "entity" && v === "facilities") continue; if (k === "view" && v === "table") continue; if (k === "page" && Number(v) <= 1) continue; if (k === "per_page" && Number(v) === EXPLORE_PER_PAGE) continue; sp.set(k, v === true ? "1" : String(v)); } return sp; } export function exploreHref(q: ExploreQuery, patch?: Partial & Record): string { const s = exploreToParams(q, patch).toString(); return `/explore${s ? `?${s}` : ""}`; } /** Keys that count as active filters (not paging / view / sort). */ export const EXPLORE_FILTER_KEYS: Array = ["status", "country", "metro", "operator", "type", "min_mw", "max_mw", "ai", "hyperscale", "expected_before", "expected_after", "opened_from", "opened_to", "announced_since", "confidence", "location_precision", "has_mw", "project_class", "q"]; export function countExploreFilters(q: ExploreQuery): number { return EXPLORE_FILTER_KEYS.filter((k) => q[k] !== undefined && q[k] !== "" && q[k] !== false).length; } /** Toggle a member in a csv key. */ export function toggleCsv(current: string | undefined, member: string): string | undefined { const set = new Set((current ?? "").split(",").filter(Boolean)); if (set.has(member)) set.delete(member); else set.add(member); return set.size ? Array.from(set).join(",") : undefined; }