SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
14.2 KB · 276 lines tsx
Raw Blame History
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