smapoles's picture
Stand up chart factory static site
1f92077 verified
Raw History Blame Contribute Delete
11.3 kB
// renderers/_shared/cell.js β€” the ONE reusable card/cell component.
// Single owner of card chrome (spec Β§7.3): renderers build the standardized
// shell here and own only what goes INSIDE the chart area. Everything is
// DOM-built (no innerHTML with payload data), so payload strings need no
// escaping. Styles live in the sibling cell.css / tooltip.css, auto-injected
// once per document by ensureSharedCss() (createCard calls it for you).
//
// Typical renderer usage:
// import { createCard, statCell, setCell, applyTips,
// renderTable, renderMethodology } from '../_shared/cell.js';
// export function mount(el, payload) {
// const { card, body, setStamp } = createCard(el, payload.meta);
// const grid = document.createElement('div');
// grid.className = 'cf-cell-grid';
// grid.append(statCell('phi', 'persistence Β· Ο†'));
// body.append(grid);
// applyTips(card, payload.tips);
// setCell(card, 'phi', 'persistence Β· Ο†', payload.stats.phi.toFixed(2));
// renderTable(body, payload.table);
// renderMethodology(body, payload.method);
// ...
// }
const SHARED_CSS = ['cell.css', 'tooltip.css'];
/**
* Idempotently inject <link> tags for the shared stylesheets into the
* document head. Resolved relative to this module, so it works from the
* gallery, a dashboard shell, or a test page alike. The gallery also links
* these directly; the data-cf-shared guard prevents doubles.
*/
export function ensureSharedCss(doc = document) {
const head = doc.head || doc.getElementsByTagName('head')[0];
if (!head) return;
for (const file of SHARED_CSS) {
if (head.querySelector(`link[data-cf-shared="${file}"]`)) continue;
const link = doc.createElement('link');
link.rel = 'stylesheet';
link.href = new URL(`./${file}`, import.meta.url).href;
link.setAttribute('data-cf-shared', file);
head.appendChild(link);
}
}
/**
* Build the standardized card shell under `el`, namespaced by chart id:
* the card root gets class `cf-card` PLUS `meta.id` (the per-chart CSS
* namespace β€” scope all renderer-specific rules under it; no global ids).
*
* @param {HTMLElement} el mount host
* @param {object} meta payload.meta ({ id, title, ... })
* @returns {{ card: HTMLElement, body: HTMLElement, setStamp: (t:string)=>void }}
* card β€” the .cf-card root (query slots against this)
* body β€” .cf-card-body, append chart/grid/table/method content here
* setStamp β€” sets the quiet header stamp (e.g. "30 days" or a date)
*/
export function createCard(el, meta = {}) {
ensureSharedCss(el.ownerDocument);
const doc = el.ownerDocument;
const card = doc.createElement('section');
card.className = meta.id ? `cf-card ${meta.id}` : 'cf-card';
const title = doc.createElement('div');
title.className = 'cf-card-title';
const titleText = doc.createElement('span');
titleText.setAttribute('data-field', 'title');
titleText.textContent = meta.title ?? meta.id ?? '';
const stamp = doc.createElement('span');
stamp.className = 'cf-stamp';
stamp.setAttribute('data-field', 'stamp');
stamp.textContent = 'β€”';
title.append(titleText, stamp);
const body = doc.createElement('div');
body.className = 'cf-card-body';
card.append(title, body);
el.appendChild(card);
return {
card,
body,
setStamp(text) { stamp.textContent = text ?? 'β€”'; },
};
}
/**
* Stat-cell factory. Produces the standard quiet-label-over-big-value cell:
* <div class="cf-cell" data-slot="<slot>" [data-tip="<tip>"]>
* <span class="cf-cell-label" data-field="label">
* <b class="cf-cell-value" data-field="val">β€”</b>
* The slot string is the coupling key: markup (data-slot), CSS grid
* placement (per-chart CSS targets [data-slot="x"]), and tip copy
* (payload.tips[slot], applied here or later via applyTips).
*/
export function statCell(slot, label, tip = '') {
const cell = document.createElement('div');
cell.className = 'cf-cell';
cell.setAttribute('data-slot', slot);
if (tip) cell.setAttribute('data-tip', tip);
const lab = document.createElement('span');
lab.className = 'cf-cell-label';
lab.setAttribute('data-field', 'label');
lab.textContent = label ?? '';
const val = document.createElement('b');
val.className = 'cf-cell-value';
val.setAttribute('data-field', 'val');
val.textContent = 'β€”';
cell.append(lab, val);
return cell;
}
/**
* Set a stat cell's label + formatted value. `host` is any ancestor of the
* cell (usually the card root). Missing slots are ignored (renderer decides
* its slot set; payloads with fewer stats simply leave cells at "β€”").
*/
export function setCell(host, slot, label, val) {
const cell = host.querySelector(`[data-slot="${slot}"]`);
if (!cell) return;
const lab = cell.querySelector('[data-field="label"]');
const v = cell.querySelector('[data-field="val"]');
if (lab && label != null) lab.textContent = label;
if (v) v.textContent = val == null || val === '' ? 'β€”' : String(val);
}
/**
* Apply payload.tips ({ slot: copy }) onto every matching [data-slot] under
* `host`. Empty/absent copy REMOVES the attribute, so the CSS empty-tip
* guard never shows a blank box.
*/
export function applyTips(host, tips = {}) {
for (const el of host.querySelectorAll('[data-slot]')) {
const copy = tips ? tips[el.getAttribute('data-slot')] : null;
if (copy) el.setAttribute('data-tip', copy);
else el.removeAttribute('data-tip');
}
}
/**
* Format one value per a CONTRACT-2 column spec ({ precision }).
* null/undefined β†’ em-dash; numbers honor precision; rest pass through.
*/
export function formatValue(v, precision) {
if (v == null || v === '') return 'β€”';
if (typeof v === 'number' && Number.isFinite(v)) {
return precision == null ? String(v) : v.toFixed(precision);
}
return String(v);
}
/**
* Generic renderer for the CONTRACT-2 table block:
* { columns: [{ key, label, unit, precision }], rows: [{ <key>: value }] }
* Column headers show "label Β· unit" when a unit is given; numeric columns
* (any column with a precision, or whose first non-null value is a number)
* right-align with tabular numerals. Replaces `host`'s previous table
* (data-cf-table) so update() can just call it again.
* Returns the wrapper element (or null when tableSpec is empty).
*/
export function renderTable(host, tableSpec) {
host.querySelector(':scope > [data-cf-table]')?.remove();
if (!tableSpec || !Array.isArray(tableSpec.columns) || tableSpec.columns.length === 0) {
return null;
}
const doc = host.ownerDocument;
const rows = Array.isArray(tableSpec.rows) ? tableSpec.rows : [];
const numeric = tableSpec.columns.map((c) => {
if (c.precision != null) return true;
const probe = rows.find((r) => r[c.key] != null);
return probe ? typeof probe[c.key] === 'number' : false;
});
const wrap = doc.createElement('div');
wrap.className = 'cf-table-wrap';
wrap.setAttribute('data-cf-table', '');
const table = doc.createElement('table');
table.className = 'cf-table';
const thead = doc.createElement('thead');
const hr = doc.createElement('tr');
tableSpec.columns.forEach((c, i) => {
const th = doc.createElement('th');
th.textContent = c.unit ? `${c.label} Β· ${c.unit}` : (c.label ?? c.key);
if (numeric[i]) th.classList.add('cf-num');
hr.appendChild(th);
});
thead.appendChild(hr);
const tbody = doc.createElement('tbody');
for (const row of rows) {
const tr = doc.createElement('tr');
tableSpec.columns.forEach((c, i) => {
const td = doc.createElement('td');
td.textContent = formatValue(row[c.key], c.precision);
if (numeric[i]) td.classList.add('cf-num');
tr.appendChild(td);
});
tbody.appendChild(tr);
}
table.append(thead, tbody);
wrap.appendChild(table);
host.appendChild(wrap);
return wrap;
}
/**
* Render the engine-emitted methodology block (CONTRACT-2 method):
* { prose: string | string[], equations: string[], citations: [{key, apa}] }
* Prose splits on blank lines into paragraphs (or takes an array as-is);
* equations render as display lines; citations as an APA hanging-indent
* list. Replaces `host`'s previous methodology block (data-cf-method).
* Returns the block element (or null when methodBlock is empty).
*/
export function renderMethodology(host, methodBlock) {
host.querySelector(':scope > [data-cf-method]')?.remove();
if (!methodBlock) return null;
const doc = host.ownerDocument;
const block = doc.createElement('div');
block.className = 'cf-method';
block.setAttribute('data-cf-method', '');
block.setAttribute('data-slot', 'method');
const heading = doc.createElement('h4');
heading.className = 'cf-method-heading';
heading.textContent = 'Methodology';
block.appendChild(heading);
const paragraphs = Array.isArray(methodBlock.prose)
? methodBlock.prose
: String(methodBlock.prose ?? '').split(/\n\s*\n/);
for (const para of paragraphs) {
const text = String(para).trim();
if (!text) continue;
const p = doc.createElement('p');
p.textContent = text;
block.appendChild(p);
}
for (const eq of methodBlock.equations ?? []) {
const span = doc.createElement('span');
span.className = 'cf-eq';
span.textContent = eq;
block.appendChild(span);
}
const citations = methodBlock.citations ?? [];
if (citations.length) {
const list = doc.createElement('ul');
list.className = 'cf-citations';
for (const c of citations) {
const li = doc.createElement('li');
li.className = 'cf-citation';
if (c.key) li.setAttribute('data-litref', c.key);
li.textContent = c.apa ?? c.key ?? '';
list.appendChild(li);
}
block.appendChild(list);
}
host.appendChild(block);
return block;
}
/**
* Render the engine-emitted results scaffold (payload.results) as a "Results"
* block beneath the methodology, mirroring renderMethodology. resultsBlock is
* { summary, findings[] } from engine/results.py β€” the stated numbers, so the
* card shows both a Method and a Results section per chart.
*/
export function renderResults(host, resultsBlock) {
host.querySelector(':scope > [data-cf-results]')?.remove();
if (!resultsBlock || !(resultsBlock.summary
|| (Array.isArray(resultsBlock.findings) && resultsBlock.findings.length))) {
return null;
}
const doc = host.ownerDocument;
const block = doc.createElement('div');
block.className = 'cf-results';
block.setAttribute('data-cf-results', '');
block.setAttribute('data-slot', 'results');
const heading = doc.createElement('h4');
heading.className = 'cf-results-heading';
heading.textContent = 'Results';
block.appendChild(heading);
if (resultsBlock.summary) {
const p = doc.createElement('p');
p.textContent = String(resultsBlock.summary);
block.appendChild(p);
}
const findings = resultsBlock.findings ?? [];
if (findings.length) {
const list = doc.createElement('ul');
list.className = 'cf-findings';
for (const f of findings) {
const li = doc.createElement('li');
li.className = 'cf-finding';
li.textContent = String(f);
list.appendChild(li);
}
block.appendChild(list);
}
host.appendChild(block);
return block;
}