@beyondwork/docx-react-component 1.0.52 → 1.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -40
- package/src/api/public-types.ts +67 -7
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +217 -23
- package/src/runtime/collab/checkpoint-store.ts +1 -1
- package/src/runtime/collab/event-types.ts +4 -0
- package/src/runtime/collab/runtime-collab-sync.ts +88 -8
- package/src/runtime/document-runtime.ts +182 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +97 -2
- package/src/runtime/layout/layout-invalidation.ts +150 -30
- package/src/runtime/layout/page-graph.ts +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +128 -19
- package/src/runtime/layout/project-block-fragments.ts +27 -0
- package/src/runtime/layout/public-facet.ts +70 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/editor-runtime-boundary.ts +39 -2
- package/src/ui/headless/comment-decoration-model.ts +60 -5
- package/src/ui/headless/revision-decoration-model.ts +94 -6
- package/src/ui/shared/revision-filters.ts +16 -6
- package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
- package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
- package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
- package/src/ui-tailwind/chart/render/area.tsx +277 -0
- package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
- package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
- package/src/ui-tailwind/chart/render/combo.tsx +85 -0
- package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
- package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
- package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
- package/src/ui-tailwind/chart/render/line.tsx +363 -0
- package/src/ui-tailwind/chart/render/number-format.ts +120 -16
- package/src/ui-tailwind/chart/render/pie.tsx +275 -0
- package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
- package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
- package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
- package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
- package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
- package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
- package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
- package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
- package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
- package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
- package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
- package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
- package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
- package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +275 -46
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
|
@@ -0,0 +1,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
|
+
}
|