@kaydash9999/banker-plugins 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +25 -10
- package/bin/lib/codex-hooks.mjs +7 -1
- package/codex/command-skills/front-qa/SKILL.md +3 -1
- package/codex/hooks.json +15 -5
- package/codex/manifest.json +4 -0
- package/codex/skills/ralph/SKILL.md +272 -0
- package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
- package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
- package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
- package/codex/transform-matrix.md +5 -4
- package/commands/front-qa.md +3 -1
- package/hooks/ralph-continue-codex.mjs +59 -0
- package/package.json +1 -1
- package/skills/all-in-one/SKILL.md +22 -8
- package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
- package/skills/ralph-qa/references/run-folder.mjs +3 -3
- package/skills/ultra-chain/SKILL.md +11 -4
- package/skills/ultra-init/SKILL.md +9 -7
- package/skills/ultraqa/references/gate-evidence.mjs +3 -3
- package/skills/ultraqa/references/run-folder.mjs +3 -3
- package/skills/video-gen/SKILL.md +141 -0
- package/skills/video-gen/assets/example.json +36 -0
- package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
- package/skills/video-gen/scripts/vg-deck.mjs +72 -0
- package/skills/video-gen/scripts/vg-draw.mjs +231 -0
- package/skills/video-gen/scripts/vg-engine.mjs +74 -0
- package/skills/video-gen/scripts/vg-fx.mjs +106 -0
- package/skills/video-gen/scripts/vg-model.mjs +236 -0
- package/skills/video-gen/scripts/vg-render.mjs +151 -0
- package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
- package/skills/video-gen/scripts/video-gen.mjs +212 -0
- package/skills/video-gen/vendor/README.md +25 -0
- package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
- package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
- package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
- package/skills/visual-ralph/SKILL.md +9 -4
- package/skills/wbs-gen/SKILL.md +127 -0
- package/skills/wbs-gen/assets/example.json +58 -0
- package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
- package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
- package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
- package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
- package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
- package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
- package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
- package/skills/wide-pptx/SKILL.md +121 -0
- package/skills/wide-pptx/assets/example.json +63 -0
- package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
- package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
- package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
- package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
- package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
- package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
- package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
- package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
- package/skills/wide-pptx/vendor/README.md +12 -0
- package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
- package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// wp-pptx.mjs: draws the wide-pptx model as a 16:9 PPTX (13.333 x 7.5 in) with the bundled PptxGenJS. Text sets no
|
|
2
|
+
// font of its own; the theme carries the fonts: Latin Noto Sans, Hangul (and other East Asian text) Noto Sans KR.
|
|
3
|
+
// PptxGenJS writes the theme's Hangul font as 맑은 고딕 and chart text as Arial, so those parts are patched after
|
|
4
|
+
// writing.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import vm from "node:vm";
|
|
9
|
+
import { PALETTE } from "./wp-model.mjs";
|
|
10
|
+
|
|
11
|
+
const BUNDLE = join(dirname(fileURLToPath(import.meta.url)), "..", "vendor", "pptxgen.bundle.js");
|
|
12
|
+
export const LATIN = "Noto Sans";
|
|
13
|
+
export const KR = "Noto Sans KR";
|
|
14
|
+
const W = 13.333;
|
|
15
|
+
const M = 0.6;
|
|
16
|
+
const BODY = { x: 0.8, y: 1.55, w: W - 1.6, h: 5.15 };
|
|
17
|
+
|
|
18
|
+
let loaded = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Loads the browser bundle (UMD) in a context of its own: required as CommonJS it would export JSZip alone and fail
|
|
22
|
+
* on the JSZip global it expects. The context keeps its own built-ins (the bundle's core-js polyfills patch those,
|
|
23
|
+
* not this process's Uint8Array or Promise); it gets timers, text codecs and Buffer (for nodebuffer output), and no
|
|
24
|
+
* require. This is a loading context, not a security boundary: the bundle is trusted code pinned by its sha256.
|
|
25
|
+
* The bundle keeps counters across builds (chart and embedding numbers keep rising in one process); each file is
|
|
26
|
+
* still self-consistent.
|
|
27
|
+
*/
|
|
28
|
+
export function pptxgen() {
|
|
29
|
+
if (loaded) return loaded;
|
|
30
|
+
const sandbox = {
|
|
31
|
+
console, setTimeout, clearTimeout, setImmediate, clearImmediate, queueMicrotask, Buffer,
|
|
32
|
+
TextEncoder, TextDecoder, process: { versions: {}, release: {} },
|
|
33
|
+
};
|
|
34
|
+
sandbox.window = sandbox;
|
|
35
|
+
sandbox.self = sandbox;
|
|
36
|
+
sandbox.global = sandbox;
|
|
37
|
+
sandbox.globalThis = sandbox;
|
|
38
|
+
vm.createContext(sandbox);
|
|
39
|
+
vm.runInContext(readFileSync(BUNDLE, "utf8"), sandbox, { filename: "pptxgen.bundle.js" });
|
|
40
|
+
if (typeof sandbox.PptxGenJS !== "function" || typeof sandbox.JSZip !== "function") throw new Error("pptxgen.bundle.js 를 불러오지 못함");
|
|
41
|
+
loaded = { PptxGenJS: sandbox.PptxGenJS, JSZip: sandbox.JSZip };
|
|
42
|
+
return loaded;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Every text run says Korean, so spelling and the language bar match the text (fonts still follow the theme).
|
|
46
|
+
const LANG = "ko-KR";
|
|
47
|
+
|
|
48
|
+
function chrome(s, slide, model, n) {
|
|
49
|
+
const t = model.theme;
|
|
50
|
+
s.addText(slide.title, { x: M, y: 0.35, w: W - 2 * M, h: 0.85, fontSize: 28, bold: true, color: t.dark, valign: "middle", margin: 0, lang: LANG });
|
|
51
|
+
s.addShape("rect", { x: M, y: 1.22, w: 1.1, h: 0.06, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
52
|
+
footer(s, model, n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function footer(s, model, n, color) {
|
|
56
|
+
const c = color ?? model.theme.muted;
|
|
57
|
+
if (model.footer) s.addText(model.footer, { x: M, y: 7.0, w: 9, h: 0.3, fontSize: 10, color: c, margin: 0, lang: LANG });
|
|
58
|
+
s.addText(String(n), { x: W - M - 1, y: 7.0, w: 1, h: 0.3, fontSize: 10, color: c, align: "right", margin: 0, lang: LANG });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bullet paragraphs; a line break inside one bullet stays inside it (a soft break), as in the HTML deck. */
|
|
62
|
+
function runs(bullets, color) {
|
|
63
|
+
return bullets.flatMap((b) => {
|
|
64
|
+
const size = { fontSize: b.level === 2 ? 17 : 20, color, lang: LANG };
|
|
65
|
+
const para = { ...size, bullet: b.level === 2 ? { code: "25AA", indent: 18 } : true, indentLevel: b.level - 1, paraSpaceAfter: 8 };
|
|
66
|
+
const lines = b.text.split("\n");
|
|
67
|
+
return lines.map((line, j) => ({
|
|
68
|
+
text: line,
|
|
69
|
+
options: { ...(j ? { ...size, softBreakBefore: true } : para), breakLine: j === lines.length - 1 },
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DRAW = {
|
|
75
|
+
title(s, slide, model) {
|
|
76
|
+
const t = model.theme;
|
|
77
|
+
s.background = { color: t.primary };
|
|
78
|
+
s.addShape("rect", { x: 0.8, y: 2.25, w: 1.2, h: 0.08, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
79
|
+
s.addText(slide.title, { x: 0.8, y: 2.45, w: W - 1.6, h: 1.4, fontSize: 40, bold: true, color: "FFFFFF", valign: "top", margin: 0, lang: LANG });
|
|
80
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 0.8, y: 3.95, w: W - 1.6, h: 0.8, fontSize: 22, color: "D6EAF8", margin: 0, lang: LANG });
|
|
81
|
+
const by = [slide.presenter, slide.date].filter(Boolean).join(" | ");
|
|
82
|
+
if (by) s.addText(by, { x: 0.8, y: 5.7, w: W - 1.6, h: 0.5, fontSize: 16, color: "FFFFFF", margin: 0, lang: LANG });
|
|
83
|
+
},
|
|
84
|
+
section(s, slide, model, n) {
|
|
85
|
+
const t = model.theme;
|
|
86
|
+
s.background = { color: t.light };
|
|
87
|
+
s.addShape("rect", { x: 0, y: 0, w: 0.35, h: 7.5, fill: { color: t.accent }, line: { color: t.accent, width: 0 } });
|
|
88
|
+
s.addText(slide.title, { x: 1.0, y: 2.7, w: W - 2, h: 1.2, fontSize: 36, bold: true, color: t.dark, margin: 0, lang: LANG });
|
|
89
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 1.0, y: 3.9, w: W - 2, h: 0.7, fontSize: 20, color: t.muted, margin: 0, lang: LANG });
|
|
90
|
+
footer(s, model, n);
|
|
91
|
+
},
|
|
92
|
+
bullets(s, slide, model, n) {
|
|
93
|
+
chrome(s, slide, model, n);
|
|
94
|
+
if (slide.bullets.length) s.addText(runs(slide.bullets, model.theme.dark), { ...BODY, valign: "top", margin: 4 });
|
|
95
|
+
},
|
|
96
|
+
two(s, slide, model, n) {
|
|
97
|
+
chrome(s, slide, model, n);
|
|
98
|
+
const half = (BODY.w - 0.4) / 2;
|
|
99
|
+
[slide.left, slide.right].forEach((col, i) => {
|
|
100
|
+
const x = BODY.x + i * (half + 0.4);
|
|
101
|
+
if (col.heading) s.addText(col.heading, { x, y: BODY.y, w: half, h: 0.5, fontSize: 20, bold: true, color: model.theme.accent, margin: 0, lang: LANG });
|
|
102
|
+
const top = col.heading ? 0.6 : 0;
|
|
103
|
+
if (col.bullets.length) s.addText(runs(col.bullets, model.theme.dark), { x, y: BODY.y + top, w: half, h: BODY.h - top, valign: "top", margin: 4 });
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
table(s, slide, model, n) {
|
|
107
|
+
chrome(s, slide, model, n);
|
|
108
|
+
const t = model.theme;
|
|
109
|
+
const head = slide.table.header.map((h) => ({ text: h, options: { bold: true, color: "FFFFFF", fill: { color: t.primary }, lang: LANG } }));
|
|
110
|
+
const rows = slide.table.rows.map((r, i) => r.map((c) => ({ text: c, options: { color: t.dark, fill: { color: i % 2 ? "FFFFFF" : t.light }, lang: LANG } })));
|
|
111
|
+
const all = head.length ? [head, ...rows] : rows;
|
|
112
|
+
s.addTable(all, { x: BODY.x, y: BODY.y, w: BODY.w, fontSize: 14, border: { type: "solid", pt: 0.75, color: "D5D8DC" }, valign: "middle", autoPage: false });
|
|
113
|
+
caption(s, slide, model);
|
|
114
|
+
},
|
|
115
|
+
chart(s, slide, model, n, p) {
|
|
116
|
+
chrome(s, slide, model, n);
|
|
117
|
+
const c = slide.chart;
|
|
118
|
+
const data = c.series.map((x) => ({ name: x.name, labels: c.labels, values: x.values }));
|
|
119
|
+
// A colour for every point or series: past the list PptxGenJS picks one at random.
|
|
120
|
+
const colors = Array.from({ length: Math.max(c.labels.length, c.series.length, 1) }, (_, i) => PALETTE[i % PALETTE.length]);
|
|
121
|
+
const opts = {
|
|
122
|
+
x: BODY.x, y: BODY.y, w: BODY.w, h: BODY.h - 0.5, chartColors: colors, showLegend: c.series.length > 1 || c.kind === "pie",
|
|
123
|
+
legendPos: "b", legendFontSize: 12, catAxisLabelFontSize: 12, valAxisLabelFontSize: 12, barDir: "col", barGapWidthPct: 60,
|
|
124
|
+
valAxisTitle: c.unit || undefined, showValAxisTitle: !!c.unit, lineSize: 2, lineDataSymbolSize: 7,
|
|
125
|
+
showPercent: c.kind === "pie", showValue: false, dataLabelColor: "FFFFFF", dataLabelFontSize: 12,
|
|
126
|
+
};
|
|
127
|
+
s.addChart(p.ChartType[c.kind], data, opts);
|
|
128
|
+
caption(s, slide, model);
|
|
129
|
+
},
|
|
130
|
+
image(s, slide, model, n) {
|
|
131
|
+
chrome(s, slide, model, n);
|
|
132
|
+
const img = slide.image;
|
|
133
|
+
const box = { ...BODY, h: BODY.h - 0.5 };
|
|
134
|
+
const r = img.size && img.size.w && img.size.h ? img.size.w / img.size.h : box.w / box.h;
|
|
135
|
+
const w = Math.min(box.w, box.h * r);
|
|
136
|
+
const h = w / r;
|
|
137
|
+
const altText = slide.caption || slide.title;
|
|
138
|
+
s.addImage({ data: `${img.mime};base64,${img.data.toString("base64")}`, x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h, altText });
|
|
139
|
+
caption(s, slide, model);
|
|
140
|
+
},
|
|
141
|
+
quote(s, slide, model, n) {
|
|
142
|
+
const t = model.theme;
|
|
143
|
+
s.background = { color: t.light };
|
|
144
|
+
s.addText(`“${slide.quote}”`, { x: 1.2, y: 2.0, w: W - 2.4, h: 2.8, fontSize: 30, italic: true, color: t.dark, align: "center", valign: "middle", margin: 0, lang: LANG });
|
|
145
|
+
if (slide.by) s.addText(`- ${slide.by}`, { x: 1.2, y: 4.9, w: W - 2.4, h: 0.6, fontSize: 18, color: t.muted, align: "right", margin: 0, lang: LANG });
|
|
146
|
+
if (slide.title) s.addText(slide.title, { x: M, y: 0.35, w: W - 2 * M, h: 0.7, fontSize: 18, bold: true, color: t.accent, margin: 0, lang: LANG });
|
|
147
|
+
footer(s, model, n);
|
|
148
|
+
},
|
|
149
|
+
closing(s, slide, model) {
|
|
150
|
+
const t = model.theme;
|
|
151
|
+
s.background = { color: t.primary };
|
|
152
|
+
s.addText(slide.title, { x: 0.8, y: 2.7, w: W - 1.6, h: 1.3, fontSize: 40, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0, lang: LANG });
|
|
153
|
+
if (slide.subtitle) s.addText(slide.subtitle, { x: 0.8, y: 4.1, w: W - 1.6, h: 0.7, fontSize: 20, color: "D6EAF8", align: "center", margin: 0, lang: LANG });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function caption(s, slide, model) {
|
|
158
|
+
if (slide.caption) s.addText(slide.caption, { x: BODY.x, y: BODY.y + BODY.h - 0.4, w: BODY.w, h: 0.4, fontSize: 12, color: model.theme.muted, margin: 0, lang: LANG });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The theme fonts: Latin Noto Sans, East Asian and Hangul Noto Sans KR (major and minor font sets). */
|
|
162
|
+
export function patchTheme(xml) {
|
|
163
|
+
return xml
|
|
164
|
+
.replace(/<a:latin typeface="[^"]*"/g, `<a:latin typeface="${LATIN}"`)
|
|
165
|
+
.replace(/<a:ea typeface="[^"]*"/g, `<a:ea typeface="${KR}"`)
|
|
166
|
+
.replace(/<a:font script="Hang" typeface="[^"]*"/g, `<a:font script="Hang" typeface="${KR}"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Chart text: PptxGenJS writes <a:latin typeface="Arial"/> alone; make it Noto Sans and add the Hangul font. */
|
|
170
|
+
export function patchChart(xml) {
|
|
171
|
+
return xml.replace(/<a:latin typeface="[^"]*"\/>(?!<a:ea )/g, `<a:latin typeface="${LATIN}"/><a:ea typeface="${KR}"/>`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** PptxGenJS 4.0.1 writes the chart data table as ref="A1:C6'" (a stray quote); Excel wants a plain range. */
|
|
175
|
+
export const patchTable = (xml) => xml.replace(/ ref="([A-Z]+\d+:[A-Z]+\d+)'"/g, ' ref="$1"');
|
|
176
|
+
|
|
177
|
+
async function patchEmbedded(JSZip, bytes) {
|
|
178
|
+
const inner = await JSZip.loadAsync(bytes);
|
|
179
|
+
for (const f of Object.keys(inner.files).filter((x) => /^xl\/tables\/table\d+\.xml$/.test(x))) {
|
|
180
|
+
inner.file(f, patchTable(await inner.file(f).async("string")));
|
|
181
|
+
}
|
|
182
|
+
return inner.generateAsync({ type: "uint8array", compression: "DEFLATE" });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PptxGenJS ends every paragraph with <a:endParaRPr lang="en-US">; text typed there later should be Korean too. */
|
|
186
|
+
export const patchSlide = (xml) => xml.replace(/<a:endParaRPr lang="en-US"/g, '<a:endParaRPr lang="ko-KR"');
|
|
187
|
+
|
|
188
|
+
const PATCHES = [
|
|
189
|
+
[/^ppt\/theme\/theme\d+\.xml$/, "string", patchTheme],
|
|
190
|
+
[/^ppt\/slides\/slide\d+\.xml$/, "string", patchSlide],
|
|
191
|
+
[/^ppt\/charts\/chart\d+\.xml$/, "string", patchChart],
|
|
192
|
+
[/^ppt\/embeddings\/[^/]+\.xlsx$/, "uint8array", null],
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
/** Returns the PPTX bytes for a model. */
|
|
196
|
+
export async function toPptx(model) {
|
|
197
|
+
const { PptxGenJS, JSZip } = pptxgen();
|
|
198
|
+
const p = new PptxGenJS();
|
|
199
|
+
p.layout = "LAYOUT_WIDE";
|
|
200
|
+
p.title = model.title;
|
|
201
|
+
p.author = model.author || "wide-pptx";
|
|
202
|
+
p.company = "";
|
|
203
|
+
p.subject = model.title;
|
|
204
|
+
p.theme = { headFontFace: LATIN, bodyFontFace: LATIN };
|
|
205
|
+
model.slides.forEach((slide, i) => {
|
|
206
|
+
const s = p.addSlide();
|
|
207
|
+
DRAW[slide.type](s, slide, model, i + 1, p);
|
|
208
|
+
if (slide.notes) s.addNotes(slide.notes);
|
|
209
|
+
});
|
|
210
|
+
const raw = await p.write({ outputType: "nodebuffer", compression: true });
|
|
211
|
+
const zip = await JSZip.loadAsync(raw);
|
|
212
|
+
for (const f of Object.keys(zip.files)) {
|
|
213
|
+
const fix = PATCHES.find(([re]) => re.test(f));
|
|
214
|
+
if (!fix) continue;
|
|
215
|
+
const body = await zip.file(f).async(fix[1]);
|
|
216
|
+
zip.file(f, fix[2] ? fix[2](body) : await patchEmbedded(JSZip, body));
|
|
217
|
+
}
|
|
218
|
+
return Buffer.from(await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
|
|
219
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// wp-svg.mjs: bar, line and pie charts as inline SVG for the HTML deck (no library). Values come from wp-model.mjs
|
|
2
|
+
// as finite numbers; text is escaped; colours are 6-digit hex.
|
|
3
|
+
import { PALETTE } from "./wp-model.mjs";
|
|
4
|
+
|
|
5
|
+
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
6
|
+
const f = (n) => Math.round(n * 10) / 10;
|
|
7
|
+
|
|
8
|
+
/** A "nice" axis: [min, max, step] covering the values with about 5 steps. */
|
|
9
|
+
export function niceAxis(values) {
|
|
10
|
+
let lo = values.reduce((a, b) => Math.min(a, b), 0);
|
|
11
|
+
let hi = values.reduce((a, b) => Math.max(a, b), 0);
|
|
12
|
+
if (lo === hi) hi = lo + 1;
|
|
13
|
+
const raw = (hi - lo) / 5;
|
|
14
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
15
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw);
|
|
16
|
+
lo = Math.floor(lo / step) * step;
|
|
17
|
+
hi = Math.ceil(hi / step) * step;
|
|
18
|
+
return [lo, hi, step];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Decimal places a step needs: 0.25 needs 2, 0.02 needs 2, 5 needs 0. */
|
|
22
|
+
export function decimalsOf(step) {
|
|
23
|
+
for (let d = 0; d < 10; d++) if (Math.abs(Math.round(step * 10 ** d) - step * 10 ** d) < 1e-9) return d;
|
|
24
|
+
return 10;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The tick labels of an axis: lo + k * step, each with the decimals the step needs. */
|
|
28
|
+
export function tickLabels([lo, hi, step]) {
|
|
29
|
+
const dec = decimalsOf(step);
|
|
30
|
+
const n = Math.round((hi - lo) / step);
|
|
31
|
+
return Array.from({ length: n + 1 }, (_, k) => {
|
|
32
|
+
const v = lo + k * step;
|
|
33
|
+
return { v, text: Math.abs(v) >= 1000 ? v.toLocaleString("en-US", { maximumFractionDigits: dec }) : v.toFixed(dec) };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function legend(names, w, y) {
|
|
38
|
+
if (names.length < 2) return "";
|
|
39
|
+
const item = 150;
|
|
40
|
+
const x0 = (w - item * names.length) / 2;
|
|
41
|
+
return names.map((n, i) => `<rect x="${f(x0 + i * item)}" y="${y - 10}" width="14" height="14" fill="#${PALETTE[i % PALETTE.length]}"/>`
|
|
42
|
+
+ `<text x="${f(x0 + i * item + 20)}" y="${y + 2}" font-size="16">${esc(n)}</text>`).join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function axes(c, box) {
|
|
46
|
+
const axis = niceAxis(c.series.flatMap((s) => s.values));
|
|
47
|
+
const [lo, hi] = axis;
|
|
48
|
+
const y = (v) => box.y + box.h - ((v - lo) / (hi - lo)) * box.h;
|
|
49
|
+
const grid = tickLabels(axis).map((t) => `<line x1="${box.x}" x2="${box.x + box.w}" y1="${f(y(t.v))}" y2="${f(y(t.v))}" stroke="#D5D8DC"/>`
|
|
50
|
+
+ `<text x="${box.x - 10}" y="${f(y(t.v)) + 5}" font-size="14" text-anchor="end">${esc(t.text)}</text>`).join("");
|
|
51
|
+
const unit = c.unit ? `<text x="${box.x}" y="${box.y - 14}" font-size="14">${esc(c.unit)}</text>` : "";
|
|
52
|
+
return { y, svg: grid + unit };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function labelsAt(c, box, slot) {
|
|
56
|
+
return c.labels.map((l, i) => `<text x="${f(box.x + slot * (i + 0.5))}" y="${box.y + box.h + 24}" font-size="14" text-anchor="middle">${esc(l)}</text>`).join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bar(c, w, h) {
|
|
60
|
+
const box = { x: 80, y: 30, w: w - 110, h: h - 100 };
|
|
61
|
+
const { y, svg } = axes(c, box);
|
|
62
|
+
const slot = box.w / c.labels.length;
|
|
63
|
+
const bw = (slot * 0.7) / c.series.length;
|
|
64
|
+
const bars = c.series.map((s, k) => s.values.map((v, i) => {
|
|
65
|
+
const x = box.x + slot * i + slot * 0.15 + bw * k;
|
|
66
|
+
const top = Math.min(y(v), y(0));
|
|
67
|
+
return `<rect x="${f(x)}" y="${f(top)}" width="${f(bw)}" height="${f(Math.abs(y(v) - y(0)))}" fill="#${PALETTE[k % PALETTE.length]}"/>`;
|
|
68
|
+
}).join("")).join("");
|
|
69
|
+
return svg + bars + labelsAt(c, box, slot) + legend(c.series.map((s) => s.name), w, h - 16);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function line(c, w, h) {
|
|
73
|
+
const box = { x: 80, y: 30, w: w - 110, h: h - 100 };
|
|
74
|
+
const { y, svg } = axes(c, box);
|
|
75
|
+
const slot = box.w / c.labels.length;
|
|
76
|
+
const lines = c.series.map((s, k) => {
|
|
77
|
+
const color = PALETTE[k % PALETTE.length];
|
|
78
|
+
const pts = s.values.map((v, i) => [f(box.x + slot * (i + 0.5)), f(y(v))]);
|
|
79
|
+
return `<polyline fill="none" stroke="#${color}" stroke-width="3" points="${pts.map((p) => p.join(",")).join(" ")}"/>`
|
|
80
|
+
+ pts.map((p) => `<circle cx="${p[0]}" cy="${p[1]}" r="5" fill="#${color}"/>`).join("");
|
|
81
|
+
}).join("");
|
|
82
|
+
return svg + lines + labelsAt(c, box, slot) + legend(c.series.map((s) => s.name), w, h - 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pie(c, w, h) {
|
|
86
|
+
const values = c.series[0].values;
|
|
87
|
+
const total = values.reduce((a, b) => a + b, 0) || 1;
|
|
88
|
+
const r = Math.min(w * 0.3, (h - 70) / 2);
|
|
89
|
+
const cx = w * 0.38;
|
|
90
|
+
const cy = (h - 40) / 2;
|
|
91
|
+
let a = -Math.PI / 2;
|
|
92
|
+
const parts = values.map((v, i) => {
|
|
93
|
+
const b = a + (v / total) * 2 * Math.PI;
|
|
94
|
+
const large = b - a > Math.PI ? 1 : 0;
|
|
95
|
+
const p = (t) => `${f(cx + r * Math.cos(t))} ${f(cy + r * Math.sin(t))}`;
|
|
96
|
+
const d = v >= total ? `M ${cx - r} ${cy} a ${r} ${r} 0 1 0 ${2 * r} 0 a ${r} ${r} 0 1 0 ${-2 * r} 0` : `M ${cx} ${cy} L ${p(a)} A ${r} ${r} 0 ${large} 1 ${p(b)} Z`;
|
|
97
|
+
a = b;
|
|
98
|
+
return `<path d="${d}" fill="#${PALETTE[i % PALETTE.length]}" stroke="#FFFFFF" stroke-width="2"/>`;
|
|
99
|
+
}).join("");
|
|
100
|
+
const keys = c.labels.map((l, i) => {
|
|
101
|
+
const ky = 40 + i * 30;
|
|
102
|
+
const pct = f((values[i] / total) * 100);
|
|
103
|
+
return `<rect x="${f(w * 0.7)}" y="${ky - 12}" width="16" height="16" fill="#${PALETTE[i % PALETTE.length]}"/>`
|
|
104
|
+
+ `<text x="${f(w * 0.7 + 24)}" y="${ky + 2}" font-size="16">${esc(l)} ${pct}%</text>`;
|
|
105
|
+
}).join("");
|
|
106
|
+
return parts + keys;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const KINDS = { bar, line, pie };
|
|
110
|
+
|
|
111
|
+
/** An SVG element w x h px for a model chart. */
|
|
112
|
+
export function chartSvg(c, w, h) {
|
|
113
|
+
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" fill="#1B2631" font-family="inherit">${KINDS[c.kind](c, w, h)}</svg>`;
|
|
114
|
+
}
|