SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.1 KB · 108 lines tsx
Raw Blame History
1import type { ExpansionVelocity } from "@dci/core";2import { StepLines } from "@/components/intelligence/client-charts";3import { HonestEmpty } from "@/components/intelligence/shared";4import { TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";5import { fmtInt, fmtMw } from "@/lib/format";67const WINDOW_LABEL: Record<ExpansionVelocity["windows"][number]["label"], string> = { "12m": "Last 12 months", "3y": "Last 3 years", "5y": "Last 5 years" };89/**10 * Expansion velocity: three windows (12 m / 3 y / 5 y) as a compact table, then the cumulative11 * countries / metros / facilities step chart. Every point carries its basis: "opened" = published12 * opening date; "first_seen" = when the index first observed the facility (NOT when it opened).13 */14export function ExpansionVelocityBlock({ velocity }: { velocity: ExpansionVelocity }) {15  const windows = velocity.windows;16  const series = velocity.countriesOverTime;17  const bases = new Set(series.map((p) => p.basis));18  const firstSeenYears = series.filter((p) => p.basis === "first_seen").map((p) => p.year);19  const fmtN = (n: number | null | undefined) => (n === null || n === undefined ? <span className="text-ink-4">—</span> : fmtInt(n));20  const fmtM = (n: number | null | undefined) => (n === null || n === undefined ? <span className="text-ink-4" title="No published MW figure">—</span> : fmtMw(n));2122  return (23    <div className="flex flex-col gap-5">24      {windows.length ? (25        <Table dense>26          <THead>27            <tr>28              <Th>Window</Th>29              <Th numeric>New facilities</Th>30              <Th numeric>New projects</Th>31              <Th numeric>New countries</Th>32              <Th numeric>New markets</Th>33              <Th numeric className="hidden sm:table-cell">34                Opened MW35              </Th>36              <Th numeric className="hidden sm:table-cell">37                Announced MW38              </Th>39            </tr>40          </THead>41          <TBody>42            {windows.map((w) => (43              <Tr key={w.label}>44                <Td primary>{WINDOW_LABEL[w.label]}</Td>45                <Td label="New facilities" numeric lead>46                  {fmtN(w.newFacilities)}47                </Td>48                <Td label="New projects" numeric>49                  {fmtN(w.newProjects)}50                </Td>51                <Td label="New countries" numeric>52                  {fmtN(w.newCountries)}53                </Td>54                <Td label="New markets" numeric>55                  {fmtN(w.newMetros)}56                </Td>57                <Td label="Opened MW" numeric className="hidden sm:table-cell">58                  {fmtM(w.openedMw)}59                </Td>60                <Td label="Announced MW" numeric className="hidden sm:table-cell">61                  {fmtM(w.announcedMw)}62                </Td>63              </Tr>64            ))}65          </TBody>66        </Table>67      ) : (68        <HonestEmpty title="No dated expansion yet" detail="windows need opening, announcement or first-observation dates" />69      )}70      <p className="text-[11.5px] text-ink-3">71        “New facilities” counts facilities whose opening date — or, when none is published, the date the index first observed them — falls in the window. MW columns show published figures only.72      </p>7374      <div>75        <div className="label-caps mb-2">Cumulative footprint by year</div>76        {series.length >= 2 ? (77          <>78            <StepLines79              height={180}80              format="int"81              series={[82                { name: "Countries", color: "var(--ink)", data: series.map((p) => ({ x: p.year, y: p.countries })) },83                { name: "Markets", color: "var(--st-planned)", data: series.map((p) => ({ x: p.year, y: p.metros })) },84                { name: "Facilities", color: "var(--accent)", dashed: true, data: series.map((p) => ({ x: p.year, y: p.facilities })) },85              ]}86              ariaLabel="Cumulative countries, markets and facilities by year"87            />88            <ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-ink-3">89              {series.map((p) => (90                <li key={p.year} className="figure">91                  {p.year}92                  <span className="text-ink-4"> · {p.basis === "opened" ? "opened" : "first seen"}</span>93                </li>94              ))}95            </ul>96          </>97        ) : (98          <HonestEmpty title="Not enough dated points to chart" detail="a step chart appears once two or more years are known" />99        )}100        <p className="mt-2 text-[11.5px] text-ink-3">101          Basis per point: <strong className="font-medium text-ink-2">opened</strong> = published opening date; <strong className="font-medium text-ink-2">first seen</strong> = the year the index first observed the facility, which is <em>not</em> when it opened.102          {bases.has("first_seen") && firstSeenYears.length > 0 && <> The {firstSeenYears.length === 1 ? "point for" : "points for"} {firstSeenYears.join(", ")} {firstSeenYears.length === 1 ? "is" : "are"} first-seen values and overstate recent growth.</>}103        </p>104      </div>105    </div>106  );107}108