slidemind 0.3.19 → 0.4.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/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/engine-slides.mjs +1 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// One read chart as a native `<Chart>`: its data, the design's chart colours by
|
|
2
|
+
// what each mark means, and the labels the image shows — on the chart itself,
|
|
3
|
+
// in the number format it sets them in. The data and the labels come from the
|
|
4
|
+
// reader's spec; the colours from the design bundle (design-tokens.mjs
|
|
5
|
+
// `chartPalette`), never from the painting.
|
|
6
|
+
import { chartPalette, contrastRatio } from "./design-tokens.mjs";
|
|
7
|
+
import { waterfallAsStacked } from "./concept-geometry.mjs";
|
|
8
|
+
import { holeSizeFor, ringRect } from "./chart-hole.mjs";
|
|
9
|
+
import { figureOf, labelKey, onlyFigure } from "./chart-labels.mjs";
|
|
10
|
+
|
|
11
|
+
const q = (s) => JSON.stringify(String(s));
|
|
12
|
+
|
|
13
|
+
// A figure in a label: "95.4%", "€1.4m", "1,200", "+3.7pp", "95,4 %".
|
|
14
|
+
const FIGURE = /^(.*?)([-+−]?)(\d[\d.,\s]*\d|\d)(.*)$/s;
|
|
15
|
+
const num = (t) => {
|
|
16
|
+
const m = /-?\d+(?:[.,]\d+)?/.exec(String(t).replace(/(\d),(\d{3})/g, "$1$2"));
|
|
17
|
+
return m ? Number(m[0].replace(",", ".")) : null;
|
|
18
|
+
};
|
|
19
|
+
const near = (a, b) => a != null && b != null && Math.abs(a - b) <= Math.max(0.05, Math.abs(b) * 0.01);
|
|
20
|
+
|
|
21
|
+
// A figure's digits, each way its separators can be read: "1,200" is 1200
|
|
22
|
+
// grouped or 1.2 with a decimal comma; "95.4" is 95.4.
|
|
23
|
+
function readings(digits) {
|
|
24
|
+
const plain = digits.replace(/\s/g, "");
|
|
25
|
+
const out = [{ read: Number(plain.replace(/[.,]/g, "")), decimals: 0, grouped: /[.,]/.test(plain) }];
|
|
26
|
+
for (const sep of [".", ","]) {
|
|
27
|
+
const at = plain.lastIndexOf(sep);
|
|
28
|
+
const decimals = plain.length - at - 1;
|
|
29
|
+
if (at < 0 || decimals < 1 || decimals > 3) continue;
|
|
30
|
+
out.push({
|
|
31
|
+
read: Number(`${plain.slice(0, at).replace(/[.,]/g, "")}.${plain.slice(at + 1)}`),
|
|
32
|
+
decimals,
|
|
33
|
+
grouped: /[.,]/.test(plain.slice(0, at)),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A label carries a value when its figure IS the value at the label's own
|
|
40
|
+
// precision: "95.4%" carries 95.4, the axis tick "96%" beside it does not.
|
|
41
|
+
const carries = (reading, value) => Math.abs(reading.read - Math.abs(value)) <= 0.5 * 10 ** -reading.decimals + 1e-9;
|
|
42
|
+
|
|
43
|
+
const literal = (s) => (s ? `"${s.replace(/"/g, "")}"` : "");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The number format the image's value labels are set in — decimals, thousands,
|
|
47
|
+
* a sign, a unit before or after the figure — read from the labels that carry
|
|
48
|
+
* the chart's own values: "95.4%" -> `0.0"%"`, "€1.4m" -> `"€"0.0"m"`,
|
|
49
|
+
* "+3.7pp" -> `+0.0"pp";-0.0"pp";0.0"pp"`. The decimal separator is the
|
|
50
|
+
* viewer's (PowerPoint localises it), so a German PowerPoint shows "95,4%".
|
|
51
|
+
* Null when no label carries a value, the labels disagree, or they say no more
|
|
52
|
+
* than General does.
|
|
53
|
+
*/
|
|
54
|
+
export function labelNumberFormat(values, seen) {
|
|
55
|
+
const forms = [];
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
for (const label of seen) {
|
|
58
|
+
const m = FIGURE.exec(String(label).trim());
|
|
59
|
+
const reading = m && readings(m[3]).find((r) => carries(r, value));
|
|
60
|
+
if (!reading) continue;
|
|
61
|
+
forms.push({ pre: m[1].trim(), sign: m[2] === "+", decimals: reading.decimals, grouped: reading.grouped, post: m[4].trim() });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!forms.length) return null;
|
|
66
|
+
const [first] = forms;
|
|
67
|
+
if (forms.some((f) => f.pre !== first.pre || f.post !== first.post)) return null;
|
|
68
|
+
const decimals = Math.max(...forms.map((f) => f.decimals));
|
|
69
|
+
const grouped = forms.some((f) => f.grouped);
|
|
70
|
+
const signed = forms.some((f) => f.sign);
|
|
71
|
+
if (!first.pre && !first.post && !decimals && !grouped && !signed) return null;
|
|
72
|
+
const body = `${grouped ? "#,##0" : "0"}${decimals ? `.${"0".repeat(decimals)}` : ""}`;
|
|
73
|
+
const part = (sign) => `${sign}${literal(first.pre)}${body}${literal(first.post)}`;
|
|
74
|
+
return signed ? `${part("+")};${part("-")};${part("")}` : part("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The ground a chart is drawn on: the nearest container holding it that the
|
|
79
|
+
* reader saw painted, else the slide's own (the design's light or dark ground).
|
|
80
|
+
*/
|
|
81
|
+
function groundOf(e, spec, tokens) {
|
|
82
|
+
const byId = new Map((spec.elements ?? []).map((o) => [o.id, o]));
|
|
83
|
+
for (let p = byId.get(e.parent); p; p = byId.get(p.parent)) {
|
|
84
|
+
if (p.fill && p.fill !== "none" && p.fillHex) return p.fillHex;
|
|
85
|
+
}
|
|
86
|
+
const name = spec.background === "dark" ? "--theme-tx1" : "--theme-bg1";
|
|
87
|
+
return tokens.find((t) => t.name === name)?.hex ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A filled mark the ground swallows is no colour for this chart.
|
|
91
|
+
const SEEN_ON_GROUND = 1.25;
|
|
92
|
+
const QUIET_ON_GROUND = 1.1;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The design's chart palette as this chart's ground shows it: a slot the ground
|
|
96
|
+
* swallows (a pale fill on a pale card, a dark one on an ink panel) leaves the
|
|
97
|
+
* cycle, and a role painted in it takes the first colour that stays. Null when
|
|
98
|
+
* the bundle declares no theme: the master then paints its own series.
|
|
99
|
+
*/
|
|
100
|
+
function paletteOn(e, spec, tokens) {
|
|
101
|
+
const palette = chartPalette(tokens);
|
|
102
|
+
if (!palette) return null;
|
|
103
|
+
const ground = groundOf(e, spec, tokens);
|
|
104
|
+
const hexOf = (p) => (p?.rgb ? `#${p.rgb}` : tokens.find((t) => t.name === `--theme-${p?.scheme}`)?.hex ?? null);
|
|
105
|
+
const seen = (p) => { const hex = hexOf(p); return !ground || !hex || (contrastRatio(hex, ground) ?? 0) >= SEEN_ON_GROUND; };
|
|
106
|
+
const kept = palette.series.filter(seen);
|
|
107
|
+
const series = kept.length ? kept : palette.series;
|
|
108
|
+
const roles = Object.fromEntries(Object.entries(palette.roles).map(([r, p]) => [r, p && seen(p) ? p : series[0]]));
|
|
109
|
+
// A mark set back only needs to part from the ground, not carry a series.
|
|
110
|
+
const quietSeen = (p) => { const hex = hexOf(p); return !ground || !hex || (contrastRatio(hex, ground) ?? 0) >= QUIET_ON_GROUND; };
|
|
111
|
+
return { series, roles, quiet: (palette.quiet ?? []).filter(quietSeen) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One paint per series (or point): the colour its role names where the reader
|
|
116
|
+
* judged one, else the next colour of the palette that no role on this chart
|
|
117
|
+
* has taken — so a highlighted bar never shares its colour with a plain one.
|
|
118
|
+
*/
|
|
119
|
+
function paintsFor(roles, count, palette) {
|
|
120
|
+
const key = (p) => JSON.stringify(p);
|
|
121
|
+
const named = (r) => r && r !== "series" && palette.roles[r];
|
|
122
|
+
const taken = new Set(roles.filter(named).map((r) => key(palette.roles[r])));
|
|
123
|
+
// Several marks set back take the design's quiet neutrals one by one, never
|
|
124
|
+
// one grey for all (x113 QBR 3: three slices beside the highlighted lane all
|
|
125
|
+
// context grey); a colour another role here has taken stays that role's.
|
|
126
|
+
const contexts = roles.slice(0, count).filter((r) => r === "context").length;
|
|
127
|
+
const others = new Set(roles.filter((r) => named(r) && r !== "context").map((r) => key(palette.roles[r])));
|
|
128
|
+
const quiet = contexts > 1 ? (palette.quiet ?? []).filter((p) => p && !others.has(key(p))) : [];
|
|
129
|
+
for (const p of quiet.slice(0, contexts)) taken.add(key(p));
|
|
130
|
+
// The series palette first, then the role colours no mark here has taken:
|
|
131
|
+
// more slices than the palette holds still read apart before it repeats.
|
|
132
|
+
const cycle = [];
|
|
133
|
+
for (const p of [...palette.series, ...Object.values(palette.roles)]) {
|
|
134
|
+
if (p && !taken.has(key(p)) && !cycle.some((q) => key(q) === key(p))) cycle.push(p);
|
|
135
|
+
}
|
|
136
|
+
if (!cycle.length) cycle.push(...palette.series);
|
|
137
|
+
let k = 0;
|
|
138
|
+
let c = 0;
|
|
139
|
+
return Array.from({ length: count }, (_, i) => {
|
|
140
|
+
if (roles[i] === "context" && quiet.length) return c < quiet.length ? quiet[c++] : cycle[k++ % cycle.length];
|
|
141
|
+
return named(roles[i]) ? palette.roles[roles[i]] : cycle[k++ % cycle.length];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// How a mark is drawn, as the reader read it (`chart.draws`), never its colour:
|
|
146
|
+
// a plan or target bar as an empty dashed outline, a target as a dashed line.
|
|
147
|
+
const DASH = { dashed: "dash", dotted: "sysDot" };
|
|
148
|
+
const hollow = (draw) => draw === "outlined" || draw === "dashed" || draw === "dotted";
|
|
149
|
+
|
|
150
|
+
/** A filled mark's style (a bar, a slice, a point of either) in `paint`, drawn as read. */
|
|
151
|
+
function markStyle(paint, draw) {
|
|
152
|
+
if (!hollow(draw)) return paint ? { fill: paint } : {};
|
|
153
|
+
return { fill: "none", line: { ...(paint ? { color: paint } : {}), widthPt: 1.25, ...(DASH[draw] ? { dash: DASH[draw] } : {}) } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A line series' style in `paint`, drawn as read: a dashed or dotted line marks no points. */
|
|
157
|
+
function lineStyle(paint, draw) {
|
|
158
|
+
const dash = DASH[draw];
|
|
159
|
+
return { line: { ...(paint ? { color: paint } : {}), ...(dash ? { dash } : {}) }, ...(dash ? { marker: { symbol: "none" } } : {}) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The value axis as the image paints it: the axis the reader's tick labels
|
|
164
|
+
* spell (folded back by chart-labels.mjs: visible, its range, its step, its
|
|
165
|
+
* format), else the start the reader read where the marks visibly do not stand
|
|
166
|
+
* on 0 (`axisStart`, a cut axis — x113 QBR 2: ±1pp steps beside 19% totals
|
|
167
|
+
* drawn as slivers) — kept only below every mark, never cutting one off.
|
|
168
|
+
* Null: the axis stays as the chart's defaults draw it.
|
|
169
|
+
*/
|
|
170
|
+
function valueAxisOf(c, lowest, notes, id) {
|
|
171
|
+
const axis = c.folded?.axis;
|
|
172
|
+
if (axis && Number.isFinite(axis.min) && Number.isFinite(axis.max) && axis.max > axis.min) {
|
|
173
|
+
return { visible: true, majorGridlines: false, min: axis.min, max: axis.max, ...(axis.step > 0 ? { majorUnit: axis.step } : {}), ...(axis.format ? { numberFormat: axis.format } : {}) };
|
|
174
|
+
}
|
|
175
|
+
const start = typeof c.axisStart === "number" && Number.isFinite(c.axisStart) ? c.axisStart : null;
|
|
176
|
+
if (start == null || start === 0) return null;
|
|
177
|
+
if (!(lowest > start)) {
|
|
178
|
+
notes.push(`chart ${id}: the reader read the value axis starting at ${start}, but a mark ends at ${lowest} — kept on 0, the axis never cuts a mark off`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return { visible: false, majorGridlines: false, min: start };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const LEGEND_POS = { top: "t", bottom: "b", left: "l", right: "r" };
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The chart's legend as the image draws it: where the reader saw one (or where
|
|
188
|
+
* the chart's own legend rows were drawn as separate texts and folded back,
|
|
189
|
+
* chart-labels.mjs), at the side it names. A spec that says nothing: a legend
|
|
190
|
+
* for more than one series, and for a pie whose slices the image names.
|
|
191
|
+
*/
|
|
192
|
+
function legendOf(c, type, seriesCount, small, categories = []) {
|
|
193
|
+
const declared = typeof c.legend === "string" ? c.legend : null;
|
|
194
|
+
// A pie's legend names its slices: shown where the image names them (x111
|
|
195
|
+
// QBR 3: the doughnut's slice names were read, no legend was built, and the
|
|
196
|
+
// agent typed one beside it as four text boxes).
|
|
197
|
+
const sliced = ["pie", "doughnut"].includes(type);
|
|
198
|
+
const named = sliced && (c.labels ?? []).some((t) => categories.some((cat) => cat && String(t).trim().toLowerCase() === String(cat).trim().toLowerCase()));
|
|
199
|
+
const show = c.folded?.legend === true || (!small && (declared ? declared !== "none" : seriesCount > 1 || named));
|
|
200
|
+
return { show, pos: LEGEND_POS[declared] ?? LEGEND_POS[c.folded?.side] ?? (sliced ? "r" : "b") };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The painted label that carries `value` at its own precision, or null.
|
|
204
|
+
function labelCarrying(value, seen) {
|
|
205
|
+
for (const label of seen) {
|
|
206
|
+
const m = FIGURE.exec(String(label).trim());
|
|
207
|
+
if (m && readings(m[3]).some((r) => carries(r, value))) return m;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A read waterfall as its stacked-column construction (`waterfallAsStacked`):
|
|
214
|
+
* the bars the reader named stand on the axis, every step starts where the bar
|
|
215
|
+
* before it ends; the bars the image labels are labelled — a step with its
|
|
216
|
+
* signed change just above the bar, a total inside its end — in the image's own
|
|
217
|
+
* number format; connector lines only where the image draws them. Figures that
|
|
218
|
+
* do not bridge are built as read and reported in `notes`.
|
|
219
|
+
*/
|
|
220
|
+
function waterfallJsx(e, spec, tokens, data, base, notes) {
|
|
221
|
+
const c = e.chart;
|
|
222
|
+
const values = data.series[0].values;
|
|
223
|
+
const small = e.w < 180 || e.h < 110;
|
|
224
|
+
const isCategory = (t) => data.categories.some((cat) => cat && String(cat).trim().toLowerCase() === t.trim().toLowerCase().replace(/\s+/g, " "));
|
|
225
|
+
const seen = [...(Array.isArray(c.labels) ? c.labels : []), ...(c.folded?.texts ?? [])].map(String).filter((t) => !isCategory(t));
|
|
226
|
+
// A small chart carries the labels its image paints on it (folded back), and
|
|
227
|
+
// no others.
|
|
228
|
+
const labelled = small && c.folded?.values !== true ? [] : values.map((v, i) => (labelCarrying(v, seen) ? i : -1)).filter((i) => i >= 0);
|
|
229
|
+
const w = waterfallAsStacked(data, { labelled });
|
|
230
|
+
const totals = new Set(w.totals);
|
|
231
|
+
const name = (i) => JSON.stringify(String(data.categories[i] ?? i + 1).replace(/\s+/g, " ").trim());
|
|
232
|
+
for (const m of w.mismatches) {
|
|
233
|
+
notes.push(`chart ${e.id}: the steps before total ${name(m.index)} end at ${m.running}, but that total reads ${m.total} — the figures do not bridge; check them against the approved plan (built as read, not corrected)`);
|
|
234
|
+
}
|
|
235
|
+
if (!w.declared) {
|
|
236
|
+
const later = w.totals.filter((i) => i > 0).map(name);
|
|
237
|
+
notes.push(`chart ${e.id}: the reader named no waterfall totals; the first bar stands on the axis${later.length ? `, and ${later.join(", ")} because ${later.length === 1 ? "it equals" : "each equals"} the running sum` : ""} — check which bars the concept draws standing on the axis`);
|
|
238
|
+
}
|
|
239
|
+
// Each sign in the format the image sets it in; a fall's bar is drawn as a
|
|
240
|
+
// height, so its label carries the painted minus as a literal.
|
|
241
|
+
const rises = values.filter((v, i) => !totals.has(i) && v >= 0);
|
|
242
|
+
const falls = values.filter((v, i) => !totals.has(i) && v < 0);
|
|
243
|
+
const riseFormat = labelNumberFormat(rises, seen);
|
|
244
|
+
const fallSign = falls.map((v) => labelCarrying(v, seen)?.[2]).find((s) => s === "-" || s === "−");
|
|
245
|
+
const fallBody = labelNumberFormat(falls.map(Math.abs), seen.map((t) => t.replace(/^(\D*?)[-−]/, "$1"))) ?? "General";
|
|
246
|
+
const fallFormat = fallSign ? `"${fallSign}"${fallBody}` : fallBody;
|
|
247
|
+
const totalFormat = labelNumberFormat(values.filter((_, i) => totals.has(i)), seen);
|
|
248
|
+
const label = (format, position) => ({ show: true, value: true, position, ...(format ? { numberFormat: format } : {}) });
|
|
249
|
+
// Each bar in its role's colour: what the reader judged it means, else a total
|
|
250
|
+
// is the highlight and a step a rise or a fall.
|
|
251
|
+
const palette = paletteOn(e, spec, tokens);
|
|
252
|
+
const roles = values.map((v, i) => (Array.isArray(c.roles) && c.roles[i] && c.roles[i] !== "series" ? c.roles[i] : totals.has(i) ? "highlight" : v >= 0 ? "positive" : "negative"));
|
|
253
|
+
const paints = palette ? paintsFor(roles, values.length, palette) : [];
|
|
254
|
+
const draws = Array.isArray(c.draws) ? c.draws : [];
|
|
255
|
+
const fill = (i) => markStyle(paints[i], draws[i]);
|
|
256
|
+
const style = w.data.series.map(() => ({ labels: { show: false } }));
|
|
257
|
+
style[w.index.base] = { fill: "none", labels: { show: false } };
|
|
258
|
+
style[w.index.bars] = {
|
|
259
|
+
labels: { show: false },
|
|
260
|
+
points: values
|
|
261
|
+
.map((_, i) => ({ idx: i, ...fill(i), ...(totals.has(i) && labelled.includes(i) ? { label: label(totalFormat, "inEnd") } : {}) }))
|
|
262
|
+
.filter((p) => Object.keys(p).length > 1),
|
|
263
|
+
};
|
|
264
|
+
if (w.index.below != null) {
|
|
265
|
+
style[w.index.below] = { labels: { show: false }, points: values.map((_, i) => ({ idx: i, ...fill(i) })).filter((p, i) => w.data.series[w.index.below].values[i] != null && Object.keys(p).length > 1) };
|
|
266
|
+
}
|
|
267
|
+
if (w.index.change != null) {
|
|
268
|
+
style[w.index.change] = {
|
|
269
|
+
fill: "none",
|
|
270
|
+
labels: { show: false },
|
|
271
|
+
points: labelled.filter((i) => !totals.has(i)).map((i) => ({ idx: i, label: label(values[i] >= 0 ? riseFormat : fallFormat, "inBase") })),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const connector = palette?.roles.reference ?? null;
|
|
275
|
+
for (const k of w.index.connectors) {
|
|
276
|
+
style[k] = { line: { ...(connector ? { color: connector } : {}), widthPt: 0.75, dash: "dash" }, marker: { symbol: "none" }, labels: { show: false } };
|
|
277
|
+
}
|
|
278
|
+
const showCategories = c.folded?.categories === true || (!small && (!Array.isArray(c.labels) || data.categories.some((cat) => cat && c.labels.some((t) => String(t).trim().toLowerCase().replace(/\s+/g, " ") === String(cat).trim().toLowerCase().replace(/\s+/g, " ")))));
|
|
279
|
+
// Where each bar's ink ends lowest: a total from its top down to the axis, a
|
|
280
|
+
// step between the running value before and after it.
|
|
281
|
+
let running = 0;
|
|
282
|
+
let lowest = Infinity;
|
|
283
|
+
values.forEach((v, i) => {
|
|
284
|
+
if (totals.has(i)) { lowest = Math.min(lowest, v); running = v; } else { lowest = Math.min(lowest, running, running + v); running += v; }
|
|
285
|
+
});
|
|
286
|
+
const value = valueAxisOf(c, lowest, notes, e.id) ?? { visible: false, majorGridlines: false };
|
|
287
|
+
const axes = ` axes={${JSON.stringify({ category: { visible: showCategories, majorGridlines: false }, value })}}`;
|
|
288
|
+
return `<B ${base} h={${e.h}}><Chart type="column" data={${JSON.stringify(w.data)}}${axes} labels={{ show: false }} legend={{ show: false, pos: "b" }} layout={${JSON.stringify({ overlap: 100, gapWidth: 60 })}} dispBlanksAs="gap" seriesStyle={${JSON.stringify(style)}} style={{ width: ${e.w}, height: ${e.h} }} /></B>`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The `<B>` holding one read chart with its native `<Chart>`. `data` is the
|
|
293
|
+
* renderable payload (`nativeChartData`); `base` the element's id and position
|
|
294
|
+
* attributes; `notes` collects what the agent must check.
|
|
295
|
+
*/
|
|
296
|
+
export function chartJsx(e, spec, tokens, data, base, notes = []) {
|
|
297
|
+
const c = e.chart;
|
|
298
|
+
const type = c.type;
|
|
299
|
+
if (type === "waterfall") return waterfallJsx(e, spec, tokens, data, base, notes);
|
|
300
|
+
// A thumbnail-sized chart (x28 cloud 8: four charts under 180px) cannot
|
|
301
|
+
// carry value labels and a legend without overlapping itself — unless its
|
|
302
|
+
// image paints them there (x113 QBR 1: eight 107px KPI charts, each with its
|
|
303
|
+
// values and quarters), folded back as the chart's own.
|
|
304
|
+
const small = e.w < 180 || e.h < 110;
|
|
305
|
+
const folded = c.folded ?? {};
|
|
306
|
+
const sliced = ["pie", "doughnut"].includes(type);
|
|
307
|
+
// A pie's legend rows that carry each slice's value ("DE–PL lane 21%") name
|
|
308
|
+
// the slices so: the legend lists name and value as the image does.
|
|
309
|
+
const legendRows = sliced && folded.legend ? legendWithValues(data, folded.texts ?? []) : null;
|
|
310
|
+
if (legendRows) data = { ...data, categories: legendRows };
|
|
311
|
+
// The design's colours by what each series (each slice of a pie; each bar of a
|
|
312
|
+
// single series whose bars mean different things) stands for.
|
|
313
|
+
const palette = paletteOn(e, spec, tokens);
|
|
314
|
+
const roles = Array.isArray(c.roles) ? c.roles : [];
|
|
315
|
+
const draws = Array.isArray(c.draws) ? c.draws : [];
|
|
316
|
+
const perPoint = sliced
|
|
317
|
+
|| (data.series.length === 1 && data.categories.length > 1 && (
|
|
318
|
+
(roles.length === data.categories.length && new Set(roles).size > 1)
|
|
319
|
+
|| (draws.length === data.categories.length && new Set(draws).size > 1)));
|
|
320
|
+
const count = perPoint ? data.categories.length : data.series.length;
|
|
321
|
+
const paints = palette ? paintsFor(roles, count, palette) : [];
|
|
322
|
+
const styled = paints.length > 0 || draws.some(hollow) || draws.some((d) => DASH[d]);
|
|
323
|
+
const seriesStyle = styled
|
|
324
|
+
? perPoint
|
|
325
|
+
? [{ points: Array.from({ length: count }, (_, idx) => ({ idx, ...markStyle(paints[idx], draws[idx]) })).filter((p) => Object.keys(p).length > 1) }]
|
|
326
|
+
: data.series.map((_, i) => (["line", "scatter"].includes(type) ? lineStyle(paints[i], draws[i]) : markStyle(paints[i], draws[i])))
|
|
327
|
+
: [];
|
|
328
|
+
// Labels are what the image labels. Values ride on the marks only where the
|
|
329
|
+
// image labels every value — and not where texts of their own already carry
|
|
330
|
+
// them (x67 QBR 3: a donut's centre read "48%", the chart printed "48" and
|
|
331
|
+
// "52" on its slices as well) — in the format the image sets them in.
|
|
332
|
+
// Categories show where the image names them. A spec without observed
|
|
333
|
+
// labels keeps the size rule alone.
|
|
334
|
+
// Texts the image drew as the chart's own legend, value or category labels are
|
|
335
|
+
// folded back into the chart (chart-labels.mjs); they are labels it shows.
|
|
336
|
+
const seen = Array.isArray(c.labels) || folded.texts?.length ? [...(c.labels ?? []), ...(folded.texts ?? [])].map(String) : null;
|
|
337
|
+
const inside = (o) => o !== e && o.text != null && o.x >= e.x - 2 && o.y >= e.y - 2 && o.x + o.w <= e.x + e.w + 2 && o.y + o.h <= e.y + e.h + 2;
|
|
338
|
+
const values = data.series.flatMap((sr) => sr.values).filter(Number.isFinite);
|
|
339
|
+
const texts = (spec.elements ?? []).filter(inside).map((o) => num(o.text));
|
|
340
|
+
const showValues = folded.values === true || (!small && (!seen || (values.every((v) => seen.some((t) => near(num(t), v))) && !values.some((v) => texts.some((t) => near(t, v))))));
|
|
341
|
+
const showCategories = folded.categories === true || (!small && (!seen || data.categories.some((cat) => cat && seen.some((t) => t.trim().toLowerCase() === String(cat).trim().toLowerCase()))));
|
|
342
|
+
// A category name ("Q3", "2024") carries a figure too; it is no value label.
|
|
343
|
+
const isCategory = (t) => data.categories.some((cat) => cat && String(cat).trim().toLowerCase() === t.trim().toLowerCase());
|
|
344
|
+
const format = showValues && seen ? labelNumberFormat(values, seen.filter((t) => !isCategory(t))) : null;
|
|
345
|
+
// A series the image names at one of its points (a target line's "Target
|
|
346
|
+
// 96.5%"): its last point carries its name and value, its other points none.
|
|
347
|
+
for (const s of folded.seriesLabels ?? []) {
|
|
348
|
+
const last = data.series[s]?.values.length - 1;
|
|
349
|
+
if (!(last >= 0)) continue;
|
|
350
|
+
seriesStyle[s] = seriesStyle[s] ?? {};
|
|
351
|
+
seriesStyle[s].labels = { show: false };
|
|
352
|
+
seriesStyle[s].points = [...(seriesStyle[s].points ?? []), { idx: last, label: { show: true, value: true, seriesName: true, separator: " ", ...(type === "line" ? { position: "right" } : {}), ...(format ? { numberFormat: format } : {}) } }];
|
|
353
|
+
}
|
|
354
|
+
const labels = `{ show: ${showValues}, value: ${showValues}${format ? `, numberFormat: ${q(format)}` : ""} }`;
|
|
355
|
+
// Mockup charts are labelled, not gridded: values ride on the marks, so the
|
|
356
|
+
// value axis and every gridline stay off (x55 QBR 2: default axes and
|
|
357
|
+
// gridlines read as "default-style plots") — unless the image paints the
|
|
358
|
+
// axis with its ticks, or cuts it (valueAxisOf).
|
|
359
|
+
const value = sliced ? null : valueAxisOf(c, Math.min(...values.map((v) => (["line", "scatter", "area"].includes(type) ? v : Math.max(v, 0)))), notes, e.id) ?? { visible: false, majorGridlines: false };
|
|
360
|
+
const axes = sliced ? "" : ` axes={${JSON.stringify({ category: { visible: showCategories, majorGridlines: false }, value })}}`;
|
|
361
|
+
const legend = legendOf(c, type, data.series.length, small, data.categories);
|
|
362
|
+
// A pie's slices vary: its legend then lists the slices, not the one series
|
|
363
|
+
// (`c:varyColors` 0 gives a single "Spend mix" entry, x108 cloud 5).
|
|
364
|
+
// A doughnut's hole holds what stands in it (chart-hole.mjs).
|
|
365
|
+
const hole = type === "doughnut" ? holeSizeFor(spec, e) : null;
|
|
366
|
+
if (hole && !hole.fits) notes.push(`chart ${e.id}: what stands in its hole is wider than the widest hole a doughnut draws (90% of the ring) — it runs onto the ring; make it smaller or the ring larger`);
|
|
367
|
+
const layout = sliced ? ` layout={{ varyColors: true${hole ? `, holeSize: ${hole.size}` : ""} }}` : "";
|
|
368
|
+
const style = seriesStyle.length ? ` seriesStyle={${JSON.stringify(seriesStyle.map((x) => x ?? {}))}}` : "";
|
|
369
|
+
const plot = sliced ? ringPlot(e, hole != null && !legend.show) : null;
|
|
370
|
+
const plotArea = plot ? ` plotArea={{ layout: ${JSON.stringify(plot)} }}` : "";
|
|
371
|
+
return `<B ${base} h={${e.h}}><Chart type=${q(type)} data={${JSON.stringify(data)}}${axes} labels={${labels}} legend={{ show: ${legend.show}, pos: ${q(legend.pos)} }}${layout}${plotArea}${style} style={{ width: ${e.w}, height: ${e.h} }} /></B>`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Where a pie or doughnut draws its ring, as the plot area's manual layout
|
|
376
|
+
* (fractions of the chart's frame): where its frame grew to hold its folded
|
|
377
|
+
* legend or labels (chart-labels.mjs `folded.frame`), the ring stays the size
|
|
378
|
+
* and place the image painted it — left to PowerPoint, the ring shrinks and
|
|
379
|
+
* moves beside the native legend (x114 QBR 3: the globe in the hole sat on the
|
|
380
|
+
* ring's edge); and a ring with something in its hole and no legend (`whole`)
|
|
381
|
+
* fills its frame, so the hole is the size `holeSizeFor` gave it. null
|
|
382
|
+
* otherwise: PowerPoint lays the ring out.
|
|
383
|
+
*/
|
|
384
|
+
function ringPlot(e, whole = false) {
|
|
385
|
+
const ring = ringRect(e);
|
|
386
|
+
if (ring.x === e.x && ring.y === e.y && ring.w === e.w && ring.h === e.h && !whole) return null;
|
|
387
|
+
if (!(e.w > 0 && e.h > 0 && ring.w > 0 && ring.h > 0)) return null;
|
|
388
|
+
const f = (v) => Math.round(Math.min(1, Math.max(0, v)) * 10000) / 10000;
|
|
389
|
+
return { x: f((ring.x - e.x) / e.w), y: f((ring.y - e.y) / e.h), w: f(ring.w / e.w), h: f(ring.h / e.h) };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A pie's slice names as its legend rows read them, with each slice's value
|
|
394
|
+
* where every row carries it ("DE–PL lane" + "21%" -> "DE–PL lane 21%"); null
|
|
395
|
+
* when the rows name the slices alone.
|
|
396
|
+
*/
|
|
397
|
+
function legendWithValues(data, texts) {
|
|
398
|
+
const values = data.series[0]?.values ?? [];
|
|
399
|
+
const rows = data.categories.map((cat, i) => {
|
|
400
|
+
const name = labelKey(cat);
|
|
401
|
+
for (const t of texts) {
|
|
402
|
+
const key = labelKey(t);
|
|
403
|
+
if (!name || !key.startsWith(`${name} `)) continue;
|
|
404
|
+
const rest = String(t).replace(/\s+/g, " ").trim().slice(String(cat).replace(/\s+/g, " ").trim().length).trim();
|
|
405
|
+
if (onlyFigure(rest) && Math.abs(Math.abs(figureOf(rest)) - Math.abs(values[i])) <= 0.051 + Math.abs(values[i]) * 0.005) return `${String(cat).trim()} ${rest}`;
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
});
|
|
409
|
+
return rows.every(Boolean) ? rows : null;
|
|
410
|
+
}
|