spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { FacilityStatus, FacilitySummary, ProjectSummary } from "@dci/core";2import type { Metadata } from "next";3import Link from "next/link";4import { Breadcrumbs } from "@/components/entity/breadcrumbs";5import { SourcesFooter } from "@/components/entity/sources-footer";6import { FacilityTable } from "@/components/facility-table";7import { ExploreCharts } from "@/components/intelligence/explore/explore-charts";8import { ExploreMap } from "@/components/intelligence/explore/explore-map";9import { QueryBuilder } from "@/components/intelligence/explore/query-builder";10import { ResultBar } from "@/components/intelligence/explore/result-bar";11import { ApiNote } from "@/components/intelligence/shared";12import { ProjectList } from "@/components/project-list";13import { Page, PageHeader } from "@/components/ui/card";14import { FilterChips, type Chip } from "@/components/ui/filter-chips";15import { Pagination } from "@/components/ui/pagination";16import { SegmentedControl } from "@/components/ui/segmented";17import { EmptyState, OfflineNotice } from "@/components/ui/states";18import { api } from "@/lib/api";19import { EXPLORE_PER_PAGE, exploreHref, parseExploreQuery, toggleCsv } from "@/lib/explore-query";20import { countryName, fmtInt } from "@/lib/format";21import { CONFIDENCE_SHORT, PRECISION_SHORT, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";22import { routes } from "@/lib/routes";23import { pageMetadata } from "@/lib/seo";2425export const revalidate = 120;2627export const metadata: Metadata = pageMetadata({28 title: "Explore",29 description: "Structured query builder over every indexed facility and project: status, country, market, operator, type, published MW, AI evidence, dates, confidence and location precision — as a table, a map or charts. Every view is a shareable URL.",30 path: routes.explore(),31});3233type SP = Record<string, string | string[] | undefined>;3435export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {36 const sp = await searchParams;37 const q = parseExploreQuery(sp);38 const entity = q.entity ?? "facilities";39 const view = q.view ?? "table";40 const r = await api.explore(q);41 const data = r.ok ? r.data : null;42 const page = q.page ?? 1;43 const perPage = q.per_page ?? EXPLORE_PER_PAGE;44 const total = data?.total ?? 0;45 const methodology = r.ok ? (r.meta?.methodology as string | undefined) : undefined;4647 const chips: Chip[] = [];48 const drop = (key: string, member?: string) => exploreHref(q, member ? { [key]: toggleCsv(q[key as keyof typeof q] as string | undefined, member) } : { [key]: undefined, ...(key === "min_mw" ? { max_mw: undefined } : {}), ...(key === "opened_from" ? { opened_to: undefined } : {}), ...(key === "expected_after" ? { expected_before: undefined } : {}) });49 if (q.q) chips.push({ key: "q", label: "Search", value: q.q, removeHref: drop("q") });50 for (const s of q.status?.split(",") ?? []) chips.push({ key: `st:${s}`, label: "Status", value: STATUS_LABEL[s as FacilityStatus] ?? s, removeHref: drop("status", s) });51 for (const c of q.country?.split(",") ?? []) chips.push({ key: `c:${c}`, label: "Country", value: countryName(c), removeHref: drop("country", c) });52 if (q.metro) chips.push({ key: "metro", label: "Market", value: q.metro, removeHref: drop("metro") });53 if (q.operator) chips.push({ key: "op", label: "Operator", value: q.operator, removeHref: drop("operator") });54 for (const t of q.type?.split(",") ?? []) chips.push({ key: `t:${t}`, label: "Type", value: TYPE_LABEL[t as keyof typeof TYPE_LABEL] ?? t, removeHref: drop("type", t) });55 if (q.min_mw !== undefined || q.max_mw !== undefined) chips.push({ key: "mw", label: "MW", value: q.min_mw !== undefined && q.max_mw !== undefined ? `${q.min_mw}–${q.max_mw}` : q.min_mw !== undefined ? `≥ ${q.min_mw}` : `≤ ${q.max_mw}`, removeHref: drop("min_mw") });56 if (q.ai) chips.push({ key: "ai", label: "AI evidence", value: q.ai === "any" ? "any" : `≥ ${q.ai}`, removeHref: drop("ai") });57 if (q.hyperscale) chips.push({ key: "hs", label: "Hyperscale", value: "yes", removeHref: drop("hyperscale") });58 if (q.has_mw) chips.push({ key: "hasmw", label: "Known MW", value: "yes", removeHref: drop("has_mw") });59 if (q.opened_from !== undefined || q.opened_to !== undefined) chips.push({ key: "opened", label: "Opened", value: `${q.opened_from ?? "…"}–${q.opened_to ?? "…"}`, removeHref: drop("opened_from") });60 if (q.expected_after !== undefined || q.expected_before !== undefined) chips.push({ key: "expected", label: "Expected", value: `${q.expected_after ?? "…"}–${q.expected_before ?? "…"}`, removeHref: drop("expected_after") });61 if (q.announced_since) chips.push({ key: "since", label: "Announced since", value: q.announced_since, removeHref: drop("announced_since") });62 for (const c of q.confidence?.split(",") ?? []) chips.push({ key: `cf:${c}`, label: "Confidence", value: CONFIDENCE_SHORT[c as keyof typeof CONFIDENCE_SHORT] ?? c, removeHref: drop("confidence", c) });63 for (const p of q.location_precision?.split(",") ?? []) chips.push({ key: `lp:${p}`, label: "Precision", value: PRECISION_SHORT[p as keyof typeof PRECISION_SHORT] ?? p, removeHref: drop("location_precision", p) });64 for (const c of q.project_class?.split(",") ?? []) chips.push({ key: `pc:${c}`, label: "Class", value: c.replace(/_/g, " ").toLowerCase(), removeHref: drop("project_class", c) });6566 const viewHref = (v: "table" | "map" | "charts") => exploreHref(q, { view: v, page: undefined });67 const countryHrefBase = entity === "projects" ? routes.projects("country=") : routes.datacenters("country=");68 const items = data?.items ?? [];69 const facilities = entity === "facilities" ? (items as FacilitySummary[]) : [];70 const projects = entity === "projects" ? (items as ProjectSummary[]) : [];7172 return (73 <Page wide>74 <Breadcrumbs items={[{ name: "Explore", href: routes.explore() }]} className="pt-3" />75 <PageHeader eyebrow="Explore" title="Query the index" description="Build a structured query over facilities or projects. Facets, charts and the map cover the whole filtered set; the URL is the saved view." className="pt-3" />76 {!r.ok && <OfflineNotice detail={r.error} className="mb-3" />}7778 <div className="grid gap-6 lg:grid-cols-[300px_minmax(0,1fr)] lg:gap-8">79 <aside className="lg:sticky lg:top-16 lg:self-start" aria-label="Query builder">80 <QueryBuilder query={q} facets={data?.facets ?? null} />81 </aside>8283 <div className="min-w-0">84 <div className="sticky top-12 z-[20] -mx-3 flex flex-col gap-2 border-b border-hair bg-paper/95 px-3 py-2 backdrop-blur sm:top-14 sm:mx-0 sm:px-0">85 <ResultBar total={r.ok ? total : null} entity={entity} mwCoverage={data ? data.mwCoverage : null} />86 <div className="flex flex-wrap items-center gap-2">87 <SegmentedControl88 label="View"89 value={view}90 options={[91 { value: "table", label: "Table", href: viewHref("table") },92 { value: "map", label: `Map`, href: viewHref("map"), title: data ? `${fmtInt(data.map.total)} points with coordinates` : undefined },93 { value: "charts", label: "Charts", href: viewHref("charts") },94 ]}95 />96 <FilterChips chips={chips} resetHref={exploreHref({ entity, view: q.view })} className="min-w-0 flex-1" />97 </div>98 </div>99100 <div className="pt-4">101 {!data ? (102 <EmptyState title="Results unavailable" description="The index API did not respond. The page recovers automatically once it is reachable." />103 ) : total === 0 ? (104 <EmptyState title={`No ${entity === "projects" ? "project" : "infrastructure"} record matches`} description="Loosen a filter or clear them all — a zero here means nothing in the index matches, not that nothing exists." action={<Link href={exploreHref({ entity })} className="text-[12.5px] text-ink underline decoration-hair-2 hover:text-accent">Clear filters</Link>} />105 ) : view === "map" ? (106 <ExploreMap query={q} total={data.map.total} degraded={data.map.degraded} />107 ) : view === "charts" ? (108 <ExploreCharts charts={data.charts} entity={entity} countryHrefBase={countryHrefBase} />109 ) : (110 <>111 {entity === "facilities" ? <FacilityTable items={facilities} columns={["operator", "market", "country", "status", "mw", "type", "confidence"]} emptyTitle="No facility on this page" /> : <ProjectList items={projects} emptyTitle="No project on this page" />}112 <Pagination page={page} perPage={perPage} total={total} hrefFor={(p) => exploreHref(q, { page: p })} label={entity === "projects" ? "projects" : "facilities"} className="mt-3" />113 </>114 )}115 </div>116117 {data && (118 <div className="mt-6 grid gap-3 sm:grid-cols-2">119 <FacetBlock title="Status" items={data.facets.status.map((f) => ({ key: f.key, label: STATUS_LABEL[f.key as FacilityStatus] ?? f.key, count: f.count, href: exploreHref(q, { status: toggleCsv(q.status, f.key), page: undefined }), active: (q.status ?? "").split(",").includes(f.key) }))} />120 <FacetBlock title="AI evidence" items={data.facets.ai.map((f) => ({ key: f.key, label: f.key, count: f.count, href: f.key === "unknown" ? undefined : exploreHref(q, { ai: f.key as never, page: undefined }), active: q.ai === f.key }))} note="Counts by the strongest evidence level on each record; “unknown” = no signal." />121 {data.facets.type && data.facets.type.length > 0 && <FacetBlock title="Type" items={data.facets.type.map((f) => ({ key: f.key, label: TYPE_LABEL[f.key as keyof typeof TYPE_LABEL] ?? f.key, count: f.count, href: exploreHref(q, { type: toggleCsv(q.type, f.key), page: undefined }), active: (q.type ?? "").split(",").includes(f.key) }))} />}122 <FacetBlock title="Operators" items={data.facets.operator.slice(0, 12).map((f) => ({ key: f.key, label: f.name, count: f.count, href: exploreHref(q, { operator: q.operator === f.key ? undefined : f.key, page: undefined }), active: q.operator === f.key }))} />123 </div>124 )}125126 <ApiNote note={methodology} label="Method" className="mt-6" />127 </div>128 </div>129130 <SourcesFooter sources={r.ok ? r.sources : undefined} note="Result counts are exact over indexed records; MW figures are published values only (containment-aware), never extrapolated. mwCoverage is the share of matching records with any MW figure." />131 </Page>132 );133}134135function FacetBlock({ title, items, note }: { title: string; items: Array<{ key: string; label: string; count: number; href?: string; active: boolean }>; note?: string }) {136 if (!items.length) return null;137 return (138 <div className="rounded-sm border border-hair bg-surface px-3 py-2.5">139 <h2 className="label-caps mb-1.5">{title}</h2>140 <ul className="flex flex-wrap gap-1.5">141 {items.map((it) => {142 const cls = `inline-flex h-10 items-center gap-1.5 rounded-sm border px-2 text-[12px] sm:h-7 ${it.active ? "border-ink bg-ink text-paper" : "border-hair-2 bg-surface text-ink-2 hover:bg-surface-2"}`;143 return (144 <li key={it.key}>145 {it.href ? (146 <Link href={it.href} scroll={false} className={cls} aria-pressed={it.active}>147 {it.label} <span className="figure text-[11px] opacity-70">{fmtInt(it.count)}</span>148 </Link>149 ) : (150 <span className={cls}>151 {it.label} <span className="figure text-[11px] opacity-70">{fmtInt(it.count)}</span>152 </span>153 )}154 </li>155 );156 })}157 </ul>158 {note && <p className="mt-1.5 text-[11px] text-ink-4">{note}</p>}159 </div>160 );161}162