@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.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. 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
- * The current implementation uses the fixed-stub text measurement
65
- * (`GLYPH_WIDTH_PX` × font size, `LINE_HEIGHT_RATIO` × font size). Slice
66
- * 3B replaces those constants with a call-through to the real
67
- * font-metrics helper.
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
- if (model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
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: width = max(tick label width) + axis-title
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);
136
- const yWidth =
137
- maxLabelWidth(yTickLabels, theme) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
138
- out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
139
- left += yWidth + BAND_GAP_PX;
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
- const y2TickLabels = axisTickLabels(axes.secondaryY, axes.secondaryYFormatCode);
144
- const y2Width =
145
- maxLabelWidth(y2TickLabels, theme) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
146
- out.secondaryYAxisRect = {
147
- x: right - y2Width,
148
- y: top,
149
- w: y2Width,
150
- h: bottom - top,
151
- };
152
- right -= y2Width + BAND_GAP_PX;
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: height = label height + axis-title band.
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;
166
- const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
167
- out.xAxisRect = {
168
- x: left,
169
- y: bottom - xHeight,
170
- w: right - left,
171
- h: xHeight,
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({ model, layout, theme }: AreaChartProps): React.ReactElement {
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 toY = (v: number): number => plot.y + plot.h - ((v - valueMin) / valueSpan) * plot.h;
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
- const lowerBounds = new Array<number>(categoryCount).fill(valueMin);
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
- return horizontal ? plot.x + frac * plot.w : plot.y + plot.h - frac * plot.h;
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%; pos stack and neg stack both scaled so
100
- // |sum| sits at ±100. Recommended approach: per-category, compute
101
- // pos/neg totals, scale each value's contribution accordingly.
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(bars, model, theme, defsRegistry, c, slotSize, groupSize, plot,
106
- horizontal, (v) => (v / totalsAbs) * 100, (_v) => 0, 100, -100, (frac) => {
107
- return horizontal ? plot.x + frac * plot.w : plot.y + plot.h - frac * plot.h;
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 cx = plot.x + ((x - xMin) / xSpan) * plot.w;
57
- const cy = plot.y + plot.h - ((y - yMin) / ySpan) * plot.h;
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 <BarColumnChart model={group} layout={layout} theme={theme} />;
91
+ return (
92
+ <BarColumnChart
93
+ model={group}
94
+ layout={layout}
95
+ theme={theme}
96
+ seriesIndexOffset={seriesIndexOffset}
97
+ />
98
+ );
80
99
  case "line":
81
- return <LineChart model={group} layout={layout} theme={theme} />;
100
+ return (
101
+ <LineChart
102
+ model={group}
103
+ layout={layout}
104
+ theme={theme}
105
+ seriesIndexOffset={seriesIndexOffset}
106
+ />
107
+ );
82
108
  case "area":
83
- return <AreaChart model={group} layout={layout} theme={theme} />;
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({ model, layout, theme }: LineChartProps): React.ReactElement {
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, palette, defsRegistry);
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 visually caps the real-rendered effect at ~35% of radius).
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) * 0.35;
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 () => cancelIdleCallback(h);
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);