/** Map, search, rankings, dashboard, stats, sources, sitemap. */ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { publicGet } from "../../lib/route.js"; import { DENSITY_VIEWS, MAP_LAYERS } from "@dci/core"; import { TTL, badRequest, csv, notFound } from "../../lib/http.js"; import { boolParam, csvParam, intParam, numParam, pageParam, strParam } from "../../lib/params.js"; import { METHODOLOGY_MW } from "../../lib/sql.js"; import { sourcesForEntities } from "../../lib/source-history.js"; import { mapData, MAP_METHODOLOGY, MAX_POINTS, type Bbox } from "../../repositories/map.js"; import { facilityIdsOf, search } from "../../repositories/search.js"; const empty = (v: unknown) => (v === "" || v === null ? undefined : v); import { getRanking, listRankings } from "../../repositories/rankings.js"; import { dashboard } from "../../repositories/dashboard.js"; import { availableMetrics, getSource, listSources, sitemap, SITEMAP_KINDS, timeseries, type SitemapKind } from "../../repositories/misc.js"; function parseBbox(s: string | undefined): Bbox | undefined { if (!s) return undefined; const parts = s.split(",").map(Number); if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) throw badRequest("bbox must be w,s,e,n"); const [w, so, e, n] = parts as [number, number, number, number]; if (Math.abs(w) > 180 || Math.abs(e) > 180 || Math.abs(so) > 90 || Math.abs(n) > 90 || so > n) throw badRequest("bbox out of range"); return { w, s: so, e, n }; } export async function discoveryRoutes(app: FastifyInstance): Promise { const mapQuery = z.object({ zoom: numParam, bbox: strParam.describe("w,s,e,n"), status: csvParam, type: csvParam, operator: strParam, metro: strParam, country: csvParam, q: strParam, min_mw: numParam, max_mw: numParam, ai: boolParam, hyperscale: boolParam, has_mw: boolParam, confidence: csvParam, opened_from: intParam, opened_to: intParam, cloud_regions: boolParam.describe("legacy: add the cloud-region overlay"), layer: z.preprocess(empty, z.enum(MAP_LAYERS).optional()).describe(`thematic layer: ${MAP_LAYERS.join(" | ")} (default facilities)`), density: z.preprocess(empty, z.enum(DENSITY_VIEWS).optional()).describe(`density grid weighted by ${DENSITY_VIEWS.join(" | ")}`), year: intParam.describe("time machine: facilities with an opening date ≤ year"), project_status: csvParam, expected_from: intParam, expected_to: intParam, location_precision: csvParam, ai_evidence: csvParam.describe("confirmed | likely | associated | unknown"), }); publicGet(app, { url: "/map", ttl: TTL.map, query: mapQuery, summary: "Map data (MapResponse): country clusters (zoom < 5), grid clusters (5–8), points (≥ 9, capped 5 000 → clusters + degraded); layers (capacity, projects, ai, cloud, ixps, connectivity, power, pipeline), density grids and a year filter", tags: ["map"], response: { type: "MapResponse", example: { zoom: 4, mode: "clusters", layer: "facilities", clusters: [{ key: "US", lat: 39.8, lng: -98.6, count: 2600, mw: 12000, statuses: { operational: 2400 }, label: "United States" }], total: 2600, year: null, yearCoverage: null } } }, async (q) => { const data = await mapData({ zoom: q.zoom ?? 2, bbox: parseBbox(q.bbox), status: csv(q.status), type: csv(q.type), operator: q.operator, metro: q.metro, country: csv(q.country).map((c) => c.toUpperCase()), q: q.q, min_mw: q.min_mw, max_mw: q.max_mw, ai: q.ai, hyperscale: q.hyperscale, has_mw: q.has_mw, confidence: csv(q.confidence), opened_from: q.opened_from, opened_to: q.opened_to, cloud_regions: q.cloud_regions, layer: q.layer, density: q.density, year: q.year, project_status: csv(q.project_status), expected_from: q.expected_from, expected_to: q.expected_to, location_precision: csv(q.location_precision), ai_evidence: csv(q.ai_evidence) }); const facilityIds = data.mode === "points" ? (data.points ?? []).filter((p) => !p.k).map((p) => p.id) : []; const sources = facilityIds.length ? await sourcesForEntities("facility", facilityIds) : []; return { data, meta: { maxPoints: MAX_POINTS, layer: data.layer, methodology: MAP_METHODOLOGY }, sources }; }); publicGet(app, { url: "/search", ttl: TTL.search, query: z.object({ q: strParam, type: strParam }), summary: "Universal search (SearchResponse): interprets the query (country, metro, operator, status, min / max MW, type, AI, projects, opening year) and returns ranked hits + matching facilities or projects", tags: ["search"], response: { type: "SearchResponse", example: { query: "projects over 500 MW in texas", interpreted: { entity: "project", minMw: 500, text: "texas" }, hits: [] } } }, async (q) => { const data = await search(q.q ?? ""); const sources = await sourcesForEntities("facility", facilityIdsOf(data)); return { data, meta: { total: data.hits.length, interpreted: data.interpreted }, sources }; }); publicGet(app, { url: "/rankings", ttl: TTL.rankings, summary: "Current rankings (key, label, scope, unit, methodology, computedAt, total)", tags: ["rankings"] }, async () => { const items = await listRankings(); return { data: items, meta: { total: items.length } }; }); publicGet(app, { url: "/rankings/:key", ttl: TTL.rankings, query: z.object({ limit: intParam, mode: z.enum(["strict", "all"]).optional() }), summary: "One ranking with rows (Ranking). mode=all returns the ungated 'all observed' companion of a coverage-gated ranking (coverage shown per row, nothing estimated)", tags: ["rankings"], params: { key: "ranking key" } }, async (q, params) => { const limit = q.limit && q.limit > 0 ? Math.min(q.limit, 1000) : undefined; const key = params.key!.replace(/:all$/, ""); const r = (q.mode === "all" ? await getRanking(`${key}:all`, limit) : null) ?? (await getRanking(key, limit)); if (!r) throw notFound("ranking"); return { data: { ...r, key }, meta: { total: r.total, methodology: r.methodology, mode: q.mode === "all" && r.minCoverage == null ? "all" : "strict", hasAllObserved: (await getRanking(`${key}:all`)) != null } }; }); publicGet(app, { url: "/dashboard", ttl: TTL.dashboard, summary: "Home dashboard (Dashboard)", tags: ["dashboard"] }, async () => { const data = await dashboard(); return { data, meta: { methodology: METHODOLOGY_MW } }; }); publicGet(app, { url: "/stats/timeseries", ttl: TTL.list, query: z.object({ metric: strParam, dim: strParam, days: intParam }), summary: "Daily metric series from daily_metrics (?metric=&dim=global&days=365); without metric lists available metrics", tags: ["stats"] }, async (q) => { if (!q.metric) { const metrics = await availableMetrics(); return { data: metrics, meta: { total: metrics.length } }; } const days = Math.max(1, Math.min(3650, q.days ?? 365)); const points = await timeseries(q.metric, q.dim ?? "global", days); return { data: points, meta: { metric: q.metric, dim: q.dim ?? "global", days, total: points.length } }; }); publicGet(app, { url: "/sources", ttl: TTL.list, summary: "Sources with document / provenance counts (SourceRef + counts)", tags: ["sources"] }, async () => { const items = await listSources(); return { data: items, meta: { total: items.length } }; }); publicGet(app, { url: "/sources/:id", ttl: TTL.detail, summary: "Source detail with breakdowns by page type and entity type", tags: ["sources"], params: { id: "src_… id" } }, async (_q, params) => { const s = await getSource(params.id!); if (!s) throw notFound("source"); return { data: s }; }); publicGet(app, { url: "/sitemap/:kind", ttl: TTL.sitemap, query: z.object({ page: pageParam }), summary: "Sitemap feed: slug + updatedAt, 5 000 per page (kinds: facilities, operators, countries, metros, projects, cloud-regions)", tags: ["sitemap"], params: { kind: SITEMAP_KINDS.join(" | ") } }, async (q, params) => { const kind = params.kind as SitemapKind; if (!SITEMAP_KINDS.includes(kind)) throw notFound("sitemap kind"); const res = await sitemap(kind, q.page); return { data: res.items, meta: { total: res.total, page: res.page, perPage: res.perPage, pages: Math.max(1, Math.ceil(res.total / res.perPage)) } }; }); }