@beyondwork/docx-react-component 1.0.53 → 1.0.54
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/package.json +1 -1
- package/src/api/public-types.ts +35 -7
- package/src/io/docx-session.ts +30 -6
- package/src/runtime/collab/checkpoint-store.ts +1 -1
- package/src/runtime/collab/event-types.ts +4 -0
- package/src/runtime/collab/runtime-collab-sync.ts +1 -2
- package/src/runtime/document-runtime.ts +23 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +58 -1
- package/src/runtime/layout/layout-invalidation.ts +150 -30
- package/src/runtime/layout/page-graph.ts +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +128 -19
- package/src/runtime/layout/project-block-fragments.ts +27 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/headless/comment-decoration-model.ts +60 -5
- package/src/ui/headless/revision-decoration-model.ts +94 -6
- package/src/ui/shared/revision-filters.ts +16 -6
- package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
- package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
- package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
- package/src/ui-tailwind/chart/render/area.tsx +277 -0
- package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
- package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
- package/src/ui-tailwind/chart/render/combo.tsx +85 -0
- package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
- package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
- package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
- package/src/ui-tailwind/chart/render/line.tsx +363 -0
- package/src/ui-tailwind/chart/render/number-format.ts +120 -16
- package/src/ui-tailwind/chart/render/pie.tsx +275 -0
- package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
- package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
- package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
- package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
- package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
- package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
- package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
- package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
- package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
- package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
- package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
- package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
- package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
- package/src/ui-tailwind/theme/editor-theme.css +249 -22
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plot-area layout: partition the canvas into title / legend / axis /
|
|
3
|
-
* plot rectangles (Stage 3A
|
|
3
|
+
* plot rectangles (Stage 3A + 3B update).
|
|
4
4
|
*
|
|
5
5
|
* Call shape:
|
|
6
6
|
*
|
|
@@ -13,21 +13,23 @@
|
|
|
13
13
|
* 4. Reserve X-axis band on the bottom.
|
|
14
14
|
* 5. Remaining rectangle is the plot area.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the stub keeps the public API stable so Slice 3B is a pure
|
|
22
|
-
* implementation swap.
|
|
16
|
+
* Slice 3B: text measurement now uses the real `measureText` helper
|
|
17
|
+
* (`src/ui-tailwind/chart/render/font-metrics.ts`) which calls
|
|
18
|
+
* `ctx.measureText` in a browser and falls back to an empirical glyph-width
|
|
19
|
+
* table in SSR / Node test environments. The public `layoutPlotArea`
|
|
20
|
+
* signature is unchanged.
|
|
23
21
|
*/
|
|
24
22
|
|
|
25
23
|
import type { ChartModel } from "../../../io/ooxml/chart/types.ts";
|
|
26
24
|
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
25
|
+
import { measureText } from "../render/font-metrics.ts";
|
|
26
|
+
import { formatNumber } from "../render/number-format.ts";
|
|
27
27
|
import {
|
|
28
28
|
generateCategoryTicks,
|
|
29
|
+
generateDateTicks,
|
|
29
30
|
generateValueTicks,
|
|
30
31
|
type TickResult,
|
|
32
|
+
type TimeUnit,
|
|
31
33
|
} from "./axis-layout.ts";
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
@@ -69,7 +71,6 @@ export function layoutPlotArea(
|
|
|
69
71
|
model: ChartModel,
|
|
70
72
|
theme: ResolvedTheme | undefined,
|
|
71
73
|
): PlotAreaLayout {
|
|
72
|
-
void theme; // consumed by Slice 3B's real font-metrics wrapper.
|
|
73
74
|
|
|
74
75
|
let top = 0;
|
|
75
76
|
let bottom = canvas.h;
|
|
@@ -82,7 +83,7 @@ export function layoutPlotArea(
|
|
|
82
83
|
|
|
83
84
|
// 1. Title band (top).
|
|
84
85
|
if (model.kind !== "unsupported" && model.title && !model.title.overlay) {
|
|
85
|
-
const titleHeight = measureTitleHeight(model.title.text ?? "");
|
|
86
|
+
const titleHeight = measureTitleHeight(model.title.text ?? "", theme);
|
|
86
87
|
out.titleRect = { x: 0, y: 0, w: canvas.w, h: titleHeight };
|
|
87
88
|
top += titleHeight + BAND_GAP_PX;
|
|
88
89
|
}
|
|
@@ -90,7 +91,8 @@ export function layoutPlotArea(
|
|
|
90
91
|
// 2. Legend band — on the side indicated by `legend.position`.
|
|
91
92
|
if (model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
|
|
92
93
|
const entryCount = countLegendEntries(model);
|
|
93
|
-
const
|
|
94
|
+
const legendLabels = collectLegendLabels(model);
|
|
95
|
+
const { w: legendW, h: legendH } = measureLegendBox(entryCount, legendLabels, theme);
|
|
94
96
|
switch (model.legend.position) {
|
|
95
97
|
case "t":
|
|
96
98
|
out.legendRect = { x: 0, y: top, w: canvas.w, h: legendH };
|
|
@@ -128,18 +130,19 @@ export function layoutPlotArea(
|
|
|
128
130
|
const axes = pickAxes(model);
|
|
129
131
|
if (axes) {
|
|
130
132
|
// Y-axis on the left: width = max(tick label width) + axis-title
|
|
131
|
-
// rotated height.
|
|
132
|
-
|
|
133
|
+
// rotated height. Numeric tick values are passed through
|
|
134
|
+
// `formatNumber(value, formatCode)` so layout reserves real label width.
|
|
135
|
+
const yTickLabels = axisTickLabels(axes.y, axes.yFormatCode);
|
|
133
136
|
const yWidth =
|
|
134
|
-
maxLabelWidth(yTickLabels) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
137
|
+
maxLabelWidth(yTickLabels, theme) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
135
138
|
out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
|
|
136
139
|
left += yWidth + BAND_GAP_PX;
|
|
137
140
|
|
|
138
141
|
// Secondary Y-axis on the right.
|
|
139
142
|
if (axes.secondaryY) {
|
|
140
|
-
const y2TickLabels = axisTickLabels(axes.secondaryY);
|
|
143
|
+
const y2TickLabels = axisTickLabels(axes.secondaryY, axes.secondaryYFormatCode);
|
|
141
144
|
const y2Width =
|
|
142
|
-
maxLabelWidth(y2TickLabels) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
145
|
+
maxLabelWidth(y2TickLabels, theme) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
143
146
|
out.secondaryYAxisRect = {
|
|
144
147
|
x: right - y2Width,
|
|
145
148
|
y: top,
|
|
@@ -150,7 +153,16 @@ export function layoutPlotArea(
|
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
// X-axis on the bottom: height = label height + axis-title band.
|
|
153
|
-
|
|
156
|
+
// For category axes, sample the widest category label so tall rotated
|
|
157
|
+
// labels reserve enough vertical space; for value/date axes use a
|
|
158
|
+
// numeric sample run through formatNumber.
|
|
159
|
+
const xTickLabels = axes.x
|
|
160
|
+
? axisTickLabels(axes.x, axes.xFormatCode)
|
|
161
|
+
: axes.categoryLabels
|
|
162
|
+
? Array.from(axes.categoryLabels)
|
|
163
|
+
: yTickLabels;
|
|
164
|
+
const xSampleLabel = xTickLabels.length > 0 ? xTickLabels[0]! : "0";
|
|
165
|
+
const xLabelHeight = measureText(xSampleLabel, AXIS_TXP, theme).lineHeight;
|
|
154
166
|
const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
155
167
|
out.xAxisRect = {
|
|
156
168
|
x: left,
|
|
@@ -171,57 +183,57 @@ export function layoutPlotArea(
|
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
// ---------------------------------------------------------------------------
|
|
174
|
-
// Text measurement —
|
|
186
|
+
// Text measurement — real metrics via font-metrics.ts (Slice 3B)
|
|
175
187
|
// ---------------------------------------------------------------------------
|
|
176
188
|
|
|
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
189
|
const BAND_GAP_PX = 4;
|
|
189
190
|
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
191
|
const LEGEND_SWATCH_WIDTH_PX = 16;
|
|
195
192
|
const LEGEND_SWATCH_GAP_PX = 6;
|
|
196
193
|
const LEGEND_ENTRY_GAP_PX = 12;
|
|
197
194
|
const LEGEND_MIN_WIDTH_PX = 80;
|
|
198
195
|
|
|
199
|
-
|
|
196
|
+
const TITLE_TXP = { fontSizePt: 14 };
|
|
197
|
+
const AXIS_TXP = { fontSizePt: 10 };
|
|
198
|
+
const LEGEND_TXP = { fontSizePt: 10 };
|
|
199
|
+
|
|
200
|
+
function measureTitleHeight(text: string, theme: ResolvedTheme | undefined): number {
|
|
200
201
|
if (!text) return 0;
|
|
201
|
-
return
|
|
202
|
+
return measureText(text, TITLE_TXP, theme).lineHeight;
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
function measureLegendBox(
|
|
205
|
+
function measureLegendBox(
|
|
206
|
+
entryCount: number,
|
|
207
|
+
labels: ReadonlyArray<string>,
|
|
208
|
+
theme: ResolvedTheme | undefined,
|
|
209
|
+
): { w: number; h: number } {
|
|
205
210
|
if (entryCount <= 0) return { w: 0, h: 0 };
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
// Measure the widest label; fall back to an 8-char estimate when no labels.
|
|
212
|
+
const sampleLabels = labels.length > 0 ? labels : ["MMMMMMMM"];
|
|
213
|
+
let maxW = 0;
|
|
214
|
+
for (const label of sampleLabels) {
|
|
215
|
+
const w = measureText(label, LEGEND_TXP, theme).width;
|
|
216
|
+
if (w > maxW) maxW = w;
|
|
217
|
+
}
|
|
218
|
+
const entryWidth = LEGEND_SWATCH_WIDTH_PX + LEGEND_SWATCH_GAP_PX + maxW;
|
|
219
|
+
const lineH = measureText(sampleLabels[0]!, LEGEND_TXP, theme).lineHeight;
|
|
212
220
|
return {
|
|
213
221
|
w: Math.max(LEGEND_MIN_WIDTH_PX, entryWidth + LEGEND_ENTRY_GAP_PX),
|
|
214
|
-
h: entryCount *
|
|
222
|
+
h: entryCount * lineH,
|
|
215
223
|
};
|
|
216
224
|
}
|
|
217
225
|
|
|
218
|
-
function maxLabelWidth(
|
|
226
|
+
function maxLabelWidth(
|
|
227
|
+
labels: ReadonlyArray<string>,
|
|
228
|
+
theme: ResolvedTheme | undefined,
|
|
229
|
+
): number {
|
|
219
230
|
if (labels.length === 0) return 0;
|
|
220
|
-
let
|
|
231
|
+
let maxW = 0;
|
|
221
232
|
for (const label of labels) {
|
|
222
|
-
|
|
233
|
+
const w = measureText(label, AXIS_TXP, theme).width;
|
|
234
|
+
if (w > maxW) maxW = w;
|
|
223
235
|
}
|
|
224
|
-
return
|
|
236
|
+
return maxW;
|
|
225
237
|
}
|
|
226
238
|
|
|
227
239
|
// ---------------------------------------------------------------------------
|
|
@@ -230,11 +242,20 @@ function maxLabelWidth(labels: ReadonlyArray<string>): number {
|
|
|
230
242
|
|
|
231
243
|
interface AxisBundle {
|
|
232
244
|
y: TickResult;
|
|
245
|
+
yFormatCode?: string;
|
|
233
246
|
yTitle: boolean;
|
|
234
247
|
x?: TickResult;
|
|
248
|
+
xFormatCode?: string;
|
|
235
249
|
xTitle: boolean;
|
|
236
250
|
secondaryY?: TickResult;
|
|
251
|
+
secondaryYFormatCode?: string;
|
|
237
252
|
secondaryYTitle: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Category axis labels (for bar/line/area/combo) — pre-resolved
|
|
255
|
+
* category-axis label strings. When present, the x-axis tick band
|
|
256
|
+
* measurement uses these strings rather than numeric tick indices.
|
|
257
|
+
*/
|
|
258
|
+
categoryLabels?: ReadonlyArray<string>;
|
|
238
259
|
}
|
|
239
260
|
|
|
240
261
|
/**
|
|
@@ -254,23 +275,33 @@ function pickAxes(model: ChartModel): AxisBundle | null {
|
|
|
254
275
|
const bundle: AxisBundle = {
|
|
255
276
|
y: yTicks,
|
|
256
277
|
yTitle: !!model.valueAxis.title,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: !!model.categoryAxis.title,
|
|
278
|
+
x: axisTicks(model.categoryAxis),
|
|
279
|
+
xTitle: !!model.categoryAxis.title,
|
|
260
280
|
secondaryYTitle: !!model.secondaryValueAxis?.title,
|
|
261
281
|
};
|
|
282
|
+
if (model.valueAxis.numberFormat) bundle.yFormatCode = model.valueAxis.numberFormat;
|
|
283
|
+
if (model.categoryAxis.numberFormat) bundle.xFormatCode = model.categoryAxis.numberFormat;
|
|
262
284
|
if (y2Ticks) bundle.secondaryY = y2Ticks;
|
|
285
|
+
if (model.secondaryValueAxis?.numberFormat) {
|
|
286
|
+
bundle.secondaryYFormatCode = model.secondaryValueAxis.numberFormat;
|
|
287
|
+
}
|
|
288
|
+
if (model.categoryAxis.kind === "category") {
|
|
289
|
+
bundle.categoryLabels = model.categoryAxis.categoryLabels;
|
|
290
|
+
}
|
|
263
291
|
return bundle;
|
|
264
292
|
}
|
|
265
293
|
case "scatter":
|
|
266
294
|
case "bubble": {
|
|
267
|
-
|
|
295
|
+
const bundle: AxisBundle = {
|
|
268
296
|
y: axisTicks(model.yAxis),
|
|
269
297
|
yTitle: !!model.yAxis.title,
|
|
270
298
|
x: axisTicks(model.xAxis),
|
|
271
299
|
xTitle: !!model.xAxis.title,
|
|
272
300
|
secondaryYTitle: false,
|
|
273
301
|
};
|
|
302
|
+
if (model.yAxis.numberFormat) bundle.yFormatCode = model.yAxis.numberFormat;
|
|
303
|
+
if (model.xAxis.numberFormat) bundle.xFormatCode = model.xAxis.numberFormat;
|
|
304
|
+
return bundle;
|
|
274
305
|
}
|
|
275
306
|
case "combo": {
|
|
276
307
|
// Use the first group's axes as representative.
|
|
@@ -289,6 +320,10 @@ function axisTicks(axis: {
|
|
|
289
320
|
min?: number;
|
|
290
321
|
max?: number;
|
|
291
322
|
majorUnit?: number;
|
|
323
|
+
majorTimeUnit?: TimeUnit;
|
|
324
|
+
minorUnit?: number;
|
|
325
|
+
minorTimeUnit?: TimeUnit;
|
|
326
|
+
baseTimeUnit?: TimeUnit;
|
|
292
327
|
categoryLabels?: ReadonlyArray<string>;
|
|
293
328
|
}): TickResult {
|
|
294
329
|
if (axis.kind === "category") {
|
|
@@ -300,6 +335,20 @@ function axisTicks(axis: {
|
|
|
300
335
|
minor: [],
|
|
301
336
|
};
|
|
302
337
|
}
|
|
338
|
+
// C8: DateAxis (kind === "date") must route to generateDateTicks so Excel
|
|
339
|
+
// serial dates produce properly-stepped tick positions rather than treating
|
|
340
|
+
// serials as raw numbers and calling generateValueTicks on them.
|
|
341
|
+
if (axis.kind === "date") {
|
|
342
|
+
return generateDateTicks({
|
|
343
|
+
min: axis.min ?? 0,
|
|
344
|
+
max: axis.max ?? 1,
|
|
345
|
+
...(axis.majorUnit !== undefined ? { majorUnit: axis.majorUnit } : {}),
|
|
346
|
+
...(axis.majorTimeUnit !== undefined ? { majorTimeUnit: axis.majorTimeUnit } : {}),
|
|
347
|
+
...(axis.minorUnit !== undefined ? { minorUnit: axis.minorUnit } : {}),
|
|
348
|
+
...(axis.minorTimeUnit !== undefined ? { minorTimeUnit: axis.minorTimeUnit } : {}),
|
|
349
|
+
...(axis.baseTimeUnit !== undefined ? { baseTimeUnit: axis.baseTimeUnit } : {}),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
303
352
|
const min = axis.min ?? 0;
|
|
304
353
|
const max = axis.max ?? 1;
|
|
305
354
|
return generateValueTicks({
|
|
@@ -310,14 +359,25 @@ function axisTicks(axis: {
|
|
|
310
359
|
}
|
|
311
360
|
|
|
312
361
|
/**
|
|
313
|
-
* Produce string labels for an axis's major ticks.
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* (
|
|
362
|
+
* Produce string labels for an axis's major ticks.
|
|
363
|
+
*
|
|
364
|
+
* - Value / date axes: each tick numeric value is passed through
|
|
365
|
+
* `formatNumber(value, formatCode)` so layout reserves width for the
|
|
366
|
+
* real rendered string (e.g. `"$1,234"`, `"45%"`, `"Jan-24"`) not the
|
|
367
|
+
* raw `String(1234)` approximation.
|
|
368
|
+
* - Category axes: when categoryLabels are provided, return them verbatim
|
|
369
|
+
* (tick indices 0..N-1 map 1:1 to labels). This avoids reserving space
|
|
370
|
+
* for "0", "1", "2" when the real rendered label is "Q1", "Q2", "Q3".
|
|
318
371
|
*/
|
|
319
|
-
function axisTickLabels(
|
|
320
|
-
|
|
372
|
+
function axisTickLabels(
|
|
373
|
+
ticks: TickResult,
|
|
374
|
+
formatCode: string | undefined,
|
|
375
|
+
categoryLabels?: ReadonlyArray<string>,
|
|
376
|
+
): string[] {
|
|
377
|
+
if (categoryLabels && categoryLabels.length > 0) {
|
|
378
|
+
return ticks.major.map((i) => categoryLabels[i] ?? "");
|
|
379
|
+
}
|
|
380
|
+
return ticks.major.map((t) => formatNumber(t, formatCode));
|
|
321
381
|
}
|
|
322
382
|
|
|
323
383
|
function countLegendEntries(model: ChartModel): number {
|
|
@@ -329,7 +389,9 @@ function countLegendEntries(model: ChartModel): number {
|
|
|
329
389
|
case "bubble":
|
|
330
390
|
return model.series.length;
|
|
331
391
|
case "pie": {
|
|
332
|
-
// Pie legends show one entry per slice.
|
|
392
|
+
// Pie legends show one entry per slice. Prefer categoryLabels when
|
|
393
|
+
// resolved; fall back to series value count for degenerate inputs.
|
|
394
|
+
if (model.categoryLabels.length > 0) return model.categoryLabels.length;
|
|
333
395
|
const first = model.series[0];
|
|
334
396
|
return first ? first.values.length : 0;
|
|
335
397
|
}
|
|
@@ -342,3 +404,34 @@ function countLegendEntries(model: ChartModel): number {
|
|
|
342
404
|
return 0;
|
|
343
405
|
}
|
|
344
406
|
}
|
|
407
|
+
|
|
408
|
+
function collectLegendLabels(model: ChartModel): ReadonlyArray<string> {
|
|
409
|
+
switch (model.kind) {
|
|
410
|
+
case "bar":
|
|
411
|
+
case "line":
|
|
412
|
+
case "area":
|
|
413
|
+
case "scatter":
|
|
414
|
+
case "bubble":
|
|
415
|
+
return model.series.map((s) => s.name ?? `Series ${s.idx + 1}`);
|
|
416
|
+
case "pie": {
|
|
417
|
+
// PieChartModel exposes categoryLabels at the model level (resolved
|
|
418
|
+
// from the first series' c:cat cache during parsing). Each slice
|
|
419
|
+
// in the legend corresponds 1:1 to a category label. Fall back to
|
|
420
|
+
// synthetic "Slice N" only when the parser saw no category cache
|
|
421
|
+
// (degenerate data-only DOCX).
|
|
422
|
+
if (model.categoryLabels.length > 0) return model.categoryLabels;
|
|
423
|
+
const first = model.series[0];
|
|
424
|
+
if (!first) return [];
|
|
425
|
+
return first.values.map((_, i) => `Slice ${i + 1}`);
|
|
426
|
+
}
|
|
427
|
+
case "combo": {
|
|
428
|
+
const labels: string[] = [];
|
|
429
|
+
for (const g of model.groups) {
|
|
430
|
+
for (const s of g.series) labels.push(s.name ?? `Series ${s.idx + 1}`);
|
|
431
|
+
}
|
|
432
|
+
return labels;
|
|
433
|
+
}
|
|
434
|
+
case "unsupported":
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title layout — chart title + axis titles (Stage 3C).
|
|
3
|
+
*
|
|
4
|
+
* Chart title:
|
|
5
|
+
* - Centered horizontally within the reserved title band.
|
|
6
|
+
* - Vertical baseline respects the title font's ascent/descent.
|
|
7
|
+
* - Rich text (c:tx/c:rich) is not yet unpacked — the title.text
|
|
8
|
+
* string from the parser is treated as the full title. Rich runs
|
|
9
|
+
* land in Stage 4G alongside data labels.
|
|
10
|
+
*
|
|
11
|
+
* Axis titles:
|
|
12
|
+
* - Y-axis: rotated −90° (anti-clockwise). Title sits in the leftmost
|
|
13
|
+
* column of the y-axis band, vertically centered on the plot area.
|
|
14
|
+
* - Secondary y-axis: rotated +90° so the baseline reads top-to-bottom
|
|
15
|
+
* on the right side. Title sits in the rightmost column of the
|
|
16
|
+
* secondary y-axis band.
|
|
17
|
+
* - X-axis: no rotation. Title sits below the tick-label band,
|
|
18
|
+
* horizontally centered on the plot area.
|
|
19
|
+
*
|
|
20
|
+
* The helpers return text-placement descriptors (x/y position, font
|
|
21
|
+
* size, rotation, anchor) rather than SVG elements — renderers compose
|
|
22
|
+
* these via `svgText` from `svg-primitives.ts`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Rect } from "./plot-area.ts";
|
|
26
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
27
|
+
import { measureText } from "../render/font-metrics.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Public API
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type AxisTitleKind = "x" | "y" | "secondaryY";
|
|
34
|
+
|
|
35
|
+
export interface TitlePlacement {
|
|
36
|
+
text: string;
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
fontSizePt: number;
|
|
40
|
+
bold: boolean;
|
|
41
|
+
/** SVG rotation in degrees, clockwise. 0 = upright. */
|
|
42
|
+
rotate: number;
|
|
43
|
+
/** SVG text-anchor: where the (x,y) anchor point lies on the glyph run. */
|
|
44
|
+
anchor: "start" | "middle" | "end";
|
|
45
|
+
/** Vertical alignment of the (x,y) point to the glyph row. */
|
|
46
|
+
baseline: "hanging" | "middle" | "auto";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TitleLayoutOptions {
|
|
50
|
+
/** Font size in points. Word default: 14pt for chart title, 10pt for axis titles. */
|
|
51
|
+
fontSizePt?: number;
|
|
52
|
+
/** Whether the title is rendered in bold. Word chart title default: true. */
|
|
53
|
+
bold?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Chart title
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Place a chart title centered horizontally inside `titleRect`. The
|
|
62
|
+
* returned placement anchors at the middle of the title band and uses
|
|
63
|
+
* `text-anchor: middle` so the glyph run spans equally on each side.
|
|
64
|
+
*
|
|
65
|
+
* Returns `null` when text is empty — callers should not emit a title
|
|
66
|
+
* element at all in that case.
|
|
67
|
+
*/
|
|
68
|
+
export function layoutTitle(
|
|
69
|
+
text: string,
|
|
70
|
+
titleRect: Rect,
|
|
71
|
+
theme: ResolvedTheme | undefined,
|
|
72
|
+
options: TitleLayoutOptions = {},
|
|
73
|
+
): TitlePlacement | null {
|
|
74
|
+
if (!text) return null;
|
|
75
|
+
const fontSizePt = options.fontSizePt ?? 14;
|
|
76
|
+
const bold = options.bold ?? true;
|
|
77
|
+
// Consume theme for future font-fallback resolution.
|
|
78
|
+
void theme;
|
|
79
|
+
const centerX = titleRect.x + titleRect.w / 2;
|
|
80
|
+
const centerY = titleRect.y + titleRect.h / 2;
|
|
81
|
+
return {
|
|
82
|
+
text,
|
|
83
|
+
x: centerX,
|
|
84
|
+
y: centerY,
|
|
85
|
+
fontSizePt,
|
|
86
|
+
bold,
|
|
87
|
+
rotate: 0,
|
|
88
|
+
anchor: "middle",
|
|
89
|
+
baseline: "middle",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Axis titles
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Place an axis title inside the axis-title band. The `kind` determines
|
|
99
|
+
* rotation:
|
|
100
|
+
* - `"y"` → −90° (baseline reads bottom-to-top on the left)
|
|
101
|
+
* - `"secondaryY"` → +90° (baseline reads top-to-bottom on the right)
|
|
102
|
+
* - `"x"` → 0° (no rotation)
|
|
103
|
+
*
|
|
104
|
+
* `bandRect` is the rectangle the plot-area reserved for the axis title
|
|
105
|
+
* itself (NOT the full axis band — just the title sliver). For y / sY
|
|
106
|
+
* this is typically ~14px wide (the `AXIS_TITLE_BAND_PX` constant from
|
|
107
|
+
* plot-area.ts); for x it's ~14px tall.
|
|
108
|
+
*
|
|
109
|
+
* `plotRect` is the plot area the title labels. The title is visually
|
|
110
|
+
* centered against the plot area (not the band) so it aligns with the
|
|
111
|
+
* mid-point of the axis data range.
|
|
112
|
+
*/
|
|
113
|
+
export function layoutAxisTitle(
|
|
114
|
+
text: string,
|
|
115
|
+
kind: AxisTitleKind,
|
|
116
|
+
bandRect: Rect,
|
|
117
|
+
plotRect: Rect,
|
|
118
|
+
theme: ResolvedTheme | undefined,
|
|
119
|
+
options: TitleLayoutOptions = {},
|
|
120
|
+
): TitlePlacement | null {
|
|
121
|
+
if (!text) return null;
|
|
122
|
+
const fontSizePt = options.fontSizePt ?? 10;
|
|
123
|
+
const bold = options.bold ?? false;
|
|
124
|
+
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case "y": {
|
|
127
|
+
// Vertical center on the plot, rotated -90°. Anchor at band center x.
|
|
128
|
+
// Consume theme to keep signature future-proof.
|
|
129
|
+
void theme;
|
|
130
|
+
return {
|
|
131
|
+
text,
|
|
132
|
+
x: bandRect.x + bandRect.w / 2,
|
|
133
|
+
y: plotRect.y + plotRect.h / 2,
|
|
134
|
+
fontSizePt,
|
|
135
|
+
bold,
|
|
136
|
+
rotate: -90,
|
|
137
|
+
anchor: "middle",
|
|
138
|
+
baseline: "middle",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "secondaryY": {
|
|
142
|
+
return {
|
|
143
|
+
text,
|
|
144
|
+
x: bandRect.x + bandRect.w / 2,
|
|
145
|
+
y: plotRect.y + plotRect.h / 2,
|
|
146
|
+
fontSizePt,
|
|
147
|
+
bold,
|
|
148
|
+
rotate: 90,
|
|
149
|
+
anchor: "middle",
|
|
150
|
+
baseline: "middle",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
case "x": {
|
|
154
|
+
return {
|
|
155
|
+
text,
|
|
156
|
+
x: plotRect.x + plotRect.w / 2,
|
|
157
|
+
y: bandRect.y + bandRect.h / 2,
|
|
158
|
+
fontSizePt,
|
|
159
|
+
bold,
|
|
160
|
+
rotate: 0,
|
|
161
|
+
anchor: "middle",
|
|
162
|
+
baseline: "middle",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Measurement helper for band sizing
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Measure the title height for the given text + font size, matching the
|
|
174
|
+
* row height a single-line title would occupy. Callers can use this to
|
|
175
|
+
* validate `titleRect.h` was reserved adequately before rendering.
|
|
176
|
+
*/
|
|
177
|
+
export function measureTitleBandHeight(
|
|
178
|
+
text: string,
|
|
179
|
+
fontSizePt: number,
|
|
180
|
+
theme: ResolvedTheme | undefined,
|
|
181
|
+
): number {
|
|
182
|
+
if (!text) return 0;
|
|
183
|
+
return measureText(text, { fontSizePt, bold: true }, theme).lineHeight;
|
|
184
|
+
}
|