@beyondwork/docx-react-component 1.0.53 → 1.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/public-types.ts +125 -7
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +27 -3
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- 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 +1 -2
- package/src/runtime/document-runtime.ts +115 -13
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +58 -1
- 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 +27 -0
- package/src/runtime/page-number-format.ts +207 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +57 -3
- 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/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- 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/editor-theme.css +249 -22
- 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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Area chart renderer (Stage 4 Slice 4D, B3).
|
|
3
|
+
*
|
|
4
|
+
* Renders filled closed polygons for each series. Handles:
|
|
5
|
+
* - `grouping: "standard"` — each series' area spans from the x-axis
|
|
6
|
+
* (y = 0) up to its values.
|
|
7
|
+
* - `grouping: "stacked"` — each series' area spans from the running
|
|
8
|
+
* cumulative base up to the cumulative top.
|
|
9
|
+
* - `grouping: "percentStacked"` — values normalize to 100% per
|
|
10
|
+
* category; cumulative stacking as above.
|
|
11
|
+
*
|
|
12
|
+
* `dispBlanksAs` (B3):
|
|
13
|
+
* - `"zero"` — null becomes 0; single polygon closed along the x-axis.
|
|
14
|
+
* - `"gap"` — null splits the area polygon into multiple pieces,
|
|
15
|
+
* closing the current piece at the last valid point and reopening
|
|
16
|
+
* at the next valid point.
|
|
17
|
+
* - `"span"` — nulls dropped; the polygon connects valid neighbors
|
|
18
|
+
* directly (creating a single continuous polygon).
|
|
19
|
+
*
|
|
20
|
+
* Emits `<g data-role="area-chart">` containing `<g data-role="areas">`
|
|
21
|
+
* with one or more `<path>` per series plus a `data-labels` placeholder.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import React from "react";
|
|
25
|
+
import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
|
|
26
|
+
import type {
|
|
27
|
+
AreaChartModel,
|
|
28
|
+
Series,
|
|
29
|
+
} from "../../../io/ooxml/chart/types.ts";
|
|
30
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
31
|
+
import type { PlotAreaLayout } from "../layout/plot-area.ts";
|
|
32
|
+
import { useProgressiveCount } from "./progressive-render.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface AreaChartProps {
|
|
39
|
+
model: AreaChartModel;
|
|
40
|
+
layout: PlotAreaLayout;
|
|
41
|
+
theme: ResolvedTheme | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function AreaChartImpl({ model, layout, theme }: AreaChartProps): React.ReactElement {
|
|
45
|
+
const plot = layout.plotRect;
|
|
46
|
+
const percent = model.grouping === "percentStacked";
|
|
47
|
+
const stacked = model.grouping === "stacked" || percent;
|
|
48
|
+
|
|
49
|
+
const categoryCount = Math.max(
|
|
50
|
+
1,
|
|
51
|
+
model.categoryAxis.kind === "category"
|
|
52
|
+
? model.categoryAxis.categoryLabels.length
|
|
53
|
+
: model.series[0]?.values.length ?? 1,
|
|
54
|
+
);
|
|
55
|
+
const visibleCategories = useProgressiveCount(categoryCount);
|
|
56
|
+
const slotWidth = plot.w / categoryCount;
|
|
57
|
+
const { valueMin, valueMax } = computeValueRange(model);
|
|
58
|
+
const valueSpan = Math.max(1e-9, valueMax - valueMin);
|
|
59
|
+
|
|
60
|
+
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;
|
|
62
|
+
|
|
63
|
+
// Pre-compute per-category cumulative bases (for stacked) + tops.
|
|
64
|
+
const lowerBounds = new Array<number>(categoryCount).fill(valueMin);
|
|
65
|
+
const paths: React.ReactElement[] = [];
|
|
66
|
+
|
|
67
|
+
for (let s = 0; s < model.series.length; s++) {
|
|
68
|
+
const series = model.series[s]!;
|
|
69
|
+
const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
|
|
70
|
+
|
|
71
|
+
// Compute this series' upper-boundary values and lower-boundary values.
|
|
72
|
+
const upper: Array<number | null> = [];
|
|
73
|
+
const lower: Array<number | null> = [];
|
|
74
|
+
for (let c = 0; c < visibleCategories; c++) {
|
|
75
|
+
const raw = series.values[c];
|
|
76
|
+
if (raw === null || raw === undefined || !Number.isFinite(raw)) {
|
|
77
|
+
upper.push(null);
|
|
78
|
+
lower.push(null);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let valueContribution = raw;
|
|
82
|
+
if (percent) {
|
|
83
|
+
const total = sumAbsAtCategory(model.series, c);
|
|
84
|
+
valueContribution = total === 0 ? 0 : (raw / total) * 100;
|
|
85
|
+
}
|
|
86
|
+
if (stacked) {
|
|
87
|
+
const lo = lowerBounds[c]!;
|
|
88
|
+
upper.push(lo + valueContribution);
|
|
89
|
+
lower.push(lo);
|
|
90
|
+
lowerBounds[c] = lo + valueContribution;
|
|
91
|
+
} else {
|
|
92
|
+
// Standard: every series sits on the axis (y=0) up to its value.
|
|
93
|
+
upper.push(valueContribution);
|
|
94
|
+
lower.push(0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Apply dispBlanksAs to decide how to close polygons at nulls.
|
|
99
|
+
const segments = partitionForDispBlanksAs(upper, lower, model.dispBlanksAs);
|
|
100
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
101
|
+
const seg = segments[segIdx]!;
|
|
102
|
+
const d = buildAreaPath(seg, toX, toY);
|
|
103
|
+
if (!d) continue;
|
|
104
|
+
paths.push(
|
|
105
|
+
<path
|
|
106
|
+
key={`area-${s}-${segIdx}`}
|
|
107
|
+
d={d}
|
|
108
|
+
fill={color}
|
|
109
|
+
fillOpacity={model.grouping === "standard" ? 0.6 : 1}
|
|
110
|
+
stroke={color}
|
|
111
|
+
strokeWidth={1}
|
|
112
|
+
data-role="area"
|
|
113
|
+
data-series-index={s}
|
|
114
|
+
data-segment-index={segIdx}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<g data-role="area-chart" data-grouping={model.grouping}>
|
|
122
|
+
<g data-role="areas">{paths}</g>
|
|
123
|
+
<g data-role="data-labels" />
|
|
124
|
+
</g>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const AreaChart = React.memo(
|
|
129
|
+
AreaChartImpl,
|
|
130
|
+
(prev, next) =>
|
|
131
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
132
|
+
prev.layout.plotRect.w === next.layout.plotRect.w &&
|
|
133
|
+
prev.layout.plotRect.h === next.layout.plotRect.h &&
|
|
134
|
+
prev.theme === next.theme,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Value range
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function computeValueRange(model: AreaChartModel): { valueMin: number; valueMax: number } {
|
|
142
|
+
if (model.valueAxis.min !== undefined && model.valueAxis.max !== undefined) {
|
|
143
|
+
return { valueMin: model.valueAxis.min, valueMax: model.valueAxis.max };
|
|
144
|
+
}
|
|
145
|
+
if (model.grouping === "percentStacked") return { valueMin: 0, valueMax: 100 };
|
|
146
|
+
const stacked = model.grouping === "stacked";
|
|
147
|
+
const categoryCount = Math.max(1, model.series[0]?.values.length ?? 1);
|
|
148
|
+
let min = 0;
|
|
149
|
+
let max = 0;
|
|
150
|
+
if (stacked) {
|
|
151
|
+
for (let c = 0; c < categoryCount; c++) {
|
|
152
|
+
let sum = 0;
|
|
153
|
+
for (const s of model.series) {
|
|
154
|
+
const v = s.values[c];
|
|
155
|
+
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
156
|
+
sum += v;
|
|
157
|
+
}
|
|
158
|
+
if (sum > max) max = sum;
|
|
159
|
+
if (sum < min) min = sum;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
for (const s of model.series) {
|
|
163
|
+
for (const v of s.values) {
|
|
164
|
+
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
165
|
+
if (v > max) max = v;
|
|
166
|
+
if (v < min) min = v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { valueMin: min, valueMax: max };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sumAbsAtCategory(series: Series[], c: number): number {
|
|
174
|
+
let total = 0;
|
|
175
|
+
for (const s of series) {
|
|
176
|
+
const v = s.values[c];
|
|
177
|
+
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
178
|
+
total += Math.abs(v);
|
|
179
|
+
}
|
|
180
|
+
return total;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// dispBlanksAs partition (B3) + polygon path builder
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
interface AreaPoint {
|
|
188
|
+
c: number;
|
|
189
|
+
upper: number;
|
|
190
|
+
lower: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Partition a series' (upper, lower) boundary arrays into contiguous
|
|
195
|
+
* polygon segments based on `dispBlanksAs`.
|
|
196
|
+
*
|
|
197
|
+
* - `"zero"`: null → 0 on upper, 0 on lower; single segment.
|
|
198
|
+
* - `"gap"`: null splits into multiple segments. Each segment is a
|
|
199
|
+
* contiguous run of non-null categories.
|
|
200
|
+
* - `"span"`: nulls dropped from the sequence; single segment.
|
|
201
|
+
*/
|
|
202
|
+
function partitionForDispBlanksAs(
|
|
203
|
+
upper: ReadonlyArray<number | null>,
|
|
204
|
+
lower: ReadonlyArray<number | null>,
|
|
205
|
+
mode: "gap" | "zero" | "span",
|
|
206
|
+
): AreaPoint[][] {
|
|
207
|
+
const n = upper.length;
|
|
208
|
+
switch (mode) {
|
|
209
|
+
case "zero": {
|
|
210
|
+
const out: AreaPoint[] = [];
|
|
211
|
+
for (let c = 0; c < n; c++) {
|
|
212
|
+
const u = upper[c];
|
|
213
|
+
const l = lower[c];
|
|
214
|
+
out.push({
|
|
215
|
+
c,
|
|
216
|
+
upper: u === null ? 0 : u,
|
|
217
|
+
lower: l === null ? 0 : l,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return [out];
|
|
221
|
+
}
|
|
222
|
+
case "gap": {
|
|
223
|
+
const segments: AreaPoint[][] = [];
|
|
224
|
+
let cur: AreaPoint[] = [];
|
|
225
|
+
for (let c = 0; c < n; c++) {
|
|
226
|
+
const u = upper[c];
|
|
227
|
+
const l = lower[c];
|
|
228
|
+
if (u === null || l === null) {
|
|
229
|
+
if (cur.length > 0) {
|
|
230
|
+
segments.push(cur);
|
|
231
|
+
cur = [];
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
cur.push({ c, upper: u, lower: l });
|
|
236
|
+
}
|
|
237
|
+
if (cur.length > 0) segments.push(cur);
|
|
238
|
+
return segments;
|
|
239
|
+
}
|
|
240
|
+
case "span": {
|
|
241
|
+
const out: AreaPoint[] = [];
|
|
242
|
+
for (let c = 0; c < n; c++) {
|
|
243
|
+
const u = upper[c];
|
|
244
|
+
const l = lower[c];
|
|
245
|
+
if (u === null || l === null) continue;
|
|
246
|
+
out.push({ c, upper: u, lower: l });
|
|
247
|
+
}
|
|
248
|
+
return out.length > 0 ? [out] : [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildAreaPath(
|
|
254
|
+
seg: ReadonlyArray<AreaPoint>,
|
|
255
|
+
toX: (c: number) => number,
|
|
256
|
+
toY: (v: number) => number,
|
|
257
|
+
): string {
|
|
258
|
+
if (seg.length < 2) return "";
|
|
259
|
+
const parts: string[] = [];
|
|
260
|
+
// Walk the upper boundary left → right.
|
|
261
|
+
parts.push(`M ${fmt(toX(seg[0]!.c))} ${fmt(toY(seg[0]!.upper))}`);
|
|
262
|
+
for (let i = 1; i < seg.length; i++) {
|
|
263
|
+
parts.push(`L ${fmt(toX(seg[i]!.c))} ${fmt(toY(seg[i]!.upper))}`);
|
|
264
|
+
}
|
|
265
|
+
// Then the lower boundary right → left to close.
|
|
266
|
+
for (let i = seg.length - 1; i >= 0; i--) {
|
|
267
|
+
parts.push(`L ${fmt(toX(seg[i]!.c))} ${fmt(toY(seg[i]!.lower))}`);
|
|
268
|
+
}
|
|
269
|
+
parts.push("Z");
|
|
270
|
+
return parts.join(" ");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function fmt(n: number): string {
|
|
274
|
+
if (!Number.isFinite(n)) return "0";
|
|
275
|
+
const r = Math.round(n * 1000) / 1000;
|
|
276
|
+
return r === 0 ? "0" : String(r);
|
|
277
|
+
}
|
|
@@ -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
|
+
}
|