spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { OperatorComparison } from "@dci/core";2import type { Metadata } from "next";3import Link from "next/link";4import type { ReactNode } from "react";5import { Breadcrumbs } from "@/components/entity/breadcrumbs";6import { SourcesFooter } from "@/components/entity/sources-footer";7import { OperatorPicker } from "@/components/intelligence/compare/operator-picker";8import { CoverageBar, HonestEmpty, PublishedOnlyNote } from "@/components/intelligence/shared";9import { Badge } from "@/components/ui/badge";10import { Page, PageHeader, Section } from "@/components/ui/card";11import { CountryFlag } from "@/components/ui/country-flag";12import { EmptyState, OfflineNotice } from "@/components/ui/states";13import { api } from "@/lib/api";14import { fmtInt, fmtMw, fmtPct } from "@/lib/format";15import { OPERATOR_KIND_LABEL } from "@/lib/labels";16import { routes } from "@/lib/routes";17import { pageMetadata } from "@/lib/seo";18import type { SearchParams } from "@/lib/sort";1920export const revalidate = 120;2122const MAX = 5;2324export const metadata: Metadata = pageMetadata({25 title: "Compare operators",26 description: "Side-by-side comparison of 2–5 data center operators: facilities, countries, markets, known MW with its coverage, pipeline, AI facilities, new markets entered and recent projects — published figures only.",27 path: routes.compareOperators(),28});2930/* Presets verified against the index (top operators by facility count on 2026-09-12). */31const PRESETS: Array<{ label: string; slugs: string[] }> = [32 { label: "Equinix vs Digital Realty", slugs: ["equinix", "digital-realty"] },33 { label: "US wholesale: STACK · CyrusOne · Vantage · QTS", slugs: ["stack-infrastructure", "cyrusone", "vantage-data-centers", "qts"] },34 { label: "DataBank vs EdgeConneX", slugs: ["databank", "edgeconnex"] },35];3637type Op = OperatorComparison["operators"][number];3839function parseSlugs(sp: SearchParams): string[] {40 const raw = sp.slugs;41 const s = Array.isArray(raw) ? raw.join(",") : (raw ?? "");42 const out: string[] = [];43 for (const part of s.split(",")) {44 const v = part.trim().toLowerCase();45 if (v && /^[a-z0-9][a-z0-9-]*$/.test(v) && !out.includes(v)) out.push(v);46 }47 return out.slice(0, MAX);48}4950const dash = <span className="text-ink-4">—</span>;51const mw = (v: number | null | undefined, est?: boolean) => (v === null || v === undefined ? dash : <span className="figure">{est ? "≈ " : ""}{fmtMw(v)}</span>);52const int = (v: number | null | undefined) => (v === null || v === undefined ? dash : <span className="figure">{fmtInt(v)}</span>);5354interface RowDef {55 key: string;56 label: string;57 hint?: string;58 cell: (o: Op) => ReactNode;59 /** row belongs to a group header */60 group?: string;61}6263function rows(): RowDef[] {64 const win = (o: Op, label: "12m" | "3y" | "5y") => o.velocity.windows.find((w) => w.label === label);65 return [66 { key: "kind", label: "Kind", cell: (o) => (o.kind ? OPERATOR_KIND_LABEL[o.kind] : dash) },67 { key: "hq", label: "Headquarters", cell: (o) => (o.hqCountryIso2 ? <CountryFlag iso2={o.hqCountryIso2} withName /> : dash) },68 { key: "facilities", label: "Facilities", group: "Footprint", cell: (o) => int(o.facilityCount) },69 { key: "countries", label: "Countries", cell: (o) => int(o.countryCount) },70 { key: "metros", label: "Markets", cell: (o) => int(o.metroCount) },71 { key: "known", label: "Known operational MW", group: "Capacity (published figures only)", hint: "Σ IT / total power over operational facilities", cell: (o) => mw(o.knownMw) },72 { key: "coverage", label: "Capacity coverage", hint: "share of facilities with any MW figure", cell: (o) => <CoverageBar value={o.coverage ?? o.mwCoverage ?? o.pipeline.mwCoverage} width="w-full max-w-[160px]" label="Capacity coverage" /> },73 { key: "construction", label: "Under construction MW", cell: (o) => mw(o.constructionMw ?? o.pipeline.construction.mw, true) },74 { key: "planned", label: "Planned MW", cell: (o) => mw(o.plannedMw ?? (o.pipeline.approved.mw === null && o.pipeline.announced.mw === null ? null : (o.pipeline.approved.mw ?? 0) + (o.pipeline.announced.mw ?? 0)), true) },75 { key: "p-op", label: "Operational sites", group: "Pipeline buckets", cell: (o) => int(o.pipeline.operational.count) },76 { key: "p-con", label: "Under construction sites", cell: (o) => int(o.pipeline.construction.count) },77 { key: "p-app", label: "Approved / permitting sites", cell: (o) => int(o.pipeline.approved.count) },78 { key: "p-ann", label: "Announced / proposed sites", cell: (o) => int(o.pipeline.announced.count) },79 { key: "projects", label: "Project records", cell: (o) => int(o.projectCount ?? o.pipeline.projects.reduce((a, p) => a + p.count, 0)) },80 { key: "pmw", label: "Project planned MW", cell: (o) => mw(o.projectPlannedMw, true) },81 { key: "ai", label: "AI facilities", group: "AI & cloud", hint: "confirmed + likely evidence", cell: (o) => int(o.aiCount) },82 { key: "cloud", label: "Cloud regions", cell: (o) => int(o.cloudRegionCount) },83 {84 key: "newmarkets",85 label: "New markets entered (12 m)",86 group: "Expansion",87 cell: (o) =>88 o.newMarkets12m.length ? (89 <span className="flex flex-wrap gap-1">90 {o.newMarkets12m.slice(0, 8).map((m) => (91 <Link key={m.id} href={routes.market(m.slug)} className="inline-flex h-10 items-center rounded-xs border border-hair bg-surface px-2 text-[11.5px] text-ink hover:text-accent sm:h-6 sm:px-1.5">92 {m.name}93 </Link>94 ))}95 {o.newMarkets12m.length > 8 && <span className="text-[11.5px] text-ink-4">+{o.newMarkets12m.length - 8}</span>}96 </span>97 ) : (98 <span className="text-ink-4">none</span>99 ),100 },101 { key: "v12", label: "New facilities · 12 m", cell: (o) => int(win(o, "12m")?.newFacilities) },102 { key: "v3", label: "New facilities · 3 y", cell: (o) => int(win(o, "3y")?.newFacilities) },103 { key: "v5", label: "New facilities · 5 y", cell: (o) => int(win(o, "5y")?.newFacilities) },104 { key: "c12", label: "New countries · 12 m", cell: (o) => int(win(o, "12m")?.newCountries) },105 { key: "np12", label: "New projects · 12 m", cell: (o) => int(win(o, "12m")?.newProjects) },106 {107 key: "recent",108 label: "Recent projects",109 group: "Projects",110 cell: (o) =>111 o.recentProjects.length ? (112 <ul className="flex flex-col gap-1">113 {o.recentProjects.slice(0, 3).map((p) => (114 <li key={p.id} className="min-w-0">115 <Link href={routes.project(p.slug)} className="block truncate text-[12.5px] text-ink hover:text-accent hover:underline" title={p.name}>116 {p.name}117 </Link>118 <span className="figure text-[11px] text-ink-3">119 {p.plannedMw !== null ? `≈ ${fmtMw(p.plannedMw)}` : "MW —"}120 {p.countryIso2 ? ` · ${p.countryIso2}` : ""}121 </span>122 </li>123 ))}124 </ul>125 ) : (126 <span className="text-ink-4">none</span>127 ),128 },129 ];130}131132function ComparisonGrid({ ops }: { ops: Op[] }) {133 const defs = rows();134 const cols = `minmax(150px,200px) repeat(${ops.length}, minmax(150px, 1fr))`;135 return (136 <>137 {/* ≥ 640 px: one column per operator (scrolls horizontally inside its own container for 4–5 operators) */}138 <div className="hidden overflow-x-auto rounded-sm border border-hair sm:block">139 <div role="table" aria-label="Operator comparison" className="min-w-[640px] text-[13px]" style={{ display: "grid", gridTemplateColumns: cols }}>140 <div role="row" className="contents">141 <div role="columnheader" className="label-caps sticky left-0 z-[1] border-b border-hair bg-surface px-3 py-2.5">142 Metric143 </div>144 {ops.map((o) => (145 <div key={o.id} role="columnheader" className="border-b border-l border-hair bg-surface px-3 py-2.5">146 <Link href={routes.operator(o.slug)} className="block truncate text-[13.5px] font-semibold text-ink hover:text-accent hover:underline" title={o.name}>147 {o.name}148 </Link>149 <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-ink-3">150 {o.kind && (151 <Badge tone={o.kind === "hyperscaler" ? "accent" : "outline"} size="xs">152 {OPERATOR_KIND_LABEL[o.kind]}153 </Badge>154 )}155 {o.hqCountryIso2 && <CountryFlag iso2={o.hqCountryIso2} size={11} />}156 </div>157 </div>158 ))}159 </div>160 {defs.map((d) => (161 <div key={d.key} role="row" className="contents">162 {d.group && (163 <div role="rowheader" className="label-caps border-b border-hair bg-surface-2 px-3 py-1.5 text-ink-3" style={{ gridColumn: `1 / span ${ops.length + 1}` }}>164 {d.group}165 </div>166 )}167 <div role="rowheader" className="sticky left-0 z-[1] border-b border-hair bg-paper px-3 py-2 text-ink-2">168 {d.label}169 {d.hint && <div className="text-[11px] text-ink-4">{d.hint}</div>}170 </div>171 {ops.map((o) => (172 <div key={o.id} role="cell" className="min-w-0 border-b border-l border-hair px-3 py-2 text-ink">173 {d.cell(o)}174 </div>175 ))}176 </div>177 ))}178 </div>179 </div>180 {/* < 640 px: stacked per-operator cards */}181 <div className="flex flex-col gap-4 sm:hidden">182 {ops.map((o) => (183 <section key={o.id} className="rounded-sm border border-hair bg-surface" aria-label={o.name}>184 <div className="border-b border-hair px-3 py-2.5">185 <Link href={routes.operator(o.slug)} className="text-[14px] font-semibold text-ink hover:text-accent">186 {o.name}187 </Link>188 <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-ink-3">189 {o.kind && (190 <Badge tone="outline" size="xs">191 {OPERATOR_KIND_LABEL[o.kind]}192 </Badge>193 )}194 {o.hqCountryIso2 && <CountryFlag iso2={o.hqCountryIso2} withName />}195 </div>196 </div>197 <dl className="divide-y divide-hair px-3">198 {defs199 .filter((d) => d.key !== "kind" && d.key !== "hq")200 .map((d) => (201 <div key={d.key} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-x-3 py-2 text-[12.5px]">202 <dt className="text-ink-3">{d.label}</dt>203 <dd className="min-w-0 text-right text-ink [&_.flex-wrap]:justify-end">{d.cell(o)}</dd>204 </div>205 ))}206 </dl>207 </section>208 ))}209 </div>210 </>211 );212}213214export default async function CompareOperatorsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {215 const sp = await searchParams;216 const slugs = parseSlugs(sp);217 const r = slugs.length >= 2 ? await api.compareOperators(slugs) : null;218 const ops = r?.ok ? r.data.operators : [];219 const names: Record<string, string> = {};220 for (const o of ops) names[o.slug] = o.name;221 const missing = slugs.filter((s) => !names[s]);222 // slugs picked but not yet resolved (< 2 selected): resolve names for the chips223 if (slugs.length === 1) {224 const one = await api.operator(slugs[0]!);225 if (one.ok) names[slugs[0]!] = one.data.name;226 }227228 return (229 <Page>230 <Breadcrumbs items={[{ name: "Operators", href: routes.operators() }, { name: "Compare", href: routes.compareOperators() }]} className="pt-3" />231 <PageHeader eyebrow="Compare" title="Compare operators" description="Two to five operators side by side. Capacity figures are published values only and every operator carries its capacity coverage — a low coverage means the MW column understates reality." />232233 <OperatorPicker slugs={slugs} names={names} />234235 <div className="mt-6">236 {slugs.length < 2 ? (237 <EmptyState238 title={slugs.length === 1 ? "Add a second operator to compare" : "Pick two or more operators"}239 description="Use the search box above, or start from a preset."240 action={241 <ul className="flex flex-wrap justify-center gap-1.5">242 {PRESETS.map((p) => (243 <li key={p.label}>244 <Link href={routes.compareOperators(p.slugs)} className="inline-flex h-10 items-center rounded-sm border border-hair bg-surface px-2.5 text-[12.5px] text-ink hover:border-hair-2 hover:text-accent sm:h-8">245 {p.label}246 </Link>247 </li>248 ))}249 </ul>250 }251 />252 ) : r && !r.ok ? (253 <>254 <OfflineNotice detail={r.error} className="mb-3" />255 <HonestEmpty title="Comparison unavailable" detail="the index API did not answer; the selection is kept in the URL" />256 </>257 ) : (258 <>259 {missing.length > 0 && <HonestEmpty className="mb-3" title={`Unknown operator ${missing.length === 1 ? "slug" : "slugs"}: ${missing.join(", ")}`} detail="not in the index — remove the chip or search again" />}260 {ops.length >= 2 ? (261 <Section title="Side by side" description={`${ops.length} operators · ${r?.data.generatedAt ? `computed ${new Date(r.data.generatedAt).toISOString().slice(0, 10)} UTC` : ""}`}>262 <ComparisonGrid ops={ops} />263 <PublishedOnlyNote className="mt-3" extra={<>Capacity coverage = facilities with any MW figure ÷ facilities ({ops.map((o) => `${o.name} ${fmtPct(o.coverage ?? o.mwCoverage ?? 0)}`).join(" · ")}). </>} />264 </Section>265 ) : (266 <HonestEmpty title="Fewer than two operators could be resolved" detail="check the slugs in the URL" />267 )}268 </>269 )}270 </div>271272 <SourcesFooter sources={r?.ok ? r.sources : undefined} note="Comparison rows are read from each operator's record: counts are exact, MW figures are lower bounds from published values, and 'new facilities' rely on opening dates or first observation (see each operator's velocity section)." />273 </Page>274 );275}276