@beyondwork/docx-react-component 1.0.56 → 1.0.57
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 +157 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +107 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +415 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +693 -41
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +186 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +168 -10
- package/src/ui/editor-runtime-boundary.ts +94 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/validation/compatibility-engine.ts +2 -0
- package/src/validation/docx-comment-proof.ts +12 -3
|
@@ -57,20 +57,80 @@ export interface CanvasRect {
|
|
|
57
57
|
h: number;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Which chrome bands are actually painted by the caller. Unpainted chrome
|
|
62
|
+
* is excluded from band reservation so the plot area fills the canvas
|
|
63
|
+
* gracefully instead of rendering with empty margins around unrendered
|
|
64
|
+
* title / legend / axis-label chrome.
|
|
65
|
+
*
|
|
66
|
+
* In v1 the chart renderer paints only the plot-area body — no component
|
|
67
|
+
* currently paints title, legend, or axis tick-labels (documented gap in
|
|
68
|
+
* docs/wiki/images-and-media.md §"Known gaps"). The default constant below
|
|
69
|
+
* reflects that reality so charts render edge-to-edge. When Stage-C chrome
|
|
70
|
+
* wire-up lands and a renderer begins painting one of these bands, flip
|
|
71
|
+
* the matching flag in `CHROME_PAINTED_DEFAULT` and the band reservation
|
|
72
|
+
* re-engages automatically.
|
|
73
|
+
*/
|
|
74
|
+
export interface ChromePaintedSet {
|
|
75
|
+
title?: boolean;
|
|
76
|
+
legend?: boolean;
|
|
77
|
+
xAxis?: boolean;
|
|
78
|
+
yAxis?: boolean;
|
|
79
|
+
secondaryYAxis?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default: nothing paints. Callers that do want band math (tests exercising
|
|
84
|
+
* layout, or Stage-C callers after chrome wires up) opt in explicitly via
|
|
85
|
+
* `options.paintedChrome`.
|
|
86
|
+
*/
|
|
87
|
+
export const CHROME_PAINTED_DEFAULT: Required<ChromePaintedSet> = {
|
|
88
|
+
title: false,
|
|
89
|
+
legend: false,
|
|
90
|
+
xAxis: false,
|
|
91
|
+
yAxis: false,
|
|
92
|
+
secondaryYAxis: false,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Legacy math mode — reserves every band as if every chrome element
|
|
97
|
+
* will paint. Exported so existing layout tests that verify band-math
|
|
98
|
+
* correctness can opt in without every test restating the shape.
|
|
99
|
+
*/
|
|
100
|
+
export const CHROME_PAINTED_ALL: Required<ChromePaintedSet> = {
|
|
101
|
+
title: true,
|
|
102
|
+
legend: true,
|
|
103
|
+
xAxis: true,
|
|
104
|
+
yAxis: true,
|
|
105
|
+
secondaryYAxis: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface LayoutPlotAreaOptions {
|
|
109
|
+
paintedChrome?: ChromePaintedSet;
|
|
110
|
+
}
|
|
111
|
+
|
|
60
112
|
/**
|
|
61
113
|
* Partition the canvas into labelled sub-rectangles. The returned
|
|
62
114
|
* `plotRect` is the rectangle the chart-type renderer draws into.
|
|
63
115
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
116
|
+
* Graceful degradation: only chrome flagged `true` in
|
|
117
|
+
* `options.paintedChrome` reserves a band. Unpainted chrome is skipped
|
|
118
|
+
* so the plot area fills the canvas instead of leaving empty margins.
|
|
119
|
+
* **Back-compat**: when `options` is omitted, every band is reserved
|
|
120
|
+
* (`CHROME_PAINTED_ALL` semantics) — this preserves every existing test's
|
|
121
|
+
* expectations. Runtime callers (`ChartSurface`) explicitly pass
|
|
122
|
+
* `CHROME_PAINTED_DEFAULT` to opt into the graceful-degradation path.
|
|
68
123
|
*/
|
|
69
124
|
export function layoutPlotArea(
|
|
70
125
|
canvas: CanvasRect,
|
|
71
126
|
model: ChartModel,
|
|
72
127
|
theme: ResolvedTheme | undefined,
|
|
128
|
+
options?: LayoutPlotAreaOptions,
|
|
73
129
|
): PlotAreaLayout {
|
|
130
|
+
const painted: Required<ChromePaintedSet> = {
|
|
131
|
+
...CHROME_PAINTED_ALL,
|
|
132
|
+
...(options?.paintedChrome ?? {}),
|
|
133
|
+
};
|
|
74
134
|
|
|
75
135
|
let top = 0;
|
|
76
136
|
let bottom = canvas.h;
|
|
@@ -81,15 +141,16 @@ export function layoutPlotArea(
|
|
|
81
141
|
plotRect: { x: 0, y: 0, w: canvas.w, h: canvas.h },
|
|
82
142
|
};
|
|
83
143
|
|
|
84
|
-
// 1. Title band (top).
|
|
85
|
-
if (model.kind !== "unsupported" && model.title && !model.title.overlay) {
|
|
144
|
+
// 1. Title band (top). Only reserve when the caller actually paints it.
|
|
145
|
+
if (painted.title && model.kind !== "unsupported" && model.title && !model.title.overlay) {
|
|
86
146
|
const titleHeight = measureTitleHeight(model.title.text ?? "", theme);
|
|
87
147
|
out.titleRect = { x: 0, y: 0, w: canvas.w, h: titleHeight };
|
|
88
148
|
top += titleHeight + BAND_GAP_PX;
|
|
89
149
|
}
|
|
90
150
|
|
|
91
|
-
// 2. Legend band — on the side indicated by `legend.position`.
|
|
92
|
-
|
|
151
|
+
// 2. Legend band — on the side indicated by `legend.position`. Only
|
|
152
|
+
// reserve when the caller actually paints it.
|
|
153
|
+
if (painted.legend && model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
|
|
93
154
|
const entryCount = countLegendEntries(model);
|
|
94
155
|
const legendLabels = collectLegendLabels(model);
|
|
95
156
|
const { w: legendW, h: legendH } = measureLegendBox(entryCount, legendLabels, theme);
|
|
@@ -127,50 +188,55 @@ export function layoutPlotArea(
|
|
|
127
188
|
|
|
128
189
|
// 3. Axis bands. Only cartesian families (bar/line/area/scatter/bubble/
|
|
129
190
|
// combo) reserve axis space; pie/doughnut/unsupported skip this entirely.
|
|
191
|
+
// Each band is additionally gated on (a) the caller having opted into
|
|
192
|
+
// painting that chrome, and (b) the axis itself not being flagged
|
|
193
|
+
// invisible via `c:delete val="1"`. An axis that won't be drawn must
|
|
194
|
+
// not reserve layout space.
|
|
130
195
|
const axes = pickAxes(model);
|
|
131
196
|
if (axes) {
|
|
132
|
-
// Y-axis on the left
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
197
|
+
// Y-axis on the left.
|
|
198
|
+
if (painted.yAxis && axes.yVisible) {
|
|
199
|
+
const yTickLabels = axisTickLabels(axes.y, axes.yFormatCode);
|
|
200
|
+
const yWidth =
|
|
201
|
+
maxLabelWidth(yTickLabels, theme) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
202
|
+
out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
|
|
203
|
+
left += yWidth + BAND_GAP_PX;
|
|
204
|
+
}
|
|
140
205
|
|
|
141
206
|
// Secondary Y-axis on the right.
|
|
142
207
|
if (axes.secondaryY) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
208
|
+
if (painted.secondaryYAxis && axes.secondaryYVisible) {
|
|
209
|
+
const y2TickLabels = axisTickLabels(axes.secondaryY, axes.secondaryYFormatCode);
|
|
210
|
+
const y2Width =
|
|
211
|
+
maxLabelWidth(y2TickLabels, theme) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
212
|
+
out.secondaryYAxisRect = {
|
|
213
|
+
x: right - y2Width,
|
|
214
|
+
y: top,
|
|
215
|
+
w: y2Width,
|
|
216
|
+
h: bottom - top,
|
|
217
|
+
};
|
|
218
|
+
right -= y2Width + BAND_GAP_PX;
|
|
219
|
+
}
|
|
153
220
|
}
|
|
154
221
|
|
|
155
|
-
// X-axis on the bottom
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
bottom -= xHeight + BAND_GAP_PX;
|
|
222
|
+
// X-axis on the bottom.
|
|
223
|
+
if (painted.xAxis && axes.xVisible) {
|
|
224
|
+
const xTickLabels = axes.x
|
|
225
|
+
? axisTickLabels(axes.x, axes.xFormatCode)
|
|
226
|
+
: axes.categoryLabels
|
|
227
|
+
? Array.from(axes.categoryLabels)
|
|
228
|
+
: axisTickLabels(axes.y, axes.yFormatCode);
|
|
229
|
+
const xSampleLabel = xTickLabels.length > 0 ? xTickLabels[0]! : "0";
|
|
230
|
+
const xLabelHeight = measureText(xSampleLabel, AXIS_TXP, theme).lineHeight;
|
|
231
|
+
const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
232
|
+
out.xAxisRect = {
|
|
233
|
+
x: left,
|
|
234
|
+
y: bottom - xHeight,
|
|
235
|
+
w: right - left,
|
|
236
|
+
h: xHeight,
|
|
237
|
+
};
|
|
238
|
+
bottom -= xHeight + BAND_GAP_PX;
|
|
239
|
+
}
|
|
174
240
|
}
|
|
175
241
|
|
|
176
242
|
out.plotRect = {
|
|
@@ -244,12 +310,15 @@ interface AxisBundle {
|
|
|
244
310
|
y: TickResult;
|
|
245
311
|
yFormatCode?: string;
|
|
246
312
|
yTitle: boolean;
|
|
313
|
+
yVisible: boolean;
|
|
247
314
|
x?: TickResult;
|
|
248
315
|
xFormatCode?: string;
|
|
249
316
|
xTitle: boolean;
|
|
317
|
+
xVisible: boolean;
|
|
250
318
|
secondaryY?: TickResult;
|
|
251
319
|
secondaryYFormatCode?: string;
|
|
252
320
|
secondaryYTitle: boolean;
|
|
321
|
+
secondaryYVisible: boolean;
|
|
253
322
|
/**
|
|
254
323
|
* Category axis labels (for bar/line/area/combo) — pre-resolved
|
|
255
324
|
* category-axis label strings. When present, the x-axis tick band
|
|
@@ -275,9 +344,12 @@ function pickAxes(model: ChartModel): AxisBundle | null {
|
|
|
275
344
|
const bundle: AxisBundle = {
|
|
276
345
|
y: yTicks,
|
|
277
346
|
yTitle: !!model.valueAxis.title,
|
|
347
|
+
yVisible: model.valueAxis.visible !== false,
|
|
278
348
|
x: axisTicks(model.categoryAxis),
|
|
279
349
|
xTitle: !!model.categoryAxis.title,
|
|
350
|
+
xVisible: model.categoryAxis.visible !== false,
|
|
280
351
|
secondaryYTitle: !!model.secondaryValueAxis?.title,
|
|
352
|
+
secondaryYVisible: model.secondaryValueAxis?.visible !== false,
|
|
281
353
|
};
|
|
282
354
|
if (model.valueAxis.numberFormat) bundle.yFormatCode = model.valueAxis.numberFormat;
|
|
283
355
|
if (model.categoryAxis.numberFormat) bundle.xFormatCode = model.categoryAxis.numberFormat;
|
|
@@ -295,9 +367,12 @@ function pickAxes(model: ChartModel): AxisBundle | null {
|
|
|
295
367
|
const bundle: AxisBundle = {
|
|
296
368
|
y: axisTicks(model.yAxis),
|
|
297
369
|
yTitle: !!model.yAxis.title,
|
|
370
|
+
yVisible: model.yAxis.visible !== false,
|
|
298
371
|
x: axisTicks(model.xAxis),
|
|
299
372
|
xTitle: !!model.xAxis.title,
|
|
373
|
+
xVisible: model.xAxis.visible !== false,
|
|
300
374
|
secondaryYTitle: false,
|
|
375
|
+
secondaryYVisible: false,
|
|
301
376
|
};
|
|
302
377
|
if (model.yAxis.numberFormat) bundle.yFormatCode = model.yAxis.numberFormat;
|
|
303
378
|
if (model.xAxis.numberFormat) bundle.xFormatCode = model.xAxis.numberFormat;
|
|
@@ -39,9 +39,20 @@ export interface AreaChartProps {
|
|
|
39
39
|
model: AreaChartModel;
|
|
40
40
|
layout: PlotAreaLayout;
|
|
41
41
|
theme: ResolvedTheme | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Offset added to each series' palette index. Used by combo charts so
|
|
44
|
+
* series in group N don't collide with group (N-1)'s palette slots.
|
|
45
|
+
* Defaults to 0 for standalone render.
|
|
46
|
+
*/
|
|
47
|
+
seriesIndexOffset?: number;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
function AreaChartImpl({
|
|
50
|
+
function AreaChartImpl({
|
|
51
|
+
model,
|
|
52
|
+
layout,
|
|
53
|
+
theme,
|
|
54
|
+
seriesIndexOffset = 0,
|
|
55
|
+
}: AreaChartProps): React.ReactElement {
|
|
45
56
|
const plot = layout.plotRect;
|
|
46
57
|
const percent = model.grouping === "percentStacked";
|
|
47
58
|
const stacked = model.grouping === "stacked" || percent;
|
|
@@ -58,15 +69,22 @@ function AreaChartImpl({ model, layout, theme }: AreaChartProps): React.ReactEle
|
|
|
58
69
|
const valueSpan = Math.max(1e-9, valueMax - valueMin);
|
|
59
70
|
|
|
60
71
|
const toX = (c: number): number => plot.x + (c + 0.5) * slotWidth;
|
|
61
|
-
const
|
|
72
|
+
const reverseY = model.valueAxis.reverse === true;
|
|
73
|
+
const toY = (v: number): number => {
|
|
74
|
+
const frac = (v - valueMin) / valueSpan;
|
|
75
|
+
return reverseY ? plot.y + frac * plot.h : plot.y + plot.h - frac * plot.h;
|
|
76
|
+
};
|
|
62
77
|
|
|
63
78
|
// Pre-compute per-category cumulative bases (for stacked) + tops.
|
|
64
|
-
|
|
79
|
+
// Stack baseline is the chart's zero line, not the axis minimum — clamp
|
|
80
|
+
// so negative-min axes don't balloon the lowest series below zero.
|
|
81
|
+
const stackBaseline = Math.max(0, valueMin);
|
|
82
|
+
const lowerBounds = new Array<number>(categoryCount).fill(stackBaseline);
|
|
65
83
|
const paths: React.ReactElement[] = [];
|
|
66
84
|
|
|
67
85
|
for (let s = 0; s < model.series.length; s++) {
|
|
68
86
|
const series = model.series[s]!;
|
|
69
|
-
const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
|
|
87
|
+
const color = composeSeriesColor(model, theme ?? { colors: {} }, s + seriesIndexOffset);
|
|
70
88
|
|
|
71
89
|
// Compute this series' upper-boundary values and lower-boundary values.
|
|
72
90
|
const upper: Array<number | null> = [];
|
|
@@ -46,6 +46,12 @@ export interface BarColumnChartProps {
|
|
|
46
46
|
layout: PlotAreaLayout;
|
|
47
47
|
theme: ResolvedTheme | undefined;
|
|
48
48
|
defs?: DefsRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* Offset added to each series' palette index. Used by combo charts so
|
|
51
|
+
* series in group N don't collide with group (N-1)'s palette slots.
|
|
52
|
+
* Defaults to 0 for standalone render.
|
|
53
|
+
*/
|
|
54
|
+
seriesIndexOffset?: number;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function BarColumnChartImpl({
|
|
@@ -53,6 +59,7 @@ function BarColumnChartImpl({
|
|
|
53
59
|
layout,
|
|
54
60
|
theme,
|
|
55
61
|
defs,
|
|
62
|
+
seriesIndexOffset = 0,
|
|
56
63
|
}: BarColumnChartProps): React.ReactElement {
|
|
57
64
|
const defsRegistry = defs ?? new DefsRegistry();
|
|
58
65
|
const horizontal = model.direction === "bar";
|
|
@@ -87,30 +94,48 @@ function BarColumnChartImpl({
|
|
|
87
94
|
|
|
88
95
|
// --- value → plot-coordinate ---
|
|
89
96
|
const valueSpan = Math.max(1e-9, valueMax - valueMin);
|
|
97
|
+
const reverseValueAxis = model.valueAxis.reverse === true;
|
|
90
98
|
const valueToPlot = (v: number): number => {
|
|
91
99
|
const frac = (v - valueMin) / valueSpan;
|
|
92
|
-
|
|
100
|
+
if (horizontal) {
|
|
101
|
+
return reverseValueAxis ? plot.x + plot.w - frac * plot.w : plot.x + frac * plot.w;
|
|
102
|
+
}
|
|
103
|
+
return reverseValueAxis ? plot.y + frac * plot.h : plot.y + plot.h - frac * plot.h;
|
|
93
104
|
};
|
|
94
105
|
|
|
95
106
|
// --- emit bars ---
|
|
96
107
|
const bars: React.ReactElement[] = [];
|
|
97
108
|
|
|
98
109
|
if (percent) {
|
|
99
|
-
// Normalize each slot to 100%;
|
|
100
|
-
//
|
|
101
|
-
//
|
|
110
|
+
// Normalize each slot to ±100%; contributions divide by totalsAbs and
|
|
111
|
+
// multiply by 100 so pos/neg stacks land on a ±100 percent scale. The
|
|
112
|
+
// closure maps those percent values through the fixed [-100..100] span
|
|
113
|
+
// (what `computeValueRange` returns for percent-stacked charts).
|
|
114
|
+
const percentSpan = 200; // -100..100
|
|
115
|
+
const percentValueToPlot = (pct: number): number => {
|
|
116
|
+
const frac = (pct - -100) / percentSpan;
|
|
117
|
+
if (horizontal) {
|
|
118
|
+
return reverseValueAxis ? plot.x + plot.w - frac * plot.w : plot.x + frac * plot.w;
|
|
119
|
+
}
|
|
120
|
+
return reverseValueAxis ? plot.y + frac * plot.h : plot.y + plot.h - frac * plot.h;
|
|
121
|
+
};
|
|
102
122
|
for (let c = 0; c < visibleCategories; c++) {
|
|
103
123
|
const totalsAbs = computeSlotAbsTotal(model.series, c);
|
|
104
124
|
if (totalsAbs === 0) continue;
|
|
105
|
-
emitStackedSlot(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
emitStackedSlot(
|
|
126
|
+
bars, model, theme, defsRegistry, c, slotSize, groupSize, plot,
|
|
127
|
+
horizontal,
|
|
128
|
+
(v) => (v / totalsAbs) * 100,
|
|
129
|
+
(_v) => 0,
|
|
130
|
+
100, -100,
|
|
131
|
+
percentValueToPlot,
|
|
132
|
+
seriesIndexOffset,
|
|
133
|
+
);
|
|
109
134
|
}
|
|
110
135
|
} else if (stacked) {
|
|
111
136
|
for (let c = 0; c < visibleCategories; c++) {
|
|
112
137
|
emitStackedSlot(bars, model, theme, defsRegistry, c, slotSize, groupSize, plot,
|
|
113
|
-
horizontal, (v) => v, (v) => v, valueMax, valueMin, valueToPlot);
|
|
138
|
+
horizontal, (v) => v, (v) => v, valueMax, valueMin, valueToPlot, seriesIndexOffset);
|
|
114
139
|
}
|
|
115
140
|
} else {
|
|
116
141
|
// Clustered: place each series' bar side-by-side within the slot.
|
|
@@ -121,7 +146,7 @@ function BarColumnChartImpl({
|
|
|
121
146
|
const series = model.series[s]!;
|
|
122
147
|
const v = series.values[c];
|
|
123
148
|
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
124
|
-
const color = resolveBarColor(model, theme, s, c, series, defsRegistry);
|
|
149
|
+
const color = resolveBarColor(model, theme, s + seriesIndexOffset, c, series, defsRegistry);
|
|
125
150
|
const barOffset = s * (barWidth * (1 - overlapFrac));
|
|
126
151
|
const barStart = groupStart + barOffset;
|
|
127
152
|
const origin = valueToPlot(0);
|
|
@@ -259,6 +284,7 @@ function emitStackedSlot(
|
|
|
259
284
|
_axisMax: number,
|
|
260
285
|
_axisMin: number,
|
|
261
286
|
valueToPlot: (v: number) => number,
|
|
287
|
+
seriesIndexOffset: number = 0,
|
|
262
288
|
): void {
|
|
263
289
|
const slotStart = (horizontal ? plot.y : plot.x) + c * slotSize;
|
|
264
290
|
const groupStart = slotStart + (slotSize - groupSize) / 2;
|
|
@@ -271,7 +297,7 @@ function emitStackedSlot(
|
|
|
271
297
|
const v = normalizePositive(raw);
|
|
272
298
|
const base = v >= 0 ? posBase : negBase;
|
|
273
299
|
const end = base + v;
|
|
274
|
-
const color = resolveBarColor(model, theme, s, c, series, defs);
|
|
300
|
+
const color = resolveBarColor(model, theme, s + seriesIndexOffset, c, series, defs);
|
|
275
301
|
const originPlot = valueToPlot(base);
|
|
276
302
|
const endPlot = valueToPlot(end);
|
|
277
303
|
const rect = horizontal
|
|
@@ -38,6 +38,8 @@ function BubbleChartImpl({ model, layout, theme }: BubbleChartProps): React.Reac
|
|
|
38
38
|
// don't dominate. Scale radius linearly with sqrt(size).
|
|
39
39
|
const maxRadius = Math.max(4, Math.min(plot.w, plot.h) * 0.06);
|
|
40
40
|
const sizeScaleFactor = maxSize > 0 ? maxRadius / Math.sqrt(maxSize) : 0;
|
|
41
|
+
const reverseX = model.xAxis.reverse === true;
|
|
42
|
+
const reverseY = model.yAxis.reverse === true;
|
|
41
43
|
|
|
42
44
|
const bubbles: React.ReactElement[] = [];
|
|
43
45
|
|
|
@@ -53,8 +55,10 @@ function BubbleChartImpl({ model, layout, theme }: BubbleChartProps): React.Reac
|
|
|
53
55
|
x === null || y === null || size === null ||
|
|
54
56
|
!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(size)
|
|
55
57
|
) continue;
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
+
const xFrac = (x - xMin) / xSpan;
|
|
59
|
+
const yFrac = (y - yMin) / ySpan;
|
|
60
|
+
const cx = reverseX ? plot.x + plot.w - xFrac * plot.w : plot.x + xFrac * plot.w;
|
|
61
|
+
const cy = reverseY ? plot.y + yFrac * plot.h : plot.y + plot.h - yFrac * plot.h;
|
|
58
62
|
const r = Math.sqrt(Math.abs(size)) * sizeScaleFactor * (series.bubbleScale ?? 1);
|
|
59
63
|
bubbles.push(
|
|
60
64
|
<circle
|
|
@@ -39,6 +39,17 @@ export interface ComboChartProps {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function ComboChartImpl({ model, layout, theme }: ComboChartProps): React.ReactElement {
|
|
42
|
+
// Pre-compute the series-index offset for each group so palette slots
|
|
43
|
+
// cycle across the whole combo rather than colliding at group boundaries.
|
|
44
|
+
// Word's observed behavior treats series across all groups as one
|
|
45
|
+
// continuous palette sequence.
|
|
46
|
+
const offsets: number[] = [];
|
|
47
|
+
let running = 0;
|
|
48
|
+
for (const group of model.groups) {
|
|
49
|
+
offsets.push(running);
|
|
50
|
+
running += group.series.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
// Groups render in declaration order → last group paints on top.
|
|
43
54
|
return (
|
|
44
55
|
<g
|
|
@@ -48,7 +59,7 @@ function ComboChartImpl({ model, layout, theme }: ComboChartProps): React.ReactE
|
|
|
48
59
|
>
|
|
49
60
|
{model.groups.map((group, i) => (
|
|
50
61
|
<g key={`combo-group-${i}`} data-role="combo-group" data-group-index={i}>
|
|
51
|
-
{renderGroup(group, layout, theme)}
|
|
62
|
+
{renderGroup(group, layout, theme, offsets[i]!)}
|
|
52
63
|
</g>
|
|
53
64
|
))}
|
|
54
65
|
<g data-role="data-labels" />
|
|
@@ -73,13 +84,35 @@ function renderGroup(
|
|
|
73
84
|
group: ComboChartModel["groups"][number],
|
|
74
85
|
layout: PlotAreaLayout,
|
|
75
86
|
theme: ResolvedTheme | undefined,
|
|
87
|
+
seriesIndexOffset: number,
|
|
76
88
|
): React.ReactElement {
|
|
77
89
|
switch (group.kind) {
|
|
78
90
|
case "bar":
|
|
79
|
-
return
|
|
91
|
+
return (
|
|
92
|
+
<BarColumnChart
|
|
93
|
+
model={group}
|
|
94
|
+
layout={layout}
|
|
95
|
+
theme={theme}
|
|
96
|
+
seriesIndexOffset={seriesIndexOffset}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
80
99
|
case "line":
|
|
81
|
-
return
|
|
100
|
+
return (
|
|
101
|
+
<LineChart
|
|
102
|
+
model={group}
|
|
103
|
+
layout={layout}
|
|
104
|
+
theme={theme}
|
|
105
|
+
seriesIndexOffset={seriesIndexOffset}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
82
108
|
case "area":
|
|
83
|
-
return
|
|
109
|
+
return (
|
|
110
|
+
<AreaChart
|
|
111
|
+
model={group}
|
|
112
|
+
layout={layout}
|
|
113
|
+
theme={theme}
|
|
114
|
+
seriesIndexOffset={seriesIndexOffset}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
84
117
|
}
|
|
85
118
|
}
|
|
@@ -37,9 +37,20 @@ export interface LineChartProps {
|
|
|
37
37
|
model: LineChartModel;
|
|
38
38
|
layout: PlotAreaLayout;
|
|
39
39
|
theme: ResolvedTheme | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Offset added to each series' palette index. Used by combo charts so
|
|
42
|
+
* series in group N don't collide with group (N-1)'s palette slots.
|
|
43
|
+
* Defaults to 0 for standalone render.
|
|
44
|
+
*/
|
|
45
|
+
seriesIndexOffset?: number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
function LineChartImpl({
|
|
48
|
+
function LineChartImpl({
|
|
49
|
+
model,
|
|
50
|
+
layout,
|
|
51
|
+
theme,
|
|
52
|
+
seriesIndexOffset = 0,
|
|
53
|
+
}: LineChartProps): React.ReactElement {
|
|
43
54
|
const plot = layout.plotRect;
|
|
44
55
|
const { valueMin, valueMax } = computeValueRange(model);
|
|
45
56
|
const categoryCount = Math.max(
|
|
@@ -52,11 +63,12 @@ function LineChartImpl({ model, layout, theme }: LineChartProps): React.ReactEle
|
|
|
52
63
|
const slotWidth = plot.w / categoryCount;
|
|
53
64
|
const valueSpan = Math.max(1e-9, valueMax - valueMin);
|
|
54
65
|
|
|
66
|
+
const reverseY = model.valueAxis.reverse === true;
|
|
55
67
|
/** Map (categoryIdx, value) → plot-coordinate point. */
|
|
56
68
|
const toPoint = (c: number, v: number): Point => {
|
|
57
69
|
const x = plot.x + (c + 0.5) * slotWidth;
|
|
58
70
|
const frac = (v - valueMin) / valueSpan;
|
|
59
|
-
const y = plot.y + plot.h - frac * plot.h;
|
|
71
|
+
const y = reverseY ? plot.y + frac * plot.h : plot.y + plot.h - frac * plot.h;
|
|
60
72
|
return [x, y];
|
|
61
73
|
};
|
|
62
74
|
|
|
@@ -69,7 +81,7 @@ function LineChartImpl({ model, layout, theme }: LineChartProps): React.ReactEle
|
|
|
69
81
|
for (let s = 0; s < model.series.length; s++) {
|
|
70
82
|
const series = model.series[s]!;
|
|
71
83
|
const values = effectiveValues[s]!;
|
|
72
|
-
const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
|
|
84
|
+
const color = composeSeriesColor(model, theme ?? { colors: {} }, s + seriesIndexOffset);
|
|
73
85
|
const smooth = series.smooth ?? model.smooth;
|
|
74
86
|
const showMarkers = (series.marker?.symbol ?? null) !== "none"
|
|
75
87
|
&& ((series.marker !== undefined) || model.marker);
|
|
@@ -140,14 +152,25 @@ export const LineChart = React.memo(
|
|
|
140
152
|
function polylinePath(points: ReadonlyArray<Point>): string {
|
|
141
153
|
if (points.length === 0) return "";
|
|
142
154
|
const first = points[0]!;
|
|
143
|
-
let d = `M ${first[0]} ${first[1]}`;
|
|
155
|
+
let d = `M ${fmt(first[0])} ${fmt(first[1])}`;
|
|
144
156
|
for (let i = 1; i < points.length; i++) {
|
|
145
157
|
const p = points[i]!;
|
|
146
|
-
d += ` L ${p[0]} ${p[1]}`;
|
|
158
|
+
d += ` L ${fmt(p[0])} ${fmt(p[1])}`;
|
|
147
159
|
}
|
|
148
160
|
return d;
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Round to 3 decimals and strip trailing zeros. Matches `smooth-curve.ts`
|
|
165
|
+
* so straight and smoothed path serializations are symmetric (important
|
|
166
|
+
* for deterministic pixel-diff output in Stage 7).
|
|
167
|
+
*/
|
|
168
|
+
function fmt(n: number): string {
|
|
169
|
+
if (!Number.isFinite(n)) return "0";
|
|
170
|
+
const rounded = Math.round(n * 1000) / 1000;
|
|
171
|
+
return rounded === 0 ? "0" : String(rounded);
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
// ---------------------------------------------------------------------------
|
|
152
175
|
// Value extraction (B3 — dispBlanksAs)
|
|
153
176
|
// ---------------------------------------------------------------------------
|
|
@@ -81,7 +81,6 @@ function PieChartImpl({ model, layout, theme, defs }: PieChartProps): React.Reac
|
|
|
81
81
|
let currentAngleWord = startDegWord;
|
|
82
82
|
|
|
83
83
|
const slices: React.ReactElement[] = [];
|
|
84
|
-
const palette = computePalette(model);
|
|
85
84
|
|
|
86
85
|
for (let i = 0; i < values.length; i++) {
|
|
87
86
|
const v = values[i];
|
|
@@ -91,7 +90,7 @@ function PieChartImpl({ model, layout, theme, defs }: PieChartProps): React.Reac
|
|
|
91
90
|
const endWord = currentAngleWord + sweep;
|
|
92
91
|
currentAngleWord = endWord;
|
|
93
92
|
|
|
94
|
-
const color = resolveSliceColor(model, theme, series, i,
|
|
93
|
+
const color = resolveSliceColor(model, theme, series, i, defsRegistry);
|
|
95
94
|
|
|
96
95
|
const explosionPct = resolveExplosion(series, i);
|
|
97
96
|
const midWord = (startWord + endWord) / 2;
|
|
@@ -174,6 +173,37 @@ function buildSlicePath(
|
|
|
174
173
|
endWord: number,
|
|
175
174
|
): string {
|
|
176
175
|
const sweep = endWord - startWord;
|
|
176
|
+
|
|
177
|
+
// Full-circle slice (one value at 100% of total) — SVG `A` with coincident
|
|
178
|
+
// start/end points draws nothing, so split at the midpoint into two
|
|
179
|
+
// half-arcs. Threshold < 360 to tolerate float drift from (v/total)*360.
|
|
180
|
+
if (sweep >= 359.999) {
|
|
181
|
+
const midWord = startWord + sweep / 2;
|
|
182
|
+
if (rInner <= 0) {
|
|
183
|
+
const p0 = polarToCartesian(cx, cy, rOuter, startWord);
|
|
184
|
+
const pm = polarToCartesian(cx, cy, rOuter, midWord);
|
|
185
|
+
return (
|
|
186
|
+
`M ${fmt(cx)} ${fmt(cy)} ` +
|
|
187
|
+
`L ${fmt(p0.x)} ${fmt(p0.y)} ` +
|
|
188
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 0 1 ${fmt(pm.x)} ${fmt(pm.y)} ` +
|
|
189
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 0 1 ${fmt(p0.x)} ${fmt(p0.y)} Z`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// Doughnut full-circle: two-subpath annulus.
|
|
193
|
+
const p0O = polarToCartesian(cx, cy, rOuter, startWord);
|
|
194
|
+
const pmO = polarToCartesian(cx, cy, rOuter, midWord);
|
|
195
|
+
const p0I = polarToCartesian(cx, cy, rInner, startWord);
|
|
196
|
+
const pmI = polarToCartesian(cx, cy, rInner, midWord);
|
|
197
|
+
return (
|
|
198
|
+
`M ${fmt(p0O.x)} ${fmt(p0O.y)} ` +
|
|
199
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 0 1 ${fmt(pmO.x)} ${fmt(pmO.y)} ` +
|
|
200
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 0 1 ${fmt(p0O.x)} ${fmt(p0O.y)} Z ` +
|
|
201
|
+
`M ${fmt(p0I.x)} ${fmt(p0I.y)} ` +
|
|
202
|
+
`A ${fmt(rInner)} ${fmt(rInner)} 0 0 0 ${fmt(pmI.x)} ${fmt(pmI.y)} ` +
|
|
203
|
+
`A ${fmt(rInner)} ${fmt(rInner)} 0 0 0 ${fmt(p0I.x)} ${fmt(p0I.y)} Z`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
const largeArc = sweep > 180 ? 1 : 0;
|
|
178
208
|
const p0Outer = polarToCartesian(cx, cy, rOuter, startWord);
|
|
179
209
|
const p1Outer = polarToCartesian(cx, cy, rOuter, endWord);
|
|
@@ -200,8 +230,9 @@ function buildSlicePath(
|
|
|
200
230
|
|
|
201
231
|
/**
|
|
202
232
|
* Offset a slice center radially outward along its bisector by the
|
|
203
|
-
* explosion percentage (0 = no offset, 100 = push out by full radius
|
|
204
|
-
* Word
|
|
233
|
+
* explosion percentage (0 = no offset, 100 = push out by full radius).
|
|
234
|
+
* Word's `c:explosion` value corresponds directly to the fraction of
|
|
235
|
+
* slice radius the slice moves outward.
|
|
205
236
|
*/
|
|
206
237
|
function applyExplosion(
|
|
207
238
|
cx: number,
|
|
@@ -211,7 +242,7 @@ function applyExplosion(
|
|
|
211
242
|
explosionPct: number,
|
|
212
243
|
): { cx: number; cy: number } {
|
|
213
244
|
if (explosionPct === 0) return { cx, cy };
|
|
214
|
-
const dist = rOuter * Math.min(1, explosionPct / 100)
|
|
245
|
+
const dist = rOuter * Math.min(1, explosionPct / 100);
|
|
215
246
|
const p = polarToCartesian(0, 0, dist, midAngleWord);
|
|
216
247
|
return { cx: cx + p.x, cy: cy + p.y };
|
|
217
248
|
}
|
|
@@ -220,22 +251,11 @@ function applyExplosion(
|
|
|
220
251
|
// Color & explosion resolution
|
|
221
252
|
// ---------------------------------------------------------------------------
|
|
222
253
|
|
|
223
|
-
function computePalette(
|
|
224
|
-
model: PieChartModel,
|
|
225
|
-
): (sliceIdx: number) => string | null {
|
|
226
|
-
// Returns null to indicate "fall through to series color" when
|
|
227
|
-
// varyColors is false and no palette path is taken.
|
|
228
|
-
const style = getChartStyle(resolveChartStyleId(model.styleId));
|
|
229
|
-
const mode = style.seriesColorMode;
|
|
230
|
-
return (sliceIdx: number) => paletteColorRef(mode, sliceIdx).kind === "scheme" ? null : null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
254
|
function resolveSliceColor(
|
|
234
255
|
model: PieChartModel,
|
|
235
256
|
theme: ResolvedTheme | undefined,
|
|
236
257
|
series: PieSeries,
|
|
237
258
|
sliceIdx: number,
|
|
238
|
-
_palette: (i: number) => string | null,
|
|
239
259
|
defs: DefsRegistry,
|
|
240
260
|
): string {
|
|
241
261
|
// Per-slice dPt override wins.
|
|
@@ -91,7 +91,14 @@ export function useProgressiveCount(
|
|
|
91
91
|
() => dispatch({ type: "advance", to: next }),
|
|
92
92
|
{ timeout: 100 },
|
|
93
93
|
);
|
|
94
|
-
return () =>
|
|
94
|
+
return () => {
|
|
95
|
+
// Guard `cancelIdleCallback` existence — the spec pairs them, but
|
|
96
|
+
// some polyfills ship only `requestIdleCallback`. Throwing
|
|
97
|
+
// ReferenceError on cleanup would leak the pending callback.
|
|
98
|
+
if (typeof cancelIdleCallback !== "undefined") {
|
|
99
|
+
cancelIdleCallback(h);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
95
102
|
} else {
|
|
96
103
|
const h = setTimeout(() => dispatch({ type: "advance", to: next }), 0);
|
|
97
104
|
return () => clearTimeout(h);
|