@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.
Files changed (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Bar / Column chart renderer (Stage 4 Slice 4A).
3
+ *
4
+ * Dispatches `BarChartModel` to SVG via:
5
+ * - Value → plot-coordinate mapping (derived from `model.valueAxis`).
6
+ * - Category slot math (equal-width slots across the category axis).
7
+ * - Grouping logic: clustered (side-by-side within a slot), stacked
8
+ * (values stack on positive + negative bases), or percentStacked
9
+ * (values normalize to ±100% per slot).
10
+ * - Direction flip: `"bar"` swaps the axis roles so bars grow
11
+ * horizontally.
12
+ *
13
+ * Colors derive from `composeSeriesColor(model, theme, seriesIdx)` —
14
+ * the Stage 2 cascade handles explicit spPr overrides, chart-style
15
+ * palette, and theme resolution. Per-point `dPt.spPr` overrides rank
16
+ * above series color.
17
+ *
18
+ * `invertIfNegative` (per-series): when a value is negative and the
19
+ * series opt-in is set, the bar is tinted (towards white) to match
20
+ * Word's visual cue. We emit a lightened color rather than the
21
+ * inverted palette slot.
22
+ *
23
+ * The component wraps in `React.memo` with a structural comparator so
24
+ * re-renders are skipped when the underlying `rawXml` and dimensions
25
+ * haven't changed.
26
+ */
27
+
28
+ import React from "react";
29
+ import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
30
+ import type {
31
+ BarChartModel,
32
+ DataPointOverride,
33
+ Series,
34
+ } from "../../../io/ooxml/chart/types.ts";
35
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
36
+ import type { PlotAreaLayout } from "../layout/plot-area.ts";
37
+ import { resolveFill, DefsRegistry } from "./svg-primitives.ts";
38
+ import { useProgressiveCount } from "./progressive-render.ts";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Public API
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface BarColumnChartProps {
45
+ model: BarChartModel;
46
+ layout: PlotAreaLayout;
47
+ theme: ResolvedTheme | undefined;
48
+ defs?: DefsRegistry;
49
+ }
50
+
51
+ function BarColumnChartImpl({
52
+ model,
53
+ layout,
54
+ theme,
55
+ defs,
56
+ }: BarColumnChartProps): React.ReactElement {
57
+ const defsRegistry = defs ?? new DefsRegistry();
58
+ const horizontal = model.direction === "bar";
59
+ const stacked =
60
+ model.grouping === "stacked" || model.grouping === "percentStacked";
61
+ const percent = model.grouping === "percentStacked";
62
+ const plot = layout.plotRect;
63
+
64
+ // --- Value range ---
65
+ const { valueMin, valueMax } = computeValueRange(model);
66
+
67
+ // --- Category slot math ---
68
+ const seriesCount = Math.max(1, model.series.length);
69
+ const categoryCount = Math.max(
70
+ 1,
71
+ model.categoryAxis.kind === "category"
72
+ ? model.categoryAxis.categoryLabels.length
73
+ : model.series[0]?.values.length ?? 1,
74
+ );
75
+ // Progressive rendering: render the first N categories synchronously and
76
+ // advance in idle ticks for datasets exceeding PROGRESSIVE_THRESHOLD.
77
+ const visibleCategories = useProgressiveCount(categoryCount);
78
+ const slotSize = (horizontal ? plot.h : plot.w) / categoryCount;
79
+ // gapWidth is % of bar width (Word default 150); 150 → half-slot gap.
80
+ const gapFrac = Math.min(0.9, Math.max(0.05, model.gapWidth / 100 / 2));
81
+ const groupSize = slotSize * (1 - gapFrac);
82
+ // overlap: -100..100, percent of bar width; -100 = no overlap, 100 = full overlap.
83
+ const overlapFrac = Math.max(-1, Math.min(1, model.overlap / 100));
84
+ const barWidth = stacked
85
+ ? groupSize
86
+ : computeClusteredBarWidth(groupSize, seriesCount, overlapFrac);
87
+
88
+ // --- value → plot-coordinate ---
89
+ const valueSpan = Math.max(1e-9, valueMax - valueMin);
90
+ const valueToPlot = (v: number): number => {
91
+ const frac = (v - valueMin) / valueSpan;
92
+ return horizontal ? plot.x + frac * plot.w : plot.y + plot.h - frac * plot.h;
93
+ };
94
+
95
+ // --- emit bars ---
96
+ const bars: React.ReactElement[] = [];
97
+
98
+ 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.
102
+ for (let c = 0; c < visibleCategories; c++) {
103
+ const totalsAbs = computeSlotAbsTotal(model.series, c);
104
+ 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
+ });
109
+ }
110
+ } else if (stacked) {
111
+ for (let c = 0; c < visibleCategories; c++) {
112
+ emitStackedSlot(bars, model, theme, defsRegistry, c, slotSize, groupSize, plot,
113
+ horizontal, (v) => v, (v) => v, valueMax, valueMin, valueToPlot);
114
+ }
115
+ } else {
116
+ // Clustered: place each series' bar side-by-side within the slot.
117
+ for (let c = 0; c < visibleCategories; c++) {
118
+ const slotStart = (horizontal ? plot.y : plot.x) + c * slotSize;
119
+ const groupStart = slotStart + (slotSize - groupSize) / 2;
120
+ for (let s = 0; s < model.series.length; s++) {
121
+ const series = model.series[s]!;
122
+ const v = series.values[c];
123
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
124
+ const color = resolveBarColor(model, theme, s, c, series, defsRegistry);
125
+ const barOffset = s * (barWidth * (1 - overlapFrac));
126
+ const barStart = groupStart + barOffset;
127
+ const origin = valueToPlot(0);
128
+ const end = valueToPlot(v);
129
+ const rect = horizontal
130
+ ? {
131
+ x: Math.min(origin, end),
132
+ y: barStart,
133
+ w: Math.abs(end - origin),
134
+ h: barWidth * 0.92,
135
+ }
136
+ : {
137
+ x: barStart,
138
+ y: Math.min(origin, end),
139
+ w: barWidth * 0.92,
140
+ h: Math.abs(end - origin),
141
+ };
142
+ const appliedColor = v < 0 && seriesInvertsIfNegative(series)
143
+ ? lightenHex(color, 0.4)
144
+ : color;
145
+ bars.push(
146
+ <rect
147
+ key={`bar-${s}-${c}`}
148
+ x={rect.x}
149
+ y={rect.y}
150
+ width={rect.w}
151
+ height={rect.h}
152
+ fill={appliedColor}
153
+ data-role="bar"
154
+ data-series-index={s}
155
+ data-category-index={c}
156
+ />,
157
+ );
158
+ }
159
+ }
160
+ }
161
+
162
+ return (
163
+ <g data-role="bar-column-chart" data-direction={model.direction} data-grouping={model.grouping}>
164
+ <g data-role="bars">{bars}</g>
165
+ <g data-role="data-labels" />
166
+ </g>
167
+ );
168
+ }
169
+
170
+ export const BarColumnChart = React.memo(
171
+ BarColumnChartImpl,
172
+ (prev, next) =>
173
+ prev.model.rawXml === next.model.rawXml &&
174
+ prev.layout.plotRect.w === next.layout.plotRect.w &&
175
+ prev.layout.plotRect.h === next.layout.plotRect.h &&
176
+ prev.theme === next.theme,
177
+ );
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Helpers
181
+ // ---------------------------------------------------------------------------
182
+
183
+ function computeValueRange(model: BarChartModel): {
184
+ valueMin: number;
185
+ valueMax: number;
186
+ } {
187
+ // Prefer explicit axis min/max when provided.
188
+ if (model.valueAxis.min !== undefined && model.valueAxis.max !== undefined) {
189
+ return { valueMin: model.valueAxis.min, valueMax: model.valueAxis.max };
190
+ }
191
+ if (model.grouping === "percentStacked") {
192
+ return { valueMin: -100, valueMax: 100 };
193
+ }
194
+ const stacked = model.grouping === "stacked";
195
+ const categoryCount = Math.max(
196
+ 1,
197
+ model.series[0]?.values.length ?? 1,
198
+ );
199
+ let min = 0;
200
+ let max = 0;
201
+ if (stacked) {
202
+ for (let c = 0; c < categoryCount; c++) {
203
+ let pos = 0;
204
+ let neg = 0;
205
+ for (const s of model.series) {
206
+ const v = s.values[c];
207
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
208
+ if (v >= 0) pos += v;
209
+ else neg += v;
210
+ }
211
+ if (pos > max) max = pos;
212
+ if (neg < min) min = neg;
213
+ }
214
+ } else {
215
+ for (const s of model.series) {
216
+ for (const v of s.values) {
217
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
218
+ if (v > max) max = v;
219
+ if (v < min) min = v;
220
+ }
221
+ }
222
+ }
223
+ return { valueMin: min, valueMax: max };
224
+ }
225
+
226
+ function computeClusteredBarWidth(
227
+ groupSize: number,
228
+ seriesCount: number,
229
+ overlapFrac: number,
230
+ ): number {
231
+ // barWidth × (seriesCount × (1 - overlap) + overlap) = groupSize.
232
+ // Solve: barWidth = groupSize / (seriesCount - (seriesCount - 1) × overlap).
233
+ const denom = seriesCount - (seriesCount - 1) * overlapFrac;
234
+ return denom > 0 ? groupSize / denom : groupSize / Math.max(1, seriesCount);
235
+ }
236
+
237
+ function computeSlotAbsTotal(series: Series[], categoryIdx: number): number {
238
+ let total = 0;
239
+ for (const s of series) {
240
+ const v = s.values[categoryIdx];
241
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
242
+ total += Math.abs(v);
243
+ }
244
+ return total;
245
+ }
246
+
247
+ function emitStackedSlot(
248
+ out: React.ReactElement[],
249
+ model: BarChartModel,
250
+ theme: ResolvedTheme | undefined,
251
+ defs: DefsRegistry,
252
+ c: number,
253
+ slotSize: number,
254
+ groupSize: number,
255
+ plot: { x: number; y: number; w: number; h: number },
256
+ horizontal: boolean,
257
+ normalizePositive: (v: number) => number,
258
+ _normalizeNegative: (v: number) => number,
259
+ _axisMax: number,
260
+ _axisMin: number,
261
+ valueToPlot: (v: number) => number,
262
+ ): void {
263
+ const slotStart = (horizontal ? plot.y : plot.x) + c * slotSize;
264
+ const groupStart = slotStart + (slotSize - groupSize) / 2;
265
+ let posBase = 0;
266
+ let negBase = 0;
267
+ for (let s = 0; s < model.series.length; s++) {
268
+ const series = model.series[s]!;
269
+ const raw = series.values[c];
270
+ if (raw === null || raw === undefined || !Number.isFinite(raw)) continue;
271
+ const v = normalizePositive(raw);
272
+ const base = v >= 0 ? posBase : negBase;
273
+ const end = base + v;
274
+ const color = resolveBarColor(model, theme, s, c, series, defs);
275
+ const originPlot = valueToPlot(base);
276
+ const endPlot = valueToPlot(end);
277
+ const rect = horizontal
278
+ ? {
279
+ x: Math.min(originPlot, endPlot),
280
+ y: groupStart,
281
+ w: Math.abs(endPlot - originPlot),
282
+ h: groupSize,
283
+ }
284
+ : {
285
+ x: groupStart,
286
+ y: Math.min(originPlot, endPlot),
287
+ w: groupSize,
288
+ h: Math.abs(endPlot - originPlot),
289
+ };
290
+ out.push(
291
+ <rect
292
+ key={`stack-${s}-${c}`}
293
+ x={rect.x}
294
+ y={rect.y}
295
+ width={rect.w}
296
+ height={rect.h}
297
+ fill={color}
298
+ data-role="bar"
299
+ data-series-index={s}
300
+ data-category-index={c}
301
+ />,
302
+ );
303
+ if (v >= 0) posBase += v;
304
+ else negBase += v;
305
+ }
306
+ }
307
+
308
+ function resolveBarColor(
309
+ model: BarChartModel,
310
+ theme: ResolvedTheme | undefined,
311
+ seriesIdx: number,
312
+ categoryIdx: number,
313
+ series: Series,
314
+ defs: DefsRegistry,
315
+ ): string {
316
+ // Per-point override wins.
317
+ const dPt = findDataPointOverride(series.dataPoints, categoryIdx);
318
+ if (dPt?.spPr?.fill) {
319
+ return resolveFill(dPt.spPr.fill, theme, defs);
320
+ }
321
+ return composeSeriesColor(model, theme ?? { colors: {} }, seriesIdx);
322
+ }
323
+
324
+ function findDataPointOverride(
325
+ dPts: DataPointOverride[] | undefined,
326
+ categoryIdx: number,
327
+ ): DataPointOverride | undefined {
328
+ if (!dPts) return undefined;
329
+ for (const d of dPts) if (d.idx === categoryIdx) return d;
330
+ return undefined;
331
+ }
332
+
333
+ function seriesInvertsIfNegative(series: Series): boolean {
334
+ // The parser doesn't set this per-series yet; we inspect dataPoints[0] as
335
+ // a proxy since invertIfNegative is recorded at the data-point level.
336
+ if (!series.dataPoints) return false;
337
+ return series.dataPoints.some((d) => d.invertIfNegative);
338
+ }
339
+
340
+ /**
341
+ * Blend a hex color towards white. Used for `invertIfNegative` Word
342
+ * styling. 0 = no change, 1 = pure white.
343
+ */
344
+ function lightenHex(hex: string, frac: number): string {
345
+ const m = /^#?([0-9A-Fa-f]{6})$/.exec(hex);
346
+ if (!m) return hex;
347
+ const raw = m[1]!;
348
+ const r = parseInt(raw.slice(0, 2), 16);
349
+ const g = parseInt(raw.slice(2, 4), 16);
350
+ const b = parseInt(raw.slice(4, 6), 16);
351
+ const rr = Math.round(r + (255 - r) * frac);
352
+ const gg = Math.round(g + (255 - g) * frac);
353
+ const bb = Math.round(b + (255 - b) * frac);
354
+ const toHex = (n: number) => n.toString(16).padStart(2, "0").toUpperCase();
355
+ return `#${toHex(rr)}${toHex(gg)}${toHex(bb)}`;
356
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Bubble chart renderer (Stage 4 Slice 4E).
3
+ *
4
+ * Each data point is drawn as a circle whose radius is proportional to
5
+ * `sqrt(|size|)` (the classic bubble scaling that maps size → area).
6
+ * Null x/y/size values skip the point. Negative sizes use |size|
7
+ * (matching Excel's observed behavior).
8
+ *
9
+ * `bubble3D` is collapsed to 2D per the lane non-goal.
10
+ */
11
+
12
+ import React from "react";
13
+ import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
14
+ import type {
15
+ BubbleChartModel,
16
+ BubbleSeries,
17
+ } from "../../../io/ooxml/chart/types.ts";
18
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
19
+ import type { PlotAreaLayout } from "../layout/plot-area.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Public API
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface BubbleChartProps {
26
+ model: BubbleChartModel;
27
+ layout: PlotAreaLayout;
28
+ theme: ResolvedTheme | undefined;
29
+ }
30
+
31
+ function BubbleChartImpl({ model, layout, theme }: BubbleChartProps): React.ReactElement {
32
+ const plot = layout.plotRect;
33
+ const { xMin, xMax, yMin, yMax, maxSize } = computeRange(model);
34
+ const xSpan = Math.max(1e-9, xMax - xMin);
35
+ const ySpan = Math.max(1e-9, yMax - yMin);
36
+
37
+ // Max bubble radius = ~6% of the smaller plot dimension so bubbles
38
+ // don't dominate. Scale radius linearly with sqrt(size).
39
+ const maxRadius = Math.max(4, Math.min(plot.w, plot.h) * 0.06);
40
+ const sizeScaleFactor = maxSize > 0 ? maxRadius / Math.sqrt(maxSize) : 0;
41
+
42
+ const bubbles: React.ReactElement[] = [];
43
+
44
+ for (let s = 0; s < model.series.length; s++) {
45
+ const series = model.series[s]!;
46
+ const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
47
+ const n = Math.min(series.xValues.length, series.yValues.length, series.sizes.length);
48
+ for (let i = 0; i < n; i++) {
49
+ const x = series.xValues[i];
50
+ const y = series.yValues[i];
51
+ const size = series.sizes[i];
52
+ if (
53
+ x === null || y === null || size === null ||
54
+ !Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(size)
55
+ ) 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 r = Math.sqrt(Math.abs(size)) * sizeScaleFactor * (series.bubbleScale ?? 1);
59
+ bubbles.push(
60
+ <circle
61
+ key={`bubble-${s}-${i}`}
62
+ cx={cx}
63
+ cy={cy}
64
+ r={Math.max(1, r)}
65
+ fill={color}
66
+ fillOpacity={0.7}
67
+ stroke={color}
68
+ strokeWidth={1}
69
+ data-role="bubble"
70
+ data-series-index={s}
71
+ data-point-index={i}
72
+ />,
73
+ );
74
+ }
75
+ }
76
+
77
+ return (
78
+ <g data-role="bubble-chart" data-bubble3d={model.bubble3D}>
79
+ <g data-role="bubbles">{bubbles}</g>
80
+ <g data-role="data-labels" />
81
+ </g>
82
+ );
83
+ }
84
+
85
+ export const BubbleChart = React.memo(
86
+ BubbleChartImpl,
87
+ (prev, next) =>
88
+ prev.model.rawXml === next.model.rawXml &&
89
+ prev.layout.plotRect.w === next.layout.plotRect.w &&
90
+ prev.layout.plotRect.h === next.layout.plotRect.h &&
91
+ prev.theme === next.theme,
92
+ );
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Range computation
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function computeRange(model: BubbleChartModel): {
99
+ xMin: number; xMax: number; yMin: number; yMax: number; maxSize: number;
100
+ } {
101
+ const ax = model.xAxis;
102
+ const ay = model.yAxis;
103
+ let xMin = ax.min ?? Infinity;
104
+ let xMax = ax.max ?? -Infinity;
105
+ let yMin = ay.min ?? Infinity;
106
+ let yMax = ay.max ?? -Infinity;
107
+ let maxSize = 0;
108
+ const dataFillX = ax.min === undefined || ax.max === undefined;
109
+ const dataFillY = ay.min === undefined || ay.max === undefined;
110
+
111
+ for (const s of model.series) {
112
+ for (let i = 0; i < s.xValues.length; i++) {
113
+ const x = s.xValues[i];
114
+ const y = s.yValues[i];
115
+ const size = s.sizes[i];
116
+ if (x === null || y === null || size === null) continue;
117
+ if (dataFillX) {
118
+ if (x < xMin) xMin = x;
119
+ if (x > xMax) xMax = x;
120
+ }
121
+ if (dataFillY) {
122
+ if (y < yMin) yMin = y;
123
+ if (y > yMax) yMax = y;
124
+ }
125
+ const absSize = Math.abs(size);
126
+ if (absSize > maxSize) maxSize = absSize;
127
+ }
128
+ }
129
+ if (!Number.isFinite(xMin)) xMin = 0;
130
+ if (!Number.isFinite(xMax)) xMax = 1;
131
+ if (!Number.isFinite(yMin)) yMin = 0;
132
+ if (!Number.isFinite(yMax)) yMax = 1;
133
+ return { xMin, xMax, yMin, yMax, maxSize };
134
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Combo chart renderer (Stage 4 Slice 4F).
3
+ *
4
+ * Combo charts pack multiple chart-type groups (bar/line/area) into a
5
+ * single plot area. Each group is a full `BarChartModel`,
6
+ * `LineChartModel`, or `AreaChartModel` that was produced by the
7
+ * parser with per-group axis objects (already cloned by
8
+ * `parse-chart-space.ts` so mutation in one group doesn't bleed into
9
+ * another).
10
+ *
11
+ * Draw order: groups render in declaration order so later groups
12
+ * visually stack on top. The convention is bar → line (line renders
13
+ * atop bar), which matches Word's observed behavior when a combo's
14
+ * type-group nodes appear in XML order.
15
+ *
16
+ * Secondary axes: when a group has `secondaryValueAxis`, its bars /
17
+ * lines project onto the secondary scale. Because per-group renderers
18
+ * already honor `valueAxis.min/max` when computing their range, this
19
+ * works without special wiring — the combo just hands each group its
20
+ * own layout and model.
21
+ */
22
+
23
+ import React from "react";
24
+ import type { ComboChartModel } from "../../../io/ooxml/chart/types.ts";
25
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
26
+ import type { PlotAreaLayout } from "../layout/plot-area.ts";
27
+ import { BarColumnChart } from "./bar-column.tsx";
28
+ import { LineChart } from "./line.tsx";
29
+ import { AreaChart } from "./area.tsx";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface ComboChartProps {
36
+ model: ComboChartModel;
37
+ layout: PlotAreaLayout;
38
+ theme: ResolvedTheme | undefined;
39
+ }
40
+
41
+ function ComboChartImpl({ model, layout, theme }: ComboChartProps): React.ReactElement {
42
+ // Groups render in declaration order → last group paints on top.
43
+ return (
44
+ <g
45
+ data-role="combo-chart"
46
+ data-group-count={model.groups.length}
47
+ data-has-secondary-axis={model.hasSecondaryAxis}
48
+ >
49
+ {model.groups.map((group, i) => (
50
+ <g key={`combo-group-${i}`} data-role="combo-group" data-group-index={i}>
51
+ {renderGroup(group, layout, theme)}
52
+ </g>
53
+ ))}
54
+ <g data-role="data-labels" />
55
+ </g>
56
+ );
57
+ }
58
+
59
+ export const ComboChart = React.memo(
60
+ ComboChartImpl,
61
+ (prev, next) =>
62
+ prev.model.rawXml === next.model.rawXml &&
63
+ prev.layout.plotRect.w === next.layout.plotRect.w &&
64
+ prev.layout.plotRect.h === next.layout.plotRect.h &&
65
+ prev.theme === next.theme,
66
+ );
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Group dispatch
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function renderGroup(
73
+ group: ComboChartModel["groups"][number],
74
+ layout: PlotAreaLayout,
75
+ theme: ResolvedTheme | undefined,
76
+ ): React.ReactElement {
77
+ switch (group.kind) {
78
+ case "bar":
79
+ return <BarColumnChart model={group} layout={layout} theme={theme} />;
80
+ case "line":
81
+ return <LineChart model={group} layout={layout} theme={theme} />;
82
+ case "area":
83
+ return <AreaChart model={group} layout={layout} theme={theme} />;
84
+ }
85
+ }