SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
6.3 KB · 144 lines typescript
Raw Blame History
1import type { ExploreQuery } from "@dci/core";2import { CONFIDENCE_LEVELS, FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, PROJECT_CLASSES } from "@dci/core";34/** URL search params ↔ ExploreQuery (/explore). Unknown values are dropped so the API never sees an invalid key. */56export type ExploreParamsInput = Record<string, string | string[] | undefined> | URLSearchParams;78export const EXPLORE_PER_PAGE = 24;9export const EXPLORE_SORTS = ["updated", "name", "mw", "opened", "completeness", "announced", "opening"] as const;10export const EXPLORE_VIEWS = ["table", "map", "charts"] as const;11export const AI_FILTERS = ["any", "confirmed", "likely", "associated"] as const;1213function get(sp: ExploreParamsInput, key: string): string | undefined {14  if (sp instanceof URLSearchParams) return sp.get(key) ?? undefined;15  const v = sp[key];16  const s = Array.isArray(v) ? v[0] : v;17  return s === undefined || s === "" ? undefined : s;18}1920const num = (v: string | undefined): number | undefined => {21  if (v === undefined) return undefined;22  const n = Number(v);23  return Number.isFinite(n) ? n : undefined;24};25const int = (v: string | undefined): number | undefined => {26  const n = num(v);27  return n === undefined ? undefined : Math.trunc(n);28};29const bool = (v: string | undefined): boolean | undefined => (v === "1" || v === "true" ? true : v === "0" || v === "false" ? false : undefined);30const csv = (v: string | undefined, allowed: ReadonlySet<string>): string | undefined => {31  if (!v) return undefined;32  const out = v33    .split(",")34    .map((s) => s.trim())35    .filter((s) => s && allowed.has(s));36  return out.length ? out.join(",") : undefined;37};3839const STATUS_SET = new Set<string>(FACILITY_STATUSES);40const TYPE_SET = new Set<string>(FACILITY_TYPES);41const CONF_SET = new Set<string>(CONFIDENCE_LEVELS);42const PREC_SET = new Set<string>(GEO_PRECISIONS);43const CLASS_SET = new Set<string>(PROJECT_CLASSES);4445export function parseExploreQuery(sp: ExploreParamsInput): ExploreQuery {46  const q: ExploreQuery = {};47  const entity = get(sp, "entity");48  q.entity = entity === "projects" ? "projects" : "facilities";49  const status = csv(get(sp, "status"), STATUS_SET);50  if (status) q.status = status;51  const country = get(sp, "country");52  if (country) {53    const cc = country54      .toUpperCase()55      .split(",")56      .map((s) => s.trim())57      .filter((s) => /^[A-Z]{2}$/.test(s));58    if (cc.length) q.country = cc.join(",");59  }60  const metro = get(sp, "metro");61  if (metro && /^[a-z0-9-]+$/i.test(metro)) q.metro = metro;62  const operator = get(sp, "operator");63  if (operator && /^[a-z0-9-]+$/i.test(operator)) q.operator = operator;64  if (q.entity === "facilities") {65    const type = csv(get(sp, "type"), TYPE_SET);66    if (type) q.type = type;67    const of = int(get(sp, "opened_from"));68    if (of !== undefined) q.opened_from = of;69    const ot = int(get(sp, "opened_to"));70    if (ot !== undefined) q.opened_to = ot;71    const hs = bool(get(sp, "hyperscale"));72    if (hs !== undefined) q.hyperscale = hs;73    const lp = csv(get(sp, "location_precision"), PREC_SET);74    if (lp) q.location_precision = lp;75  } else {76    const eb = int(get(sp, "expected_before"));77    if (eb !== undefined) q.expected_before = eb;78    const ea = int(get(sp, "expected_after"));79    if (ea !== undefined) q.expected_after = ea;80    const as = get(sp, "announced_since");81    if (as && /^\d{4}(-\d{2}(-\d{2})?)?$/.test(as)) q.announced_since = as;82    const pc = csv(get(sp, "project_class"), CLASS_SET);83    if (pc) q.project_class = pc;84  }85  const minMw = num(get(sp, "min_mw"));86  if (minMw !== undefined && minMw >= 0) q.min_mw = minMw;87  const maxMw = num(get(sp, "max_mw"));88  if (maxMw !== undefined && maxMw >= 0) q.max_mw = maxMw;89  const ai = get(sp, "ai");90  if (ai && (AI_FILTERS as readonly string[]).includes(ai)) q.ai = ai as ExploreQuery["ai"];91  const conf = csv(get(sp, "confidence"), CONF_SET);92  if (conf) q.confidence = conf;93  const hasMw = bool(get(sp, "has_mw"));94  if (hasMw !== undefined) q.has_mw = hasMw;95  const text = get(sp, "q");96  if (text) q.q = text.slice(0, 120);97  const sort = get(sp, "sort");98  if (sort && (EXPLORE_SORTS as readonly string[]).includes(sort)) q.sort = sort;99  const order = get(sp, "order");100  if (order === "asc" || order === "desc") q.order = order;101  const page = int(get(sp, "page"));102  if (page && page > 1) q.page = page;103  const per = int(get(sp, "per_page"));104  if (per && per > 0 && per !== EXPLORE_PER_PAGE) q.per_page = Math.min(100, per);105  const view = get(sp, "view");106  if (view && (EXPLORE_VIEWS as readonly string[]).includes(view)) q.view = view as ExploreQuery["view"];107  return q;108}109110/** Serialize (defaults skipped: entity=facilities, view=table, page=1, per_page=24). */111export function exploreToParams(q: ExploreQuery, patch?: Partial<ExploreQuery> & Record<string, unknown>): URLSearchParams {112  const merged: Record<string, unknown> = { ...q, ...(patch ?? {}) };113  const sp = new URLSearchParams();114  for (const [k, v] of Object.entries(merged)) {115    if (v === undefined || v === null || v === "" || v === false) continue;116    if (k === "entity" && v === "facilities") continue;117    if (k === "view" && v === "table") continue;118    if (k === "page" && Number(v) <= 1) continue;119    if (k === "per_page" && Number(v) === EXPLORE_PER_PAGE) continue;120    sp.set(k, v === true ? "1" : String(v));121  }122  return sp;123}124125export function exploreHref(q: ExploreQuery, patch?: Partial<ExploreQuery> & Record<string, unknown>): string {126  const s = exploreToParams(q, patch).toString();127  return `/explore${s ? `?${s}` : ""}`;128}129130/** Keys that count as active filters (not paging / view / sort). */131export const EXPLORE_FILTER_KEYS: Array<keyof ExploreQuery> = ["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"];132133export function countExploreFilters(q: ExploreQuery): number {134  return EXPLORE_FILTER_KEYS.filter((k) => q[k] !== undefined && q[k] !== "" && q[k] !== false).length;135}136137/** Toggle a member in a csv key. */138export function toggleCsv(current: string | undefined, member: string): string | undefined {139  const set = new Set((current ?? "").split(",").filter(Boolean));140  if (set.has(member)) set.delete(member);141  else set.add(member);142  return set.size ? Array.from(set).join(",") : undefined;143}144