@beyondwork/docx-react-component 1.0.50 → 1.0.51
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/README.md +8 -5
- package/package.json +1 -1
- package/src/runtime/layout/layout-engine-version.ts +42 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +8 -1
- package/src/runtime/render/render-kernel.ts +40 -10
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axis tick generation for chart rendering (Stage 3A, pure math).
|
|
3
|
+
*
|
|
4
|
+
* Three axis kinds:
|
|
5
|
+
* - **value** — numeric axis with a "nice" auto-step (when the model
|
|
6
|
+
* doesn't pin `majorUnit`) derived from a d3-style heuristic:
|
|
7
|
+
* pick from {1, 2, 2.5, 5} × 10^n targeting 5-8 major ticks.
|
|
8
|
+
* Log-scale value axes use `logBase` (default 10) to generate
|
|
9
|
+
* ticks at base^k covering the domain.
|
|
10
|
+
* - **category** — axis labels come from the model's pre-resolved
|
|
11
|
+
* category array; `tickLabelSkip` lets Word-authored charts skip
|
|
12
|
+
* every N-th label.
|
|
13
|
+
* - **date** — Excel serial dates (days since 1899-12-30). We respect
|
|
14
|
+
* `baseTimeUnit` ∈ {days, months, years} by stepping through the
|
|
15
|
+
* domain in units of that period; `majorTimeUnit` overrides the
|
|
16
|
+
* step when present.
|
|
17
|
+
*
|
|
18
|
+
* All functions are pure (no DOM, no React, no runtime imports) so
|
|
19
|
+
* Stage 3 layout math is unit-testable without a browser.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Value axis — linear + log
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ValueTickInput {
|
|
27
|
+
min: number;
|
|
28
|
+
max: number;
|
|
29
|
+
/** Override for major step. When present, ticks are strictly multiples. */
|
|
30
|
+
majorUnit?: number;
|
|
31
|
+
/** Override for minor step. Defaults to majorUnit / 5 when omitted. */
|
|
32
|
+
minorUnit?: number;
|
|
33
|
+
/** Log-scale base. Omitted for linear axes. */
|
|
34
|
+
logBase?: number;
|
|
35
|
+
/** Target major-tick count range for the auto-step heuristic. */
|
|
36
|
+
targetTickCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TickResult {
|
|
40
|
+
/** Major tick positions, sorted ascending. */
|
|
41
|
+
major: number[];
|
|
42
|
+
/** Minor tick positions (between majors), sorted ascending. */
|
|
43
|
+
minor: number[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TARGET_TICK_COUNT = 6;
|
|
47
|
+
// d3-scale convention. Omitting 2.5 keeps ticks on "round" values
|
|
48
|
+
// (multiples of 1/2/5/10/20/50/…) that match Word's tick choices more
|
|
49
|
+
// closely than the extended-Wilkinson mantissa list.
|
|
50
|
+
const NICE_STEP_MANTISSAS = [1, 2, 5, 10];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate tick positions for a value axis.
|
|
54
|
+
*
|
|
55
|
+
* When `majorUnit` is supplied, ticks are strict multiples of it
|
|
56
|
+
* covering `[min, max]`. Otherwise the auto-step heuristic picks a
|
|
57
|
+
* "nice" step from `NICE_STEP_MANTISSAS × 10^n` that produces roughly
|
|
58
|
+
* `targetTickCount` (default 6) major ticks.
|
|
59
|
+
*
|
|
60
|
+
* Log-scale axes generate ticks at `logBase^k` for integer k covering
|
|
61
|
+
* the domain, plus minor ticks at {2,3,…,base-1} × logBase^k between
|
|
62
|
+
* majors (standard log-axis convention, matching Word's output).
|
|
63
|
+
*
|
|
64
|
+
* Degenerate domains (min > max, min === max, non-finite values) fall
|
|
65
|
+
* back to a single `[min, min]` tick so callers always get at least
|
|
66
|
+
* one position to render an axis line.
|
|
67
|
+
*/
|
|
68
|
+
export function generateValueTicks(input: ValueTickInput): TickResult {
|
|
69
|
+
if (!Number.isFinite(input.min) || !Number.isFinite(input.max)) {
|
|
70
|
+
return { major: [input.min], minor: [] };
|
|
71
|
+
}
|
|
72
|
+
if (input.min === input.max) {
|
|
73
|
+
return { major: [input.min], minor: [] };
|
|
74
|
+
}
|
|
75
|
+
const [min, max] = input.min < input.max
|
|
76
|
+
? [input.min, input.max]
|
|
77
|
+
: [input.max, input.min];
|
|
78
|
+
|
|
79
|
+
if (input.logBase && input.logBase > 1) {
|
|
80
|
+
return generateLogTicks(min, max, input.logBase);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const step = input.majorUnit ?? niceStep(
|
|
84
|
+
max - min,
|
|
85
|
+
input.targetTickCount ?? DEFAULT_TARGET_TICK_COUNT,
|
|
86
|
+
);
|
|
87
|
+
const minorStep = input.minorUnit ?? step / 5;
|
|
88
|
+
|
|
89
|
+
const firstMajor = Math.ceil(min / step - 1e-9) * step;
|
|
90
|
+
const major: number[] = [];
|
|
91
|
+
for (let t = firstMajor; t <= max + 1e-9; t += step) {
|
|
92
|
+
// Round to the step's decimal precision to avoid floating drift.
|
|
93
|
+
major.push(roundToStep(t, step));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const minor: number[] = [];
|
|
97
|
+
if (minorStep > 0 && minorStep < step) {
|
|
98
|
+
const firstMinor = Math.ceil(min / minorStep - 1e-9) * minorStep;
|
|
99
|
+
for (let t = firstMinor; t <= max + 1e-9; t += minorStep) {
|
|
100
|
+
// Skip positions that coincide with major ticks (mod epsilon).
|
|
101
|
+
const snapped = roundToStep(t, minorStep);
|
|
102
|
+
if (!isMajorPosition(snapped, step)) minor.push(snapped);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { major, minor };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pick a "nice" step size for a given range + target tick count using
|
|
111
|
+
* the d3-scale-style heuristic: find the power-of-ten bucket, then
|
|
112
|
+
* pick from {1, 2, 2.5, 5, 10} the mantissa that yields the closest
|
|
113
|
+
* number of ticks to `target`.
|
|
114
|
+
*/
|
|
115
|
+
export function niceStep(range: number, target: number): number {
|
|
116
|
+
if (range <= 0 || !Number.isFinite(range)) return 1;
|
|
117
|
+
const rough = range / Math.max(1, target);
|
|
118
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
119
|
+
const normalized = rough / magnitude;
|
|
120
|
+
// Pick the mantissa producing tick count closest to target.
|
|
121
|
+
let best = NICE_STEP_MANTISSAS[0]!;
|
|
122
|
+
let bestDelta = Infinity;
|
|
123
|
+
for (const m of NICE_STEP_MANTISSAS) {
|
|
124
|
+
if (m < normalized) continue;
|
|
125
|
+
const step = m * magnitude;
|
|
126
|
+
const count = Math.floor(range / step) + 1;
|
|
127
|
+
const delta = Math.abs(count - target);
|
|
128
|
+
if (delta < bestDelta) {
|
|
129
|
+
bestDelta = delta;
|
|
130
|
+
best = m;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return best * magnitude;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function generateLogTicks(min: number, max: number, base: number): TickResult {
|
|
137
|
+
// For log scale, clamp min > 0 to avoid -Infinity.
|
|
138
|
+
const safeMin = Math.max(min, Number.MIN_VALUE);
|
|
139
|
+
const lo = Math.floor(logBase(safeMin, base));
|
|
140
|
+
const hi = Math.ceil(logBase(max, base));
|
|
141
|
+
const major: number[] = [];
|
|
142
|
+
const minor: number[] = [];
|
|
143
|
+
for (let k = lo; k <= hi; k++) {
|
|
144
|
+
const power = Math.pow(base, k);
|
|
145
|
+
if (power >= min - 1e-12 && power <= max + 1e-12) {
|
|
146
|
+
major.push(power);
|
|
147
|
+
}
|
|
148
|
+
// Minor ticks at 2×, 3×, …, (base-1)× within each decade.
|
|
149
|
+
for (let m = 2; m < base; m++) {
|
|
150
|
+
const minorPos = m * power;
|
|
151
|
+
if (minorPos >= min && minorPos <= max) {
|
|
152
|
+
minor.push(minorPos);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { major, minor };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function logBase(x: number, base: number): number {
|
|
160
|
+
return Math.log(x) / Math.log(base);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function roundToStep(value: number, step: number): number {
|
|
164
|
+
// Round to the decimal precision implied by the step so e.g.
|
|
165
|
+
// `step=0.2` produces values like 0.2, 0.4, 0.6 without 0.30000004.
|
|
166
|
+
// Also normalise -0 to 0 since `Math.ceil(-1e-9) * step === -0` and
|
|
167
|
+
// `deepStrictEqual` distinguishes -0 from 0.
|
|
168
|
+
if (step === 0) return value;
|
|
169
|
+
const precision = Math.max(0, -Math.floor(Math.log10(Math.abs(step))) + 2);
|
|
170
|
+
const factor = Math.pow(10, precision);
|
|
171
|
+
const rounded = Math.round(value * factor) / factor;
|
|
172
|
+
return rounded === 0 ? 0 : rounded;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isMajorPosition(value: number, step: number): boolean {
|
|
176
|
+
const mod = Math.abs(value / step - Math.round(value / step));
|
|
177
|
+
return mod < 1e-6;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Category axis — label pass-through with optional skip
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export interface CategoryTickInput {
|
|
185
|
+
labels: ReadonlyArray<string>;
|
|
186
|
+
/** c:tickLabelSkip — show every N-th label. 1 (default) shows all. */
|
|
187
|
+
tickLabelSkip?: number;
|
|
188
|
+
/** c:tickMarkSkip — show tick mark every N-th category. */
|
|
189
|
+
tickMarkSkip?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface CategoryTickResult {
|
|
193
|
+
/**
|
|
194
|
+
* Emitted tick positions as category index → label pairs. Index is
|
|
195
|
+
* the position in the original `labels` array; label is the rendered
|
|
196
|
+
* string ("" when skipped by `tickLabelSkip`). Renderers map index
|
|
197
|
+
* to plot-area coordinate via the category slot width.
|
|
198
|
+
*/
|
|
199
|
+
ticks: Array<{ index: number; label: string; major: boolean }>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function generateCategoryTicks(input: CategoryTickInput): CategoryTickResult {
|
|
203
|
+
const labelSkip = Math.max(1, Math.floor(input.tickLabelSkip ?? 1));
|
|
204
|
+
const markSkip = Math.max(1, Math.floor(input.tickMarkSkip ?? 1));
|
|
205
|
+
const ticks: CategoryTickResult["ticks"] = [];
|
|
206
|
+
for (let i = 0; i < input.labels.length; i++) {
|
|
207
|
+
const isLabeled = i % labelSkip === 0;
|
|
208
|
+
const isMajor = i % markSkip === 0;
|
|
209
|
+
ticks.push({
|
|
210
|
+
index: i,
|
|
211
|
+
label: isLabeled ? input.labels[i]! : "",
|
|
212
|
+
major: isMajor,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { ticks };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Date axis — Excel serial dates
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export type TimeUnit = "days" | "months" | "years";
|
|
223
|
+
|
|
224
|
+
export interface DateTickInput {
|
|
225
|
+
/** Excel serial date (days since 1899-12-30). */
|
|
226
|
+
min: number;
|
|
227
|
+
max: number;
|
|
228
|
+
/** c:baseTimeUnit — sets the default step for auto-generation. */
|
|
229
|
+
baseTimeUnit?: TimeUnit;
|
|
230
|
+
/** c:majorUnit + c:majorTimeUnit — pinned major step. */
|
|
231
|
+
majorUnit?: number;
|
|
232
|
+
majorTimeUnit?: TimeUnit;
|
|
233
|
+
/** c:minorUnit + c:minorTimeUnit — pinned minor step. */
|
|
234
|
+
minorUnit?: number;
|
|
235
|
+
minorTimeUnit?: TimeUnit;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Generate tick positions for a date axis. Returns Excel serial-date
|
|
240
|
+
* numbers so the renderer can format them via the chart's number-format
|
|
241
|
+
* code (`"mmm-yy"`, `"yyyy-mm-dd"`, etc.) using `formatNumber` from
|
|
242
|
+
* `number-format.ts`.
|
|
243
|
+
*
|
|
244
|
+
* When the domain spans ≤ 2 months and no explicit unit, ticks fall at
|
|
245
|
+
* day boundaries; ≤ 2 years → month boundaries; larger → year boundaries.
|
|
246
|
+
* This mirrors Excel's default tick auto-selection.
|
|
247
|
+
*/
|
|
248
|
+
export function generateDateTicks(input: DateTickInput): TickResult {
|
|
249
|
+
if (!Number.isFinite(input.min) || !Number.isFinite(input.max)) {
|
|
250
|
+
return { major: [input.min], minor: [] };
|
|
251
|
+
}
|
|
252
|
+
if (input.min === input.max) {
|
|
253
|
+
return { major: [input.min], minor: [] };
|
|
254
|
+
}
|
|
255
|
+
const [min, max] = input.min < input.max
|
|
256
|
+
? [input.min, input.max]
|
|
257
|
+
: [input.max, input.min];
|
|
258
|
+
|
|
259
|
+
const unit = input.majorTimeUnit
|
|
260
|
+
?? input.baseTimeUnit
|
|
261
|
+
?? autoPickTimeUnit(max - min);
|
|
262
|
+
const step = Math.max(1, Math.floor(input.majorUnit ?? 1));
|
|
263
|
+
|
|
264
|
+
const major = stepByTimeUnit(min, max, unit, step);
|
|
265
|
+
|
|
266
|
+
const minorUnit = input.minorTimeUnit ?? unit;
|
|
267
|
+
const minorStepSize = Math.max(1, Math.floor(input.minorUnit ?? 1));
|
|
268
|
+
const minor = input.minorUnit !== undefined || input.minorTimeUnit !== undefined
|
|
269
|
+
? stepByTimeUnit(min, max, minorUnit, minorStepSize).filter(
|
|
270
|
+
(pos) => !major.includes(pos),
|
|
271
|
+
)
|
|
272
|
+
: [];
|
|
273
|
+
|
|
274
|
+
return { major, minor };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function autoPickTimeUnit(rangeDays: number): TimeUnit {
|
|
278
|
+
if (rangeDays <= 62) return "days";
|
|
279
|
+
if (rangeDays <= 730) return "months";
|
|
280
|
+
return "years";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stepByTimeUnit(
|
|
284
|
+
min: number,
|
|
285
|
+
max: number,
|
|
286
|
+
unit: TimeUnit,
|
|
287
|
+
stepN: number,
|
|
288
|
+
): number[] {
|
|
289
|
+
const ticks: number[] = [];
|
|
290
|
+
if (unit === "days") {
|
|
291
|
+
const start = Math.ceil(min / stepN) * stepN;
|
|
292
|
+
for (let t = start; t <= max; t += stepN) {
|
|
293
|
+
ticks.push(t);
|
|
294
|
+
}
|
|
295
|
+
return ticks;
|
|
296
|
+
}
|
|
297
|
+
// Month / year: walk via Date to respect variable month lengths.
|
|
298
|
+
let { y, m, d } = serialToYMD(Math.ceil(min));
|
|
299
|
+
// Snap d to 1st for month/year stepping.
|
|
300
|
+
d = 1;
|
|
301
|
+
// Snap m to 1st for year stepping.
|
|
302
|
+
if (unit === "years") m = 1;
|
|
303
|
+
let serial = ymdToSerial(y, m, d);
|
|
304
|
+
if (serial < min) {
|
|
305
|
+
if (unit === "months") {
|
|
306
|
+
m += 1;
|
|
307
|
+
if (m > 12) { m = 1; y += 1; }
|
|
308
|
+
} else {
|
|
309
|
+
y += 1;
|
|
310
|
+
}
|
|
311
|
+
serial = ymdToSerial(y, m, d);
|
|
312
|
+
}
|
|
313
|
+
while (serial <= max) {
|
|
314
|
+
ticks.push(serial);
|
|
315
|
+
if (unit === "months") {
|
|
316
|
+
m += stepN;
|
|
317
|
+
while (m > 12) { m -= 12; y += 1; }
|
|
318
|
+
} else {
|
|
319
|
+
y += stepN;
|
|
320
|
+
}
|
|
321
|
+
serial = ymdToSerial(y, m, d);
|
|
322
|
+
}
|
|
323
|
+
return ticks;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert an Excel serial date to a {y, m, d} triple. Excel's epoch is
|
|
328
|
+
* 1899-12-30 (treating the bogus 1900-02-29 leap-day as day 60).
|
|
329
|
+
*/
|
|
330
|
+
function serialToYMD(serial: number): { y: number; m: number; d: number } {
|
|
331
|
+
// 1899-12-30 baseline → offset of 25569 aligns with Unix epoch.
|
|
332
|
+
const ms = (serial - 25569) * 86_400_000;
|
|
333
|
+
const d = new Date(ms);
|
|
334
|
+
return {
|
|
335
|
+
y: d.getUTCFullYear(),
|
|
336
|
+
m: d.getUTCMonth() + 1,
|
|
337
|
+
d: d.getUTCDate(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function ymdToSerial(y: number, m: number, d: number): number {
|
|
342
|
+
const ms = Date.UTC(y, m - 1, d);
|
|
343
|
+
return ms / 86_400_000 + 25569;
|
|
344
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plot-area layout: partition the canvas into title / legend / axis /
|
|
3
|
+
* plot rectangles (Stage 3A, pure math).
|
|
4
|
+
*
|
|
5
|
+
* Call shape:
|
|
6
|
+
*
|
|
7
|
+
* layoutPlotArea({ w, h }, model, theme) → PlotAreaLayout
|
|
8
|
+
*
|
|
9
|
+
* Algorithm (Word's observed order):
|
|
10
|
+
* 1. Reserve title band at top (if `model.title` present and not overlaid).
|
|
11
|
+
* 2. Reserve legend band on the declared side (b/t/l/r/tr).
|
|
12
|
+
* 3. Reserve Y-axis band on the left (and right if secondary axis).
|
|
13
|
+
* 4. Reserve X-axis band on the bottom.
|
|
14
|
+
* 5. Remaining rectangle is the plot area.
|
|
15
|
+
*
|
|
16
|
+
* **Text measurement is a fixed-stub at Slice 3A.** `measureTextWidth`
|
|
17
|
+
* and `measureTextHeight` use a deterministic avg-glyph / line-height
|
|
18
|
+
* approximation so the layout math is unit-testable without a browser.
|
|
19
|
+
* Slice 3B swaps in the real `measureText(text, txPr, theme)` helper
|
|
20
|
+
* that calls `ctx.measureText` (browser) or an empirical LRU (SSR) —
|
|
21
|
+
* the stub keeps the public API stable so Slice 3B is a pure
|
|
22
|
+
* implementation swap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ChartModel } from "../../../io/ooxml/chart/types.ts";
|
|
26
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
27
|
+
import {
|
|
28
|
+
generateCategoryTicks,
|
|
29
|
+
generateValueTicks,
|
|
30
|
+
type TickResult,
|
|
31
|
+
} from "./axis-layout.ts";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Public API
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface Rect {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
w: number;
|
|
41
|
+
h: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlotAreaLayout {
|
|
45
|
+
plotRect: Rect;
|
|
46
|
+
titleRect?: Rect;
|
|
47
|
+
legendRect?: Rect;
|
|
48
|
+
xAxisRect?: Rect;
|
|
49
|
+
yAxisRect?: Rect;
|
|
50
|
+
secondaryYAxisRect?: Rect;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CanvasRect {
|
|
54
|
+
w: number;
|
|
55
|
+
h: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Partition the canvas into labelled sub-rectangles. The returned
|
|
60
|
+
* `plotRect` is the rectangle the chart-type renderer draws into.
|
|
61
|
+
*
|
|
62
|
+
* The current implementation uses the fixed-stub text measurement
|
|
63
|
+
* (`GLYPH_WIDTH_PX` × font size, `LINE_HEIGHT_RATIO` × font size). Slice
|
|
64
|
+
* 3B replaces those constants with a call-through to the real
|
|
65
|
+
* font-metrics helper.
|
|
66
|
+
*/
|
|
67
|
+
export function layoutPlotArea(
|
|
68
|
+
canvas: CanvasRect,
|
|
69
|
+
model: ChartModel,
|
|
70
|
+
theme: ResolvedTheme | undefined,
|
|
71
|
+
): PlotAreaLayout {
|
|
72
|
+
void theme; // consumed by Slice 3B's real font-metrics wrapper.
|
|
73
|
+
|
|
74
|
+
let top = 0;
|
|
75
|
+
let bottom = canvas.h;
|
|
76
|
+
let left = 0;
|
|
77
|
+
let right = canvas.w;
|
|
78
|
+
|
|
79
|
+
const out: PlotAreaLayout = {
|
|
80
|
+
plotRect: { x: 0, y: 0, w: canvas.w, h: canvas.h },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 1. Title band (top).
|
|
84
|
+
if (model.kind !== "unsupported" && model.title && !model.title.overlay) {
|
|
85
|
+
const titleHeight = measureTitleHeight(model.title.text ?? "");
|
|
86
|
+
out.titleRect = { x: 0, y: 0, w: canvas.w, h: titleHeight };
|
|
87
|
+
top += titleHeight + BAND_GAP_PX;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Legend band — on the side indicated by `legend.position`.
|
|
91
|
+
if (model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
|
|
92
|
+
const entryCount = countLegendEntries(model);
|
|
93
|
+
const { w: legendW, h: legendH } = measureLegendBox(entryCount);
|
|
94
|
+
switch (model.legend.position) {
|
|
95
|
+
case "t":
|
|
96
|
+
out.legendRect = { x: 0, y: top, w: canvas.w, h: legendH };
|
|
97
|
+
top += legendH + BAND_GAP_PX;
|
|
98
|
+
break;
|
|
99
|
+
case "b":
|
|
100
|
+
out.legendRect = {
|
|
101
|
+
x: 0,
|
|
102
|
+
y: bottom - legendH,
|
|
103
|
+
w: canvas.w,
|
|
104
|
+
h: legendH,
|
|
105
|
+
};
|
|
106
|
+
bottom -= legendH + BAND_GAP_PX;
|
|
107
|
+
break;
|
|
108
|
+
case "l":
|
|
109
|
+
out.legendRect = { x: left, y: top, w: legendW, h: bottom - top };
|
|
110
|
+
left += legendW + BAND_GAP_PX;
|
|
111
|
+
break;
|
|
112
|
+
case "r":
|
|
113
|
+
case "tr":
|
|
114
|
+
default:
|
|
115
|
+
out.legendRect = {
|
|
116
|
+
x: right - legendW,
|
|
117
|
+
y: top,
|
|
118
|
+
w: legendW,
|
|
119
|
+
h: bottom - top,
|
|
120
|
+
};
|
|
121
|
+
right -= legendW + BAND_GAP_PX;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Axis bands. Only cartesian families (bar/line/area/scatter/bubble/
|
|
127
|
+
// combo) reserve axis space; pie/doughnut/unsupported skip this entirely.
|
|
128
|
+
const axes = pickAxes(model);
|
|
129
|
+
if (axes) {
|
|
130
|
+
// Y-axis on the left: width = max(tick label width) + axis-title
|
|
131
|
+
// rotated height.
|
|
132
|
+
const yTickLabels = axisTickLabels(axes.y);
|
|
133
|
+
const yWidth =
|
|
134
|
+
maxLabelWidth(yTickLabels) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
135
|
+
out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
|
|
136
|
+
left += yWidth + BAND_GAP_PX;
|
|
137
|
+
|
|
138
|
+
// Secondary Y-axis on the right.
|
|
139
|
+
if (axes.secondaryY) {
|
|
140
|
+
const y2TickLabels = axisTickLabels(axes.secondaryY);
|
|
141
|
+
const y2Width =
|
|
142
|
+
maxLabelWidth(y2TickLabels) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
143
|
+
out.secondaryYAxisRect = {
|
|
144
|
+
x: right - y2Width,
|
|
145
|
+
y: top,
|
|
146
|
+
w: y2Width,
|
|
147
|
+
h: bottom - top,
|
|
148
|
+
};
|
|
149
|
+
right -= y2Width + BAND_GAP_PX;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// X-axis on the bottom: height = label height + axis-title band.
|
|
153
|
+
const xLabelHeight = X_AXIS_LABEL_HEIGHT_PX;
|
|
154
|
+
const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
155
|
+
out.xAxisRect = {
|
|
156
|
+
x: left,
|
|
157
|
+
y: bottom - xHeight,
|
|
158
|
+
w: right - left,
|
|
159
|
+
h: xHeight,
|
|
160
|
+
};
|
|
161
|
+
bottom -= xHeight + BAND_GAP_PX;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
out.plotRect = {
|
|
165
|
+
x: left,
|
|
166
|
+
y: top,
|
|
167
|
+
w: Math.max(0, right - left),
|
|
168
|
+
h: Math.max(0, bottom - top),
|
|
169
|
+
};
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Text measurement — fixed stub (Slice 3B replaces with real metrics)
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Approximate glyph width in pixels at font size 1. The real value
|
|
179
|
+
* depends on font + character; Calibri averages ~0.44 em for narrow
|
|
180
|
+
* body text, but chart labels tend to be digits which are monospace-
|
|
181
|
+
* like. 0.55 em is a conservative average that matches Word's default
|
|
182
|
+
* axis-label font (Calibri 10pt ≈ 13.3px) well enough for layout
|
|
183
|
+
* reservation. Replaced in Slice 3B.
|
|
184
|
+
*/
|
|
185
|
+
const GLYPH_WIDTH_EM = 0.55;
|
|
186
|
+
|
|
187
|
+
const LINE_HEIGHT_RATIO = 1.2;
|
|
188
|
+
const BAND_GAP_PX = 4;
|
|
189
|
+
const AXIS_TITLE_BAND_PX = 14;
|
|
190
|
+
const X_AXIS_LABEL_HEIGHT_PX = 14;
|
|
191
|
+
const DEFAULT_AXIS_FONT_PX = 10;
|
|
192
|
+
const DEFAULT_TITLE_FONT_PX = 14;
|
|
193
|
+
const DEFAULT_LEGEND_FONT_PX = 10;
|
|
194
|
+
const LEGEND_SWATCH_WIDTH_PX = 16;
|
|
195
|
+
const LEGEND_SWATCH_GAP_PX = 6;
|
|
196
|
+
const LEGEND_ENTRY_GAP_PX = 12;
|
|
197
|
+
const LEGEND_MIN_WIDTH_PX = 80;
|
|
198
|
+
|
|
199
|
+
function measureTitleHeight(text: string): number {
|
|
200
|
+
if (!text) return 0;
|
|
201
|
+
return DEFAULT_TITLE_FONT_PX * LINE_HEIGHT_RATIO;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function measureLegendBox(entryCount: number): { w: number; h: number } {
|
|
205
|
+
if (entryCount <= 0) return { w: 0, h: 0 };
|
|
206
|
+
const avgLabelChars = 8;
|
|
207
|
+
const entryWidth =
|
|
208
|
+
LEGEND_SWATCH_WIDTH_PX +
|
|
209
|
+
LEGEND_SWATCH_GAP_PX +
|
|
210
|
+
GLYPH_WIDTH_EM * DEFAULT_LEGEND_FONT_PX * avgLabelChars;
|
|
211
|
+
// Reserve a one-column stack: width = entryWidth, height = N lines.
|
|
212
|
+
return {
|
|
213
|
+
w: Math.max(LEGEND_MIN_WIDTH_PX, entryWidth + LEGEND_ENTRY_GAP_PX),
|
|
214
|
+
h: entryCount * DEFAULT_LEGEND_FONT_PX * LINE_HEIGHT_RATIO,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function maxLabelWidth(labels: ReadonlyArray<string>): number {
|
|
219
|
+
if (labels.length === 0) return 0;
|
|
220
|
+
let maxChars = 0;
|
|
221
|
+
for (const label of labels) {
|
|
222
|
+
if (label.length > maxChars) maxChars = label.length;
|
|
223
|
+
}
|
|
224
|
+
return maxChars * GLYPH_WIDTH_EM * DEFAULT_AXIS_FONT_PX;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Model introspection helpers
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
interface AxisBundle {
|
|
232
|
+
y: TickResult;
|
|
233
|
+
yTitle: boolean;
|
|
234
|
+
x?: TickResult;
|
|
235
|
+
xTitle: boolean;
|
|
236
|
+
secondaryY?: TickResult;
|
|
237
|
+
secondaryYTitle: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Inspect the model and produce tick lists for each axis position so
|
|
242
|
+
* the layout can reserve space based on the widest label. Pie /
|
|
243
|
+
* doughnut / unsupported return null (no axis bands).
|
|
244
|
+
*/
|
|
245
|
+
function pickAxes(model: ChartModel): AxisBundle | null {
|
|
246
|
+
switch (model.kind) {
|
|
247
|
+
case "bar":
|
|
248
|
+
case "line":
|
|
249
|
+
case "area": {
|
|
250
|
+
const yTicks = axisTicks(model.valueAxis);
|
|
251
|
+
const y2Ticks = model.secondaryValueAxis
|
|
252
|
+
? axisTicks(model.secondaryValueAxis)
|
|
253
|
+
: undefined;
|
|
254
|
+
const bundle: AxisBundle = {
|
|
255
|
+
y: yTicks,
|
|
256
|
+
yTitle: !!model.valueAxis.title,
|
|
257
|
+
xTitle: model.categoryAxis.kind === "category"
|
|
258
|
+
? !!model.categoryAxis.title
|
|
259
|
+
: !!model.categoryAxis.title,
|
|
260
|
+
secondaryYTitle: !!model.secondaryValueAxis?.title,
|
|
261
|
+
};
|
|
262
|
+
if (y2Ticks) bundle.secondaryY = y2Ticks;
|
|
263
|
+
return bundle;
|
|
264
|
+
}
|
|
265
|
+
case "scatter":
|
|
266
|
+
case "bubble": {
|
|
267
|
+
return {
|
|
268
|
+
y: axisTicks(model.yAxis),
|
|
269
|
+
yTitle: !!model.yAxis.title,
|
|
270
|
+
x: axisTicks(model.xAxis),
|
|
271
|
+
xTitle: !!model.xAxis.title,
|
|
272
|
+
secondaryYTitle: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
case "combo": {
|
|
276
|
+
// Use the first group's axes as representative.
|
|
277
|
+
const first = model.groups[0];
|
|
278
|
+
if (!first) return null;
|
|
279
|
+
return pickAxes(first);
|
|
280
|
+
}
|
|
281
|
+
case "pie":
|
|
282
|
+
case "unsupported":
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function axisTicks(axis: {
|
|
288
|
+
kind: string;
|
|
289
|
+
min?: number;
|
|
290
|
+
max?: number;
|
|
291
|
+
majorUnit?: number;
|
|
292
|
+
categoryLabels?: ReadonlyArray<string>;
|
|
293
|
+
}): TickResult {
|
|
294
|
+
if (axis.kind === "category") {
|
|
295
|
+
const cat = generateCategoryTicks({
|
|
296
|
+
labels: axis.categoryLabels ?? [],
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
major: cat.ticks.map((t) => t.index),
|
|
300
|
+
minor: [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const min = axis.min ?? 0;
|
|
304
|
+
const max = axis.max ?? 1;
|
|
305
|
+
return generateValueTicks({
|
|
306
|
+
min,
|
|
307
|
+
max,
|
|
308
|
+
...(axis.majorUnit !== undefined ? { majorUnit: axis.majorUnit } : {}),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Produce string labels for an axis's major ticks. Category axis uses
|
|
314
|
+
* the source labels directly; value axis stringifies numeric positions.
|
|
315
|
+
* Real number-format handling lands in Slice 4G via `number-format.ts`;
|
|
316
|
+
* the fixed-width stub here is sufficient for plot-area reservation
|
|
317
|
+
* (within ~1 glyph-width of the real rendered size).
|
|
318
|
+
*/
|
|
319
|
+
function axisTickLabels(ticks: TickResult): string[] {
|
|
320
|
+
return ticks.major.map((t) => String(t));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function countLegendEntries(model: ChartModel): number {
|
|
324
|
+
switch (model.kind) {
|
|
325
|
+
case "bar":
|
|
326
|
+
case "line":
|
|
327
|
+
case "area":
|
|
328
|
+
case "scatter":
|
|
329
|
+
case "bubble":
|
|
330
|
+
return model.series.length;
|
|
331
|
+
case "pie": {
|
|
332
|
+
// Pie legends show one entry per slice.
|
|
333
|
+
const first = model.series[0];
|
|
334
|
+
return first ? first.values.length : 0;
|
|
335
|
+
}
|
|
336
|
+
case "combo": {
|
|
337
|
+
let total = 0;
|
|
338
|
+
for (const g of model.groups) total += g.series.length;
|
|
339
|
+
return total;
|
|
340
|
+
}
|
|
341
|
+
case "unsupported":
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
}
|