Skill Tracer
Skills › Archify › archify › charts.mjs

charts.mjs

70 lines · archify/explainer/charts.mjs at a406594

1import { PeleError, detectType, register, render } from './vendor/pele/core.js';
2import pie from './vendor/pele/diagrams/pie.js';
3import xychart from './vendor/pele/diagrams/xychart.js';
4import quadrantChart from './vendor/pele/diagrams/quadrantChart.js';
5import sankey from './vendor/pele/diagrams/sankey.js';
6import radar from './vendor/pele/diagrams/radar.js';
7import venn from './vendor/pele/diagrams/venn.js';
8
9register(pie, xychart, quadrantChart, sankey, radar, venn);
11const NATIVE = { flowchart: 'workflow or architecture', sequence: 'sequence', class: 'class', er: 'erd', state: 'lifecycle', timeline: 'timeline', gantt: 'timeline or waterfall', mindmap: 'tree', treeview: 'tree' };
13// A pie legend reads "Label · value (share%)" rather than Pele's "Label [value]". The labels are
14// rewritten before rendering so the legend is laid out for their real width.
15function pieLegend(text) {
16 if (!/^\s*pie\b/u.test(text)) return text;
17 const slice = /^(\s*)"((?:[^"\\]|\\.)*)"\s*:\s*(-?\d+(?:\.\d+)?)\s*$/gmu;
18 const total = [...text.matchAll(slice)].reduce((sum, match) => sum + Number(match[3]), 0);
19 if (!(total > 0)) return text;
20 return text.replace(/^(\s*pie)\s+showData\b/u, '$1').replace(slice, (_, indent, label, value) => { const share = Math.round(Number(value) / total * 100); return `${indent}"${label} · ${value}${/\p{Script=Han}/u.test(label) ? `(${share}%)` : ` (${share}%)`}" : ${value}`; });
23/** Render a Mermaid chart to static SVG, or return a located diagnostic reason. */
24export function compileExplainerChart(text) {
25 const type = detectType(text);
26 if (type && NATIVE[type]) return { error: `Mermaid ${type} is drawn with the native ${NATIVE[type]} shorthand, which Archify validates and lets readers explore.`, example: `@${NATIVE[type].split(' ')[0]} id=...` };
27 try {
28 const result = render(pieLegend(text), { padding: 8, fontSize: 13, links: false, images: false, config: { xyChart: { width: 480, height: 260, showDataLabel: true }, quadrantChart: { chartWidth: 400, chartHeight: 400 } } });
29 // A rotated CJK axis title lies on its side; vertical writing keeps the characters upright.
30 const svg = result.svg.replace(/<g transform="translate\(([\d.-]+),([\d.-]+)\) rotate\(-90\)"><text x="0" y="[\d.-]+" text-anchor="middle">([^<]*\p{Script=Han}[^<]*)<\/text><\/g>/gu, (_, x, y, label) => `<text x="${x}" y="${y}" text-anchor="middle" writing-mode="vertical-rl">${label}</text>`);
31 return { svg, width: result.width, height: result.height, type: result.type };
32 } catch (error) {
33 if (!(error instanceof PeleError)) throw error;
34 const supported = 'pie, xychart-beta (bar and line), quadrantChart, sankey-beta, radar-beta or venn-beta';
35 return { error: error.code === 'unsupported-diagram' ? `A chart fence supports ${supported}.` : error.message.split('\n')[0], line: error.line, example: 'pie title Share\n "A" : 60\n "B" : 40' };
36 }
39// A data file becomes chart text: CSV with a header row, or JSON as an array of objects. Labels come
40// from column x and one series from each column in y; a pie takes the first y column as its values.
41export function chartFromData(bytes, filePath, { kind, x, y, unit }) {
42 let rows;
43 const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\uFEFF/u, '');
44 if (/\.json$/iu.test(filePath)) {
45 try { rows = JSON.parse(text); } catch { return { error: 'The data file is not valid JSON.' }; }
46 if (!Array.isArray(rows) || !rows.every(row => row && typeof row === 'object' && !Array.isArray(row))) return { error: 'JSON chart data must be an array of objects.' };
47 } else {
48 const cells = line => { const out = []; let cell = '', quoted = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (quoted) { if (c === '"' && line[i + 1] === '"') { cell += '"'; i++; } else if (c === '"') quoted = false; else cell += c; } else if (c === '"') quoted = true; else if (c === ',') { out.push(cell.trim()); cell = ''; } else cell += c; } out.push(cell.trim()); return out; };
49 const lines = text.split(/\r?\n/u).filter(line => line.trim());
50 if (lines.length < 2) return { error: 'CSV chart data needs a header row and at least one data row.' };
51 const header = cells(lines[0]);
52 rows = lines.slice(1).map(line => Object.fromEntries(cells(line).map((value, i) => [header[i], value])));
53 }
54 const columns = new Set(rows.flatMap(row => Object.keys(row)));
55 const missing = [x, ...y].find(column => !columns.has(column));
56 if (missing) return { error: `The data file has no column "${missing}". Columns: ${[...columns].join(', ')}.` };
57 if (rows.length > (kind === 'pie' ? 12 : 60)) return { error: `A ${kind} chart takes at most ${kind === 'pie' ? 12 : 60} rows; this file has ${rows.length}.` };
58 const quote = value => `"${String(value).replace(/["\\]/gu, '')}"`;
59 // A blank cell is missing data, not zero: Number('') is 0, so blanks are rejected before conversion.
60 const blank = value => value === undefined || value === null || String(value).trim() === '';
61 const numbers = column => rows.map(row => blank(row[column]) ? NaN : Number(String(row[column]).replace(/,/gu, '')));
62 for (const column of y) {
63 const bad = numbers(column).findIndex(value => !Number.isFinite(value));
64 if (bad < 0) continue;
65 if (blank(rows[bad][column])) return { error: `Column "${column}" is empty in data row ${bad + 1}. A blank is missing data, not zero: fill in the value, or leave the row out and say so in the caption.` };
66 return { error: `Column "${column}" has a non-numeric value in data row ${bad + 1}: ${rows[bad][column]}.` };
67 }
68 if (kind === 'pie') { const values = numbers(y[0]); return { text: `pie\n${rows.map((row, i) => ` ${quote(row[x])} : ${values[i]}`).join('\n')}` }; }
69 return { text: `xychart-beta\n x-axis [${rows.map(row => quote(row[x])).join(', ')}]\n${unit ? ` y-axis ${quote(unit)}\n` : ''}${y.map(column => ` ${kind} ${quote(column)} [${numbers(column).join(', ')}]`).join('\n')}` };