@beyondwork/docx-react-component 1.0.52 → 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 +31 -40
- package/src/api/public-types.ts +67 -7
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +217 -23
- 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 +88 -8
- package/src/runtime/document-runtime.ts +182 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +97 -2
- 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 +70 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/editor-runtime-boundary.ts +39 -2
- 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/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
- 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/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +275 -46
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
- 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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend layout — entry placement with multi-row wrapping (Stage 3C).
|
|
3
|
+
*
|
|
4
|
+
* The layout is pure geometry: given a rectangle the plot-area reserved
|
|
5
|
+
* for the legend, a list of entries (label + swatch color), and the
|
|
6
|
+
* declared position (`b`/`t`/`l`/`r`/`tr`), compute the position of each
|
|
7
|
+
* swatch + label and the bounding box actually consumed.
|
|
8
|
+
*
|
|
9
|
+
* Rules (matching Word's observed behavior at 100% zoom):
|
|
10
|
+
*
|
|
11
|
+
* - Horizontal legends (position `b` / `t`) flow entries left-to-right
|
|
12
|
+
* and wrap to a new row when the next entry would overflow the
|
|
13
|
+
* available width. Entries within a row are gap-separated.
|
|
14
|
+
*
|
|
15
|
+
* - Vertical legends (position `l` / `r` / `tr`) stack entries top-to-
|
|
16
|
+
* bottom, one per row. Excess entries are not clipped — the bbox
|
|
17
|
+
* grows downward and the caller is responsible for deciding how to
|
|
18
|
+
* handle overflow.
|
|
19
|
+
*
|
|
20
|
+
* - Every entry gets a swatch of a fixed pixel width, followed by a
|
|
21
|
+
* gap, followed by the label. The label width is computed from the
|
|
22
|
+
* real `measureText` helper (Slice 3B).
|
|
23
|
+
*
|
|
24
|
+
* The returned `entries` array preserves input order so renderers can
|
|
25
|
+
* walk entries alongside their swatch color without a second lookup.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Rect } from "./plot-area.ts";
|
|
29
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
30
|
+
import { measureText } from "../render/font-metrics.ts";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Public API
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export type LegendPosition = "b" | "t" | "l" | "r" | "tr";
|
|
37
|
+
|
|
38
|
+
export interface LegendEntryInput {
|
|
39
|
+
label: string;
|
|
40
|
+
/** Resolved sRGB color for the swatch (e.g. "#4472C4"). */
|
|
41
|
+
color: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LegendEntryRect {
|
|
45
|
+
label: string;
|
|
46
|
+
color: string;
|
|
47
|
+
swatchRect: Rect;
|
|
48
|
+
labelRect: Rect;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LegendLayout {
|
|
52
|
+
/** Bounding box actually consumed inside the input `rect`. */
|
|
53
|
+
bbox: Rect;
|
|
54
|
+
/** Per-entry placement, in input order. */
|
|
55
|
+
entries: LegendEntryRect[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LegendLayoutOptions {
|
|
59
|
+
/** Font size in points for the legend label. Word default: 10pt. */
|
|
60
|
+
labelFontSizePt?: number;
|
|
61
|
+
/** Swatch width in pixels. Word default: ~10px for chart legends. */
|
|
62
|
+
swatchWidthPx?: number;
|
|
63
|
+
/** Swatch height in pixels. Defaults to `swatchWidthPx`. */
|
|
64
|
+
swatchHeightPx?: number;
|
|
65
|
+
/** Gap between swatch and label in pixels. Word default: 4px. */
|
|
66
|
+
swatchToLabelGapPx?: number;
|
|
67
|
+
/** Horizontal gap between entries in a row. Word default: 12px. */
|
|
68
|
+
entryGapPx?: number;
|
|
69
|
+
/** Vertical gap between rows. Default 2px. */
|
|
70
|
+
rowGapPx?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Defaults
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const DEFAULT_LABEL_FONT_PT = 10;
|
|
78
|
+
const DEFAULT_SWATCH_WIDTH = 10;
|
|
79
|
+
const DEFAULT_SWATCH_TO_LABEL_GAP = 4;
|
|
80
|
+
const DEFAULT_ENTRY_GAP = 12;
|
|
81
|
+
const DEFAULT_ROW_GAP = 2;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Implementation
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compute the layout of legend entries inside `rect`, honoring
|
|
89
|
+
* `position`. Returns the consumed `bbox` (so callers can re-tighten
|
|
90
|
+
* the plot-area rectangle) and per-entry swatch + label sub-rects.
|
|
91
|
+
*/
|
|
92
|
+
export function layoutLegend(
|
|
93
|
+
entries: ReadonlyArray<LegendEntryInput>,
|
|
94
|
+
rect: Rect,
|
|
95
|
+
position: LegendPosition,
|
|
96
|
+
theme: ResolvedTheme | undefined,
|
|
97
|
+
options: LegendLayoutOptions = {},
|
|
98
|
+
): LegendLayout {
|
|
99
|
+
if (entries.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
bbox: { x: rect.x, y: rect.y, w: 0, h: 0 },
|
|
102
|
+
entries: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const labelFontPt = options.labelFontSizePt ?? DEFAULT_LABEL_FONT_PT;
|
|
107
|
+
const swatchW = options.swatchWidthPx ?? DEFAULT_SWATCH_WIDTH;
|
|
108
|
+
const swatchH = options.swatchHeightPx ?? swatchW;
|
|
109
|
+
const swatchGap = options.swatchToLabelGapPx ?? DEFAULT_SWATCH_TO_LABEL_GAP;
|
|
110
|
+
const entryGap = options.entryGapPx ?? DEFAULT_ENTRY_GAP;
|
|
111
|
+
const rowGap = options.rowGapPx ?? DEFAULT_ROW_GAP;
|
|
112
|
+
const txPr = { fontSizePt: labelFontPt };
|
|
113
|
+
|
|
114
|
+
// Pre-measure every label so wrap decisions can be made without
|
|
115
|
+
// re-measuring (the LRU cache in font-metrics makes this O(unique)).
|
|
116
|
+
const measured = entries.map((e) => {
|
|
117
|
+
const m = measureText(e.label, txPr, theme);
|
|
118
|
+
return {
|
|
119
|
+
entry: e,
|
|
120
|
+
labelWidth: m.width,
|
|
121
|
+
lineHeight: m.lineHeight,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
const rowHeight = Math.max(swatchH, measured[0]!.lineHeight);
|
|
125
|
+
const entryWidth = (m: typeof measured[0]): number =>
|
|
126
|
+
swatchW + swatchGap + m.labelWidth;
|
|
127
|
+
|
|
128
|
+
// Vertical legends (l / r / tr) — one entry per row, no wrapping.
|
|
129
|
+
if (position === "l" || position === "r" || position === "tr") {
|
|
130
|
+
const maxWidth = Math.max(...measured.map(entryWidth));
|
|
131
|
+
const outEntries: LegendEntryRect[] = [];
|
|
132
|
+
let cursorY = rect.y;
|
|
133
|
+
for (const m of measured) {
|
|
134
|
+
const entryRect = placeVertical(m, rect.x, cursorY, swatchW, swatchH, swatchGap, rowHeight);
|
|
135
|
+
outEntries.push(entryRect);
|
|
136
|
+
cursorY += rowHeight + rowGap;
|
|
137
|
+
}
|
|
138
|
+
const consumedH = measured.length * rowHeight + (measured.length - 1) * rowGap;
|
|
139
|
+
return {
|
|
140
|
+
bbox: { x: rect.x, y: rect.y, w: maxWidth, h: consumedH },
|
|
141
|
+
entries: outEntries,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Horizontal legends (b / t) — row-flow with wrapping.
|
|
146
|
+
const outEntries: LegendEntryRect[] = [];
|
|
147
|
+
let cursorX = rect.x;
|
|
148
|
+
let cursorY = rect.y;
|
|
149
|
+
let rowStart = 0; // index of first entry in current row
|
|
150
|
+
let maxConsumedW = 0;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < measured.length; i++) {
|
|
153
|
+
const m = measured[i]!;
|
|
154
|
+
const w = entryWidth(m);
|
|
155
|
+
const isRowStart = i === rowStart;
|
|
156
|
+
const nextX = isRowStart ? cursorX + w : cursorX + entryGap + w;
|
|
157
|
+
const overflows = !isRowStart && nextX > rect.x + rect.w;
|
|
158
|
+
if (overflows) {
|
|
159
|
+
// Wrap: center-align current row inside rect.w, move cursor.
|
|
160
|
+
const consumed = cursorX - rect.x;
|
|
161
|
+
if (consumed > maxConsumedW) maxConsumedW = consumed;
|
|
162
|
+
cursorX = rect.x;
|
|
163
|
+
cursorY += rowHeight + rowGap;
|
|
164
|
+
rowStart = i;
|
|
165
|
+
}
|
|
166
|
+
const xStart = cursorX + (i === rowStart ? 0 : entryGap);
|
|
167
|
+
outEntries.push(placeHorizontal(m, xStart, cursorY, swatchW, swatchH, swatchGap, rowHeight));
|
|
168
|
+
cursorX = xStart + w;
|
|
169
|
+
}
|
|
170
|
+
const finalRowConsumed = cursorX - rect.x;
|
|
171
|
+
if (finalRowConsumed > maxConsumedW) maxConsumedW = finalRowConsumed;
|
|
172
|
+
const totalHeight = cursorY + rowHeight - rect.y;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
bbox: { x: rect.x, y: rect.y, w: maxConsumedW, h: totalHeight },
|
|
176
|
+
entries: outEntries,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Placement helpers
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
function placeHorizontal(
|
|
185
|
+
m: { entry: LegendEntryInput; labelWidth: number; lineHeight: number },
|
|
186
|
+
x: number,
|
|
187
|
+
y: number,
|
|
188
|
+
swatchW: number,
|
|
189
|
+
swatchH: number,
|
|
190
|
+
swatchGap: number,
|
|
191
|
+
rowH: number,
|
|
192
|
+
): LegendEntryRect {
|
|
193
|
+
// Vertically center swatch + label inside the row.
|
|
194
|
+
const swatchY = y + (rowH - swatchH) / 2;
|
|
195
|
+
const labelY = y + (rowH - m.lineHeight) / 2;
|
|
196
|
+
return {
|
|
197
|
+
label: m.entry.label,
|
|
198
|
+
color: m.entry.color,
|
|
199
|
+
swatchRect: { x, y: swatchY, w: swatchW, h: swatchH },
|
|
200
|
+
labelRect: {
|
|
201
|
+
x: x + swatchW + swatchGap,
|
|
202
|
+
y: labelY,
|
|
203
|
+
w: m.labelWidth,
|
|
204
|
+
h: m.lineHeight,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function placeVertical(
|
|
210
|
+
m: { entry: LegendEntryInput; labelWidth: number; lineHeight: number },
|
|
211
|
+
x: number,
|
|
212
|
+
y: number,
|
|
213
|
+
swatchW: number,
|
|
214
|
+
swatchH: number,
|
|
215
|
+
swatchGap: number,
|
|
216
|
+
rowH: number,
|
|
217
|
+
): LegendEntryRect {
|
|
218
|
+
const swatchY = y + (rowH - swatchH) / 2;
|
|
219
|
+
const labelY = y + (rowH - m.lineHeight) / 2;
|
|
220
|
+
return {
|
|
221
|
+
label: m.entry.label,
|
|
222
|
+
color: m.entry.color,
|
|
223
|
+
swatchRect: { x, y: swatchY, w: swatchW, h: swatchH },
|
|
224
|
+
labelRect: {
|
|
225
|
+
x: x + swatchW + swatchGap,
|
|
226
|
+
y: labelY,
|
|
227
|
+
w: m.labelWidth,
|
|
228
|
+
h: m.lineHeight,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -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
|
+
}
|