spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { CoverageRow, FacilityStatus, GridConstraintDTO, PipelineBreakdown } from "@dci/core";2import { ExternalLink, Info } from "lucide-react";3import Link from "next/link";4import type { ReactNode } from "react";5import { Badge, ConfidenceBadge, EstimateMark, type BadgeTone } from "@/components/ui/badge";6import { cn } from "@/components/ui/cn";7import { CountryFlag } from "@/components/ui/country-flag";8import { fmtDate, fmtInt, fmtMw, fmtPct, splitMw } from "@/lib/format";9import { SOURCE_KIND_LABEL, STATUS_LABEL, statusVar } from "@/lib/labels";10import { routes } from "@/lib/routes";1112/* ------------------------------------------------------------------------------------------13 Intelligence primitives (server-safe). Shared by operator / market / country 2.0, the AI index,14 pulse, power, connectivity, coverage, compare and rankings. Everything here is honest by design:15 unknown = "—", estimates carry ≈, coverage is always shown next to a MW figure.16 ------------------------------------------------------------------------------------------ */1718/** Inline coverage bar: figure + hairline track. Below 50 % the bar is dimmed (thin data). */19export function CoverageBar({ value, className, width = "w-[120px]", label, showPct = true }: { value: number | null | undefined; className?: string; width?: string; label?: string; showPct?: boolean }) {20 if (value === null || value === undefined || !Number.isFinite(value)) return <span className={cn("text-ink-4", className)}>—</span>;21 const pct = Math.max(0, Math.min(1, value));22 return (23 <span className={cn("inline-flex w-full items-center gap-2", width, className)} title={label ? `${label}: ${fmtPct(pct)}` : fmtPct(pct)} aria-label={`${label ?? "coverage"} ${fmtPct(pct)}`}>24 {showPct && <span className="figure w-10 shrink-0 text-right text-[12px] text-ink-2">{fmtPct(pct)}</span>}25 <span className="h-[3px] min-w-0 flex-1 bg-surface-3" aria-hidden>26 <span className={cn("block h-full", pct < 0.5 ? "bg-ink-4" : "bg-ink-2")} style={{ width: `${Math.max(pct > 0 ? 2 : 0, pct * 100)}%` }} />27 </span>28 </span>29 );30}3132/** Sentence-style coverage caption: "12% of 137 facilities publish a MW figure". */33export function CoverageCaption({ coverage, facilities, what = "publish a MW figure", className }: { coverage: number | null | undefined; facilities?: number | null; what?: string; className?: string }) {34 if (coverage === null || coverage === undefined) return null;35 return (36 <p className={cn("text-[11.5px] text-ink-3", className)}>37 <span className="figure">{fmtPct(coverage)}</span>38 {facilities !== null && facilities !== undefined ? ` of ${fmtInt(facilities)} facilities ` : " of facilities "}39 {what} — totals are lower bounds from published figures only.40 </p>41 );42}4344/** MW value with unit and optional ≈ mark; "—" when unknown (never 0). */45export function MwFigure({ mw, estimate, className, unitClassName }: { mw: number | null | undefined; estimate?: boolean; className?: string; unitClassName?: string }) {46 const s = splitMw(mw);47 if (!s) return <span className={cn("text-ink-4", className)}>—</span>;48 return (49 <span className={cn("figure", className)}>50 {estimate && <EstimateMark className="mr-0.5 text-[0.7em]" />}51 {s.value}52 <span className={cn("ml-1 text-[0.6em] font-medium text-ink-3", unitClassName)}>{s.unit}</span>53 </span>54 );55}5657/** Note rendered verbatim from the API (`note` / `methodology` fields) — never paraphrased. */58export function ApiNote({ note, label = "Note", className, children }: { note?: string | null; label?: string; className?: string; children?: ReactNode }) {59 if (!note && !children) return null;60 return (61 <div className={cn("flex items-start gap-2 rounded-sm border border-dashed border-hair-2 px-3 py-2 text-[12px] leading-relaxed text-ink-2", className)} role="note">62 <Info className="mt-0.5 size-3.5 shrink-0 text-ink-3" strokeWidth={1.75} aria-hidden />63 <div className="min-w-0">64 <span className="label-caps mr-1.5">{label}</span>65 {note}66 {children}67 </div>68 </div>69 );70}7172/** "Published figures only" methodology line under MW aggregates. */73export function PublishedOnlyNote({ className, extra }: { className?: string; extra?: ReactNode }) {74 return (75 <p className={cn("text-[11.5px] text-ink-4", className)}>76 Published figures only — nothing is imputed to facilities without a figure; estimates carry ≈. {extra}77 <Link href={`${routes.methodology()}#capacity`} className="underline decoration-hair-2 hover:text-ink">78 How capacity is counted79 </Link>80 </p>81 );82}8384/** Honest empty state for a section whose data is not (yet) published or collected. */85export function HonestEmpty({ title, detail, className }: { title: string; detail?: ReactNode; className?: string }) {86 return (87 <div className={cn("rounded-sm border border-dashed border-hair-2 px-3 py-3 text-[12.5px] text-ink-3", className)}>88 <span className="font-medium text-ink-2">{title}</span>89 {detail && <span> — {detail}</span>}90 </div>91 );92}9394/* ---------------------------------------------------------------- pipeline (facility lifecycle buckets) */9596const PIPE_ROWS: Array<{ key: keyof Pick<PipelineBreakdown, "operational" | "construction" | "approved" | "announced">; label: string; status: FacilityStatus }> = [97 { key: "operational", label: "Operational", status: "operational" },98 { key: "construction", label: "Under construction", status: "under_construction" },99 { key: "approved", label: "Approved / permitting", status: "approved" },100 { key: "announced", label: "Announced / proposed", status: "announced" },101];102103/**104 * Pipeline bars: facility counts and known MW by lifecycle bucket (containment-aware, published figures only),105 * then project records by stage. Bars are proportional to counts; MW is printed, never drawn when missing.106 */107export function PipelineBars({ pipeline, className, showProjects = true }: { pipeline: PipelineBreakdown; className?: string; showProjects?: boolean }) {108 const rows = PIPE_ROWS.map((r) => ({ ...r, count: pipeline[r.key].count, mw: pipeline[r.key].mw }));109 const max = Math.max(1, ...rows.map((r) => r.count));110 const projects = [...pipeline.projects].sort((a, b) => b.count - a.count);111 const pMax = Math.max(1, ...projects.map((p) => p.count));112 const total = rows.reduce((a, r) => a + r.count, 0);113 return (114 <div className={className}>115 {total === 0 ? (116 <HonestEmpty title="No facility in any lifecycle bucket" detail="facilities appear as connectors observe them" />117 ) : (118 <ol className="divide-y divide-hair">119 {rows.map((r) => (120 <li key={r.key} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[180px_minmax(0,1fr)_90px_110px]">121 <div className="flex items-center gap-2 text-[13px] text-ink">122 <span aria-hidden className="inline-block size-2 rounded-full" style={{ background: statusVar(r.status) }} />123 {r.label}124 </div>125 <div className="order-last col-span-2 mt-1 h-[4px] w-full bg-surface-3 sm:order-none sm:col-span-1 sm:mt-0" aria-hidden>126 <div className="h-full" style={{ width: `${Math.max(r.count > 0 ? 1.5 : 0, (r.count / max) * 100)}%`, background: statusVar(r.status) }} />127 </div>128 <div className="figure text-right text-[12.5px] text-ink-2 sm:w-auto">129 {fmtInt(r.count)} <span className="text-ink-4">{r.count === 1 ? "site" : "sites"}</span>130 </div>131 <div className="figure hidden text-right text-[12.5px] sm:block">{r.mw === null ? <span className="text-ink-4" title="No published MW figure in this bucket">MW —</span> : fmtMw(r.mw)}</div>132 </li>133 ))}134 </ol>135 )}136 <div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">137 <CoverageCaption coverage={pipeline.mwCoverage} facilities={total} />138 </div>139 {showProjects && projects.length > 0 && (140 <div className="mt-4">141 <div className="label-caps mb-1.5">Project records by stage</div>142 <ol className="divide-y divide-hair">143 {projects.map((p) => (144 <li key={p.status} className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 py-1.5 text-[12.5px]">145 <div className="flex min-w-0 items-center gap-2">146 <span aria-hidden className="inline-block size-2 shrink-0 rounded-full" style={{ background: statusVar(p.status) }} />147 <span className="truncate text-ink-2">{STATUS_LABEL[p.status]}</span>148 <span className="hidden h-[3px] min-w-[40px] flex-1 bg-surface-3 sm:block" aria-hidden>149 <span className="block h-full bg-ink-3" style={{ width: `${(p.count / pMax) * 100}%` }} />150 </span>151 </div>152 <span className="figure text-ink-2">{fmtInt(p.count)}</span>153 <span className="figure w-[90px] text-right text-ink-2">{p.mw === null ? <span className="text-ink-4">MW —</span> : <>≈ {fmtMw(p.mw)}</>}</span>154 </li>155 ))}156 </ol>157 <p className="mt-1.5 text-[11px] text-ink-4">Project records are announcements, not facilities; their MW are planned figures (≈) and are never added to operational capacity.</p>158 </div>159 )}160 </div>161 );162}163164/* ---------------------------------------------------------------- share list (geographic diversity, top operators…) */165166export interface ShareItem {167 key: string;168 label: ReactNode;169 href?: string;170 /** primary count (facilities) */171 count: number;172 /** 0..1 share of the total */173 share: number;174 /** optional MW shown at the right */175 mw?: number | null;176 iso2?: string | null;177}178179/** Ranked list with share bars — "Equinix · 23 facilities · 16.8 %". */180export function ShareList({ items, unit = "facilities", className, max }: { items: ShareItem[]; unit?: string; className?: string; max?: number }) {181 if (!items.length) return <HonestEmpty title="Nothing to rank yet" />;182 const m = max ?? Math.max(0.01, ...items.map((i) => i.share));183 return (184 <ol className={cn("divide-y divide-hair", className)}>185 {items.map((it, i) => {186 const row = (187 <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 py-1.5 sm:min-h-0">188 <span className="figure w-5 text-right text-[11px] text-ink-4">{i + 1}</span>189 <div className="min-w-0">190 <div className="flex items-baseline justify-between gap-2">191 <span className="inline-flex min-w-0 items-center gap-1.5 truncate text-[13px] text-ink group-hover:text-accent">192 {it.iso2 && <CountryFlag iso2={it.iso2} size={12} />}193 <span className="truncate">{it.label}</span>194 </span>195 <span className="figure shrink-0 text-[11.5px] text-ink-3">196 {fmtInt(it.count)} {it.count === 1 ? unit.replace(/ies$/, "y").replace(/(?<!ie)s$/, "") : unit}197 {it.mw !== undefined && it.mw !== null && <span className="hidden sm:inline"> · {fmtMw(it.mw)}</span>}198 </span>199 </div>200 <div className="mt-1 h-[3px] w-full bg-surface-3" aria-hidden>201 <div className="h-full bg-ink-2" style={{ width: `${Math.max(1.5, (it.share / m) * 100)}%` }} />202 </div>203 </div>204 <span className="figure w-[52px] text-right text-[12.5px] text-ink-2">{fmtPct(it.share, it.share < 0.1 ? 1 : 0)}</span>205 </div>206 );207 return (208 <li key={it.key} className="group">209 {it.href ? (210 <Link href={it.href} className="block">211 {row}212 </Link>213 ) : (214 row215 )}216 </li>217 );218 })}219 </ol>220 );221}222223/* ---------------------------------------------------------------- grid constraints */224225export const GRID_KIND_LABEL: Record<string, string> = {226 moratorium: "Moratorium",227 grid_delay: "Grid delay",228 capacity_restriction: "Capacity restriction",229 load_cap: "Load cap",230 new_transmission: "New transmission",231 new_substation: "New substation",232 regulation: "Regulation",233 large_load_queue: "Large-load queue",234 power_agreement: "Power agreement",235 grid_connection: "Grid connection",236 grid_constraint: "Grid constraint",237 utility_event: "Utility event",238};239240const GRID_KIND_TONE: Record<string, BadgeTone> = {241 moratorium: "danger",242 grid_delay: "warn",243 capacity_restriction: "warn",244 load_cap: "warn",245 large_load_queue: "warn",246 new_transmission: "ok",247 new_substation: "ok",248 regulation: "info",249 power_agreement: "accent",250 grid_connection: "info",251 grid_constraint: "warn",252 utility_event: "neutral",253};254255export function GridKindBadge({ kind, size = "xs" }: { kind: string; size?: "xs" | "sm" }) {256 return (257 <Badge tone={GRID_KIND_TONE[kind] ?? "outline"} size={size} title={`Kind: ${GRID_KIND_LABEL[kind] ?? kind}`}>258 {GRID_KIND_LABEL[kind] ?? kind.replace(/_/g, " ")}259 </Badge>260 );261}262263/** Grid constraint / power event list — each item links to its source; the kind is a badge. */264export function GridConstraintList({ items, className, showPlace = true, emptyTitle = "No grid constraint or power event recorded", emptyDetail = "detected grid, utility and power-agreement events appear here; nothing is inferred" }: { items: GridConstraintDTO[]; className?: string; showPlace?: boolean; emptyTitle?: string; emptyDetail?: ReactNode }) {265 if (!items.length) return <HonestEmpty title={emptyTitle} detail={emptyDetail} className={className} />;266 return (267 <ul className={cn("divide-y divide-hair", className)}>268 {items.map((c) => (269 <li key={c.id} className="py-2.5">270 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">271 <GridKindBadge kind={c.kind} />272 {c.effectiveDate && (273 <time className="figure text-[11.5px] text-ink-3" dateTime={c.effectiveDate}>274 {fmtDate(c.effectiveDate)}275 </time>276 )}277 {showPlace && (c.metro || c.countryIso2) && (278 <span className="inline-flex items-center gap-1 text-[11.5px] text-ink-3">279 {c.countryIso2 && <CountryFlag iso2={c.countryIso2} size={11} />}280 {c.metro ? (281 <Link href={routes.market(c.metro.slug)} className="hover:text-ink hover:underline">282 {c.metro.name}283 </Link>284 ) : null}285 </span>286 )}287 <ConfidenceBadge level={c.confidence} size="xs" className="ml-auto" />288 </div>289 <p className="mt-1 text-[13px] font-medium leading-snug text-ink">{c.title}</p>290 {c.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-ink-2">{c.summary}</p>}291 <a href={c.url} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex min-h-10 items-center gap-1 text-[11.5px] text-ink-3 hover:text-accent sm:min-h-6">292 {c.sourceName ?? "source"}293 {c.sourceKind && <span className="text-ink-4">· {SOURCE_KIND_LABEL[c.sourceKind]}</span>}294 <ExternalLink className="size-3" />295 </a>296 </li>297 ))}298 </ul>299 );300}301302/* ---------------------------------------------------------------- coverage row → cells */303304export const COVERAGE_COLUMNS: Array<{ key: keyof CoverageRow; label: string; short: string }> = [305 { key: "capacityCoverage", label: "Capacity (any MW figure)", short: "Capacity" },306 { key: "operatorCoverage", label: "Operator known", short: "Operator" },307 { key: "preciseLocationCoverage", label: "Precise location (exact / parcel / street)", short: "Precise location" },308 { key: "statusCoverage", label: "Lifecycle status known", short: "Status" },309 { key: "openingDateCoverage", label: "Opening date published", short: "Opening date" },310 { key: "multiSourceCoverage", label: "Backed by ≥ 2 sources", short: "Multi-source" },311 { key: "connectivityCoverage", label: "Carriers / IXPs known", short: "Connectivity" },312 { key: "primarySourceShare", label: "Share of fields from primary sources", short: "Primary sources" },313];314315/** Compact coverage strip for one entity (operator / market / country page "coverage row"). */316export function CoverageStrip({ row, className }: { row: CoverageRow; className?: string }) {317 return (318 <div className={cn("grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4", className)}>319 {COVERAGE_COLUMNS.map((c) => (320 <div key={c.key} className="min-w-0">321 <div className="label-caps truncate" title={c.label}>322 {c.short}323 </div>324 <CoverageBar value={row[c.key] as number} width="w-full" label={c.label} className="mt-1" />325 </div>326 ))}327 <div className="col-span-2 text-[11.5px] text-ink-3 sm:col-span-4">328 <span className="figure">{fmtInt(row.facilities)}</span> facilities · <span className="figure">{fmtInt(row.projects)}</span> projects (<span className="figure">{fmtInt(row.projectsWithLocation)}</span> with a location). Coverage says how much is known, not how much exists.329 </div>330 </div>331 );332}333334/* ---------------------------------------------------------------- dense counter grid (pulse / momentum) */335336export interface CounterItem {337 key: string;338 value: number | null | undefined;339 /** "new projects detected" — rendered as "17 new projects detected" */340 label: string;341 format?: "int" | "mw";342 href?: string;343 note?: ReactNode;344}345346/** Dense grid of deterministic counters written as sentences; unknown values read "not published", never 0. */347export function CounterGrid({ items, suffix, className }: { items: CounterItem[]; suffix?: string; className?: string }) {348 return (349 <ul className={cn("grid grid-cols-1 gap-px overflow-hidden rounded-sm border border-hair bg-hair sm:grid-cols-2 lg:grid-cols-3", className)}>350 {items.map((it) => {351 const known = it.value !== null && it.value !== undefined;352 const v = it.format === "mw" ? fmtMw(it.value) : fmtInt(it.value);353 const inner = (354 <>355 <div className="flex items-baseline gap-1.5">356 <span className={cn("figure text-[22px] font-medium leading-none tracking-[-0.03em]", known ? "text-ink" : "text-ink-4")}>{known ? (it.format === "mw" ? v.split(" ")[0] : v) : "—"}</span>357 {known && it.format === "mw" && <span className="text-[11px] font-medium text-ink-3">{v.split(" ")[1]}</span>}358 </div>359 <div className="mt-1 text-[12.5px] leading-snug text-ink-2">360 {known ? it.label : <span className="text-ink-3">{it.label} — not published</span>}361 {suffix && known && <span className="text-ink-3"> {suffix}</span>}362 </div>363 {it.note && <div className="mt-0.5 text-[11px] text-ink-4">{it.note}</div>}364 </>365 );366 return (367 <li key={it.key} className="bg-surface">368 {it.href ? (369 <Link href={it.href} className="block h-full px-3 py-3 hover:bg-surface-2">370 {inner}371 </Link>372 ) : (373 <div className="h-full px-3 py-3">{inner}</div>374 )}375 </li>376 );377 })}378 </ul>379 );380}381382/** Small "Compare with…" / cross link pill. */383export function PillLink({ href, children, className }: { href: string; children: ReactNode; className?: string }) {384 return (385 <Link href={href} className={cn("inline-flex h-10 items-center gap-1.5 rounded-sm border border-hair bg-surface px-2.5 text-[12.5px] font-medium text-ink hover:border-hair-2 hover:text-accent sm:h-8", className)}>386 {children}387 </Link>388 );389}390