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