File size: 6,213 Bytes
092334a c9d432b 092334a c9d432b 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | // ---------------------------------------------------------------------------
// pages / PageView.tsx β EXIT wave 2 (W2-7, contracts Y1 + Y2 + Y3).
//
// THE renderer. A Y1 envelope goes in, a page comes out β and it knows the name
// of no page. That is the whole bet of the wave: wave 3 ships Collections and
// Procurement by adding a server-side builder and a registry key, not a
// component tree, and this file is where that promise is either kept or broken.
// If it ever grows `if (page.key === β¦)`, the envelope has stopped paying for
// itself and it belongs in the doc as an amendment, not in a branch here.
//
// TWO RULES ARE STRUCTURAL, NOT STYLISTIC:
// Β· **Blocks render IN ORDER** (Y1 rule 7). The server decides the narrative;
// the client does not re-sort it into what it thinks reads better.
// Β· **An unknown block type is SKIPPED, never fatal.** That is the property
// that lets the server learn a block type before this client does β a
// deployment ordering the wave will actually hit.
// ---------------------------------------------------------------------------
import { DashHeader, DataTable, KpiRow, Section, ValidationPanel } from "../ui";
import type { DrillDescriptor, OnOpen } from "../ui";
import type {
ChartBlock,
DecompDescriptor,
KpisBlock,
PageBlock,
PageControl,
PageEnvelope,
SectionBlock,
TableBlock,
ValidationBlock,
} from "../ui/types";
import { isRowDrillRule } from "../ui/types";
import { SeriesChart } from "../viz/SeriesChart";
import type { ChartSpec } from "../viz/chartData";
import type { Row } from "../viz/types";
/** A `section` block labels what FOLLOWS it, so the renderer pairs each one
* with the blocks up to the next section. Without this the note and the table
* it explains are two unrelated slabs with a gap between them. */
interface Group {
head?: SectionBlock;
body: PageBlock[];
}
function group(blocks: PageBlock[]): Group[] {
const out: Group[] = [];
let cur: Group = { body: [] };
for (const b of blocks) {
if (b.type === "section") {
if (cur.head || cur.body.length) out.push(cur);
cur = { head: b as SectionBlock, body: [] };
} else {
cur.body.push(b);
}
}
if (cur.head || cur.body.length) out.push(cur);
return out;
}
function ChartBlockView({ block, onOpen }: { block: ChartBlock; onOpen?: OnOpen }) {
const rule = block.drill;
const rowByX = new Map<string, DecompDescriptor>();
if (isRowDrillRule(rule)) {
const xKey = (block.spec as { x?: string }).x;
for (const r of block.rows) {
const d = r[rule.row_key];
const x = xKey ? r[xKey] : undefined;
if (x != null && d && typeof d === "object" && (d as DecompDescriptor).kind === "decomp") {
rowByX.set(String(x), d as DecompDescriptor);
}
}
}
// The server-sent per-bucket delta (the printed YoY %). A null stays absent β
// the partial period carries no label, by the server's own rule.
const deltaByX = new Map<string, number>();
if (block.delta_key) {
const xKey = (block.spec as { x?: string }).x;
for (const r of block.rows) {
const x = xKey ? r[xKey] : undefined;
const v = r[block.delta_key];
if (x != null && typeof v === "number" && Number.isFinite(v)) deltaByX.set(String(x), v);
}
}
const pickable = !!onOpen && rowByX.size > 0;
return (
<>
<SeriesChart
spec={block.spec as unknown as ChartSpec}
series={block.series ?? []}
fields={block.fields}
rows={block.rows as unknown as Row[]}
xOrder={block.x_order}
deltaByKey={deltaByX.size ? deltaByX : undefined}
pickable={pickable}
onPick={(x) => {
const d = rowByX.get(x);
if (d && onOpen) onOpen(d);
}}
/>
{/* Y1 rule 6 β even a chart discloses its cap. A trend that quietly drew
12 of 18 months is the same defect as a truncated table. */}
{block.shown != null && block.total != null && block.total > block.shown ? (
<p className="pg-shown is-capped">
Showing {block.shown} of {block.total} periods
</p>
) : null}
</>
);
}
function BlockView({ block, onOpen }: { block: PageBlock; onOpen?: OnOpen }) {
switch (block.type) {
case "kpis":
return <KpiRow items={(block as KpisBlock).items} onOpen={onOpen} />;
case "table": {
const t = block as TableBlock;
return (
<DataTable
columns={t.columns}
rows={t.rows}
drill={t.drill}
shown={t.shown}
total={t.total}
empty={t.empty}
download={t.download}
onOpen={onOpen}
/>
);
}
case "chart":
return <ChartBlockView block={block as ChartBlock} onOpen={onOpen} />;
case "validation":
return <ValidationPanel checks={(block as ValidationBlock).checks} />;
default:
// β Y1 rule 7. Not an error, not a placeholder, not a console warning the
// user cannot see β nothing. A block type this client does not know is a
// server that is ahead of it, which is the normal state during a rollout.
return null;
}
}
export function PageView({
page,
onControl,
onOpen,
busy,
}: {
page: PageEnvelope;
onControl?: (key: string, value: string) => void;
onOpen?: (d: DrillDescriptor) => void;
busy?: boolean;
}) {
const groups = group(page.blocks);
return (
<div className="pg-page">
<DashHeader
title={page.title}
asOf={page.as_of}
subtitle={page.subtitle}
controls={page.controls as PageControl[] | undefined}
onControl={onControl}
busy={busy}
/>
{groups.map((g, i) =>
g.head ? (
<Section key={g.head.key ?? `s${i}`} label={g.head.label} note={g.head.note}>
{g.body.map((b, j) => (
<BlockView key={b.key ?? `b${j}`} block={b} onOpen={onOpen} />
))}
</Section>
) : (
<div key={`g${i}`}>
{g.body.map((b, j) => (
<BlockView key={b.key ?? `b${j}`} block={b} onOpen={onOpen} />
))}
</div>
)
)}
</div>
);
}
|