@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,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pie / doughnut chart renderer (Stage 4 Slice 4C, B6).
|
|
3
|
+
*
|
|
4
|
+
* Each slice is rendered as an SVG arc path. Doughnuts punch an inner
|
|
5
|
+
* circular void via `holeSizePercent`. Per-slice `explosion` offsets
|
|
6
|
+
* the slice radially along its bisector.
|
|
7
|
+
*
|
|
8
|
+
* Angle conventions (B6):
|
|
9
|
+
* - OOXML's `c:firstSliceAngle` is degrees clockwise from 12 o'clock.
|
|
10
|
+
* - SVG's math convention is counter-clockwise from 3 o'clock.
|
|
11
|
+
* - Conversion: `svgDeg = wordDeg - 90`.
|
|
12
|
+
*
|
|
13
|
+
* `varyColors`:
|
|
14
|
+
* - `true` (Word pie default): each slice draws from the chart-style
|
|
15
|
+
* palette via `paletteColorRef(seriesColorMode, sliceIdx)` +
|
|
16
|
+
* `resolveColor(ref, theme)`.
|
|
17
|
+
* - `false`: every slice shares the series color.
|
|
18
|
+
*
|
|
19
|
+
* Zero or negative values render no slice (pies reject negatives per
|
|
20
|
+
* Word's observed behavior).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React from "react";
|
|
24
|
+
import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
|
|
25
|
+
import { paletteColorRef } from "../../../io/ooxml/chart/color-palette.ts";
|
|
26
|
+
import { resolveColor } from "../../../io/ooxml/chart/resolve-color.ts";
|
|
27
|
+
import {
|
|
28
|
+
getChartStyle,
|
|
29
|
+
resolveChartStyleId,
|
|
30
|
+
} from "../../../io/ooxml/chart/chart-style-table.ts";
|
|
31
|
+
import type {
|
|
32
|
+
DataPointOverride,
|
|
33
|
+
PieChartModel,
|
|
34
|
+
PieSeries,
|
|
35
|
+
} from "../../../io/ooxml/chart/types.ts";
|
|
36
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
37
|
+
import type { PlotAreaLayout } from "../layout/plot-area.ts";
|
|
38
|
+
import { DefsRegistry, resolveFill } from "./svg-primitives.ts";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Public API
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export interface PieChartProps {
|
|
45
|
+
model: PieChartModel;
|
|
46
|
+
layout: PlotAreaLayout;
|
|
47
|
+
theme: ResolvedTheme | undefined;
|
|
48
|
+
defs?: DefsRegistry;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function PieChartImpl({ model, layout, theme, defs }: PieChartProps): React.ReactElement {
|
|
52
|
+
const defsRegistry = defs ?? new DefsRegistry();
|
|
53
|
+
const plot = layout.plotRect;
|
|
54
|
+
|
|
55
|
+
const series = model.series[0];
|
|
56
|
+
if (!series) {
|
|
57
|
+
return <g data-role="pie-chart" data-empty="true" />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Pie geometry ---
|
|
61
|
+
const cx = plot.x + plot.w / 2;
|
|
62
|
+
const cy = plot.y + plot.h / 2;
|
|
63
|
+
const rOuter = Math.max(0, Math.min(plot.w, plot.h) / 2);
|
|
64
|
+
const rInner = model.doughnut
|
|
65
|
+
? (rOuter * Math.max(10, Math.min(90, model.holeSizePercent ?? 50))) / 100
|
|
66
|
+
: 0;
|
|
67
|
+
|
|
68
|
+
// --- Compute slice angles ---
|
|
69
|
+
const values = series.values;
|
|
70
|
+
const total = values.reduce<number>((sum, v) => {
|
|
71
|
+
if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) return sum;
|
|
72
|
+
return sum + v;
|
|
73
|
+
}, 0);
|
|
74
|
+
|
|
75
|
+
if (total === 0) {
|
|
76
|
+
return <g data-role="pie-chart" data-empty="true" />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- B6 angle conversion ---
|
|
80
|
+
const startDegWord = model.firstSliceAngle ?? 0;
|
|
81
|
+
let currentAngleWord = startDegWord;
|
|
82
|
+
|
|
83
|
+
const slices: React.ReactElement[] = [];
|
|
84
|
+
const palette = computePalette(model);
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < values.length; i++) {
|
|
87
|
+
const v = values[i];
|
|
88
|
+
if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) continue;
|
|
89
|
+
const sweep = (v / total) * 360;
|
|
90
|
+
const startWord = currentAngleWord;
|
|
91
|
+
const endWord = currentAngleWord + sweep;
|
|
92
|
+
currentAngleWord = endWord;
|
|
93
|
+
|
|
94
|
+
const color = resolveSliceColor(model, theme, series, i, palette, defsRegistry);
|
|
95
|
+
|
|
96
|
+
const explosionPct = resolveExplosion(series, i);
|
|
97
|
+
const midWord = (startWord + endWord) / 2;
|
|
98
|
+
const { cx: ecx, cy: ecy } = applyExplosion(cx, cy, rOuter, midWord, explosionPct);
|
|
99
|
+
|
|
100
|
+
const d = buildSlicePath(ecx, ecy, rOuter, rInner, startWord, endWord);
|
|
101
|
+
slices.push(
|
|
102
|
+
<path
|
|
103
|
+
key={`slice-${i}`}
|
|
104
|
+
d={d}
|
|
105
|
+
fill={color}
|
|
106
|
+
data-role="slice"
|
|
107
|
+
data-slice-index={i}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<g data-role="pie-chart" data-doughnut={model.doughnut} data-vary-colors={model.varyColors}>
|
|
114
|
+
<g data-role="slices">{slices}</g>
|
|
115
|
+
<g data-role="data-labels" />
|
|
116
|
+
</g>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const PieChart = React.memo(
|
|
121
|
+
PieChartImpl,
|
|
122
|
+
(prev, next) =>
|
|
123
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
124
|
+
prev.layout.plotRect.w === next.layout.plotRect.w &&
|
|
125
|
+
prev.layout.plotRect.h === next.layout.plotRect.h &&
|
|
126
|
+
prev.theme === next.theme,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Geometry helpers
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert an OOXML "clockwise from 12 o'clock" degree angle to SVG
|
|
135
|
+
* math-convention "counter-clockwise from 3 o'clock" radians (B6).
|
|
136
|
+
*
|
|
137
|
+
* Word: 0° → top; 90° → right; 180° → bottom; 270° → left.
|
|
138
|
+
* SVG pixel space: +x right, +y down. To place points correctly we
|
|
139
|
+
* compute `cos`/`sin` with (wordDeg - 90) degrees — this preserves the
|
|
140
|
+
* clockwise direction visually because pixel y grows downward.
|
|
141
|
+
*/
|
|
142
|
+
function polarToCartesian(
|
|
143
|
+
cx: number,
|
|
144
|
+
cy: number,
|
|
145
|
+
r: number,
|
|
146
|
+
wordDeg: number,
|
|
147
|
+
): { x: number; y: number } {
|
|
148
|
+
const svgDeg = wordDeg - 90;
|
|
149
|
+
const rad = (svgDeg * Math.PI) / 180;
|
|
150
|
+
return {
|
|
151
|
+
x: cx + r * Math.cos(rad),
|
|
152
|
+
y: cy + r * Math.sin(rad),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the SVG path d-string for a single slice.
|
|
158
|
+
*
|
|
159
|
+
* Pie (rInner=0): `M cx cy L p0 A r r 0 large 1 p1 Z`
|
|
160
|
+
* Doughnut (rInner>0):
|
|
161
|
+
* `M p0Outer A rOut rOut 0 large 1 p1Outer L p1Inner
|
|
162
|
+
* A rIn rIn 0 large 0 p0Inner Z`
|
|
163
|
+
*
|
|
164
|
+
* The large-arc flag is `1` when the slice sweeps > 180°.
|
|
165
|
+
* The sweep flag is `1` for the outer arc (clockwise in SVG), `0` for
|
|
166
|
+
* the inner arc (reverse direction to close the annulus correctly).
|
|
167
|
+
*/
|
|
168
|
+
function buildSlicePath(
|
|
169
|
+
cx: number,
|
|
170
|
+
cy: number,
|
|
171
|
+
rOuter: number,
|
|
172
|
+
rInner: number,
|
|
173
|
+
startWord: number,
|
|
174
|
+
endWord: number,
|
|
175
|
+
): string {
|
|
176
|
+
const sweep = endWord - startWord;
|
|
177
|
+
const largeArc = sweep > 180 ? 1 : 0;
|
|
178
|
+
const p0Outer = polarToCartesian(cx, cy, rOuter, startWord);
|
|
179
|
+
const p1Outer = polarToCartesian(cx, cy, rOuter, endWord);
|
|
180
|
+
|
|
181
|
+
if (rInner <= 0) {
|
|
182
|
+
// Full pie slice.
|
|
183
|
+
return (
|
|
184
|
+
`M ${fmt(cx)} ${fmt(cy)} ` +
|
|
185
|
+
`L ${fmt(p0Outer.x)} ${fmt(p0Outer.y)} ` +
|
|
186
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 ${largeArc} 1 ${fmt(p1Outer.x)} ${fmt(p1Outer.y)} Z`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Doughnut slice: outer arc forward, inner arc backward.
|
|
191
|
+
const p0Inner = polarToCartesian(cx, cy, rInner, startWord);
|
|
192
|
+
const p1Inner = polarToCartesian(cx, cy, rInner, endWord);
|
|
193
|
+
return (
|
|
194
|
+
`M ${fmt(p0Outer.x)} ${fmt(p0Outer.y)} ` +
|
|
195
|
+
`A ${fmt(rOuter)} ${fmt(rOuter)} 0 ${largeArc} 1 ${fmt(p1Outer.x)} ${fmt(p1Outer.y)} ` +
|
|
196
|
+
`L ${fmt(p1Inner.x)} ${fmt(p1Inner.y)} ` +
|
|
197
|
+
`A ${fmt(rInner)} ${fmt(rInner)} 0 ${largeArc} 0 ${fmt(p0Inner.x)} ${fmt(p0Inner.y)} Z`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Offset a slice center radially outward along its bisector by the
|
|
203
|
+
* explosion percentage (0 = no offset, 100 = push out by full radius;
|
|
204
|
+
* Word visually caps the real-rendered effect at ~35% of radius).
|
|
205
|
+
*/
|
|
206
|
+
function applyExplosion(
|
|
207
|
+
cx: number,
|
|
208
|
+
cy: number,
|
|
209
|
+
rOuter: number,
|
|
210
|
+
midAngleWord: number,
|
|
211
|
+
explosionPct: number,
|
|
212
|
+
): { cx: number; cy: number } {
|
|
213
|
+
if (explosionPct === 0) return { cx, cy };
|
|
214
|
+
const dist = rOuter * Math.min(1, explosionPct / 100) * 0.35;
|
|
215
|
+
const p = polarToCartesian(0, 0, dist, midAngleWord);
|
|
216
|
+
return { cx: cx + p.x, cy: cy + p.y };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Color & explosion resolution
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function computePalette(
|
|
224
|
+
model: PieChartModel,
|
|
225
|
+
): (sliceIdx: number) => string | null {
|
|
226
|
+
// Returns null to indicate "fall through to series color" when
|
|
227
|
+
// varyColors is false and no palette path is taken.
|
|
228
|
+
const style = getChartStyle(resolveChartStyleId(model.styleId));
|
|
229
|
+
const mode = style.seriesColorMode;
|
|
230
|
+
return (sliceIdx: number) => paletteColorRef(mode, sliceIdx).kind === "scheme" ? null : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resolveSliceColor(
|
|
234
|
+
model: PieChartModel,
|
|
235
|
+
theme: ResolvedTheme | undefined,
|
|
236
|
+
series: PieSeries,
|
|
237
|
+
sliceIdx: number,
|
|
238
|
+
_palette: (i: number) => string | null,
|
|
239
|
+
defs: DefsRegistry,
|
|
240
|
+
): string {
|
|
241
|
+
// Per-slice dPt override wins.
|
|
242
|
+
const dPt = findDataPointOverride(series.dataPoints, sliceIdx);
|
|
243
|
+
if (dPt?.spPr?.fill) {
|
|
244
|
+
return resolveFill(dPt.spPr.fill, theme, defs);
|
|
245
|
+
}
|
|
246
|
+
if (model.varyColors) {
|
|
247
|
+
// Per-slice palette color.
|
|
248
|
+
const style = getChartStyle(resolveChartStyleId(model.styleId));
|
|
249
|
+
const ref = paletteColorRef(style.seriesColorMode, sliceIdx);
|
|
250
|
+
return resolveColor(ref, theme ?? { colors: {} });
|
|
251
|
+
}
|
|
252
|
+
// Single series color.
|
|
253
|
+
return composeSeriesColor(model, theme ?? { colors: {} }, 0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function resolveExplosion(series: PieSeries, sliceIdx: number): number {
|
|
257
|
+
const dPt = findDataPointOverride(series.dataPoints, sliceIdx);
|
|
258
|
+
if (dPt?.explosion !== undefined) return dPt.explosion;
|
|
259
|
+
return series.explosion ?? 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function findDataPointOverride(
|
|
263
|
+
dPts: DataPointOverride[] | undefined,
|
|
264
|
+
idx: number,
|
|
265
|
+
): DataPointOverride | undefined {
|
|
266
|
+
if (!dPts) return undefined;
|
|
267
|
+
for (const d of dPts) if (d.idx === idx) return d;
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function fmt(n: number): string {
|
|
272
|
+
if (!Number.isFinite(n)) return "0";
|
|
273
|
+
const r = Math.round(n * 1000) / 1000;
|
|
274
|
+
return r === 0 ? "0" : String(r);
|
|
275
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive rendering hook for large chart datasets (Stage 5).
|
|
3
|
+
*
|
|
4
|
+
* Charts with ≤ PROGRESSIVE_THRESHOLD data points render all points
|
|
5
|
+
* synchronously. Larger charts start by rendering the first threshold points
|
|
6
|
+
* and advance in PROGRESSIVE_BATCH_SIZE increments per idle tick so the
|
|
7
|
+
* main thread stays responsive (no web workers — platform constraint).
|
|
8
|
+
*
|
|
9
|
+
* In SSR / Node.js test environments (no `document`) all points are returned
|
|
10
|
+
* immediately so `renderToStaticMarkup` produces a complete SVG.
|
|
11
|
+
*
|
|
12
|
+
* Scheduling: prefers `requestIdleCallback` (chromium, Safari 17+) with a
|
|
13
|
+
* 100 ms deadline, falls back to `setTimeout(0)` for Firefox / older Safari.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useReducer, useEffect } from "react";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants (exported so callers can reference them in assertions)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Maximum data-point count that renders synchronously (≤ this → no chunking). */
|
|
23
|
+
export const PROGRESSIVE_THRESHOLD = 500;
|
|
24
|
+
|
|
25
|
+
/** Points added per idle tick when total count exceeds PROGRESSIVE_THRESHOLD. */
|
|
26
|
+
export const PROGRESSIVE_BATCH_SIZE = 100;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// SSR detection (evaluated once at module load — stable across renders)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when running in a browser environment. In SSR / Node.js test
|
|
34
|
+
* environments this is false and `useProgressiveCount` returns `totalCount`
|
|
35
|
+
* immediately.
|
|
36
|
+
*/
|
|
37
|
+
export const IS_BROWSER: boolean =
|
|
38
|
+
typeof document !== "undefined" && typeof window !== "undefined";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Reducer
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
type Action =
|
|
45
|
+
| { type: "advance"; to: number }
|
|
46
|
+
| { type: "reset"; initialCount: number };
|
|
47
|
+
|
|
48
|
+
function countReducer(_state: number, action: Action): number {
|
|
49
|
+
switch (action.type) {
|
|
50
|
+
case "advance":
|
|
51
|
+
return action.to;
|
|
52
|
+
case "reset":
|
|
53
|
+
return action.initialCount;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Hook
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the number of data points to render in the current React frame.
|
|
63
|
+
*
|
|
64
|
+
* Usage in a chart renderer:
|
|
65
|
+
* ```tsx
|
|
66
|
+
* const visibleCount = useProgressiveCount(categoryCount);
|
|
67
|
+
* for (let c = 0; c < visibleCount; c++) { ... }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @param totalCount - Total number of data points to eventually render.
|
|
71
|
+
* @param threshold - Points below this count render synchronously (default: 500).
|
|
72
|
+
* @param batchSize - Points added per idle tick above the threshold (default: 100).
|
|
73
|
+
*/
|
|
74
|
+
export function useProgressiveCount(
|
|
75
|
+
totalCount: number,
|
|
76
|
+
threshold = PROGRESSIVE_THRESHOLD,
|
|
77
|
+
batchSize = PROGRESSIVE_BATCH_SIZE,
|
|
78
|
+
): number {
|
|
79
|
+
const initialCount = IS_BROWSER ? Math.min(threshold, totalCount) : totalCount;
|
|
80
|
+
|
|
81
|
+
const [visibleCount, dispatch] = useReducer(countReducer, initialCount);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
// SSR or already fully rendered — nothing to schedule.
|
|
85
|
+
if (!IS_BROWSER || visibleCount >= totalCount) return;
|
|
86
|
+
|
|
87
|
+
const next = Math.min(visibleCount + batchSize, totalCount);
|
|
88
|
+
|
|
89
|
+
if (typeof requestIdleCallback !== "undefined") {
|
|
90
|
+
const h = requestIdleCallback(
|
|
91
|
+
() => dispatch({ type: "advance", to: next }),
|
|
92
|
+
{ timeout: 100 },
|
|
93
|
+
);
|
|
94
|
+
return () => cancelIdleCallback(h);
|
|
95
|
+
} else {
|
|
96
|
+
const h = setTimeout(() => dispatch({ type: "advance", to: next }), 0);
|
|
97
|
+
return () => clearTimeout(h);
|
|
98
|
+
}
|
|
99
|
+
}, [visibleCount, totalCount, batchSize]);
|
|
100
|
+
|
|
101
|
+
// Guard: if totalCount shrinks (e.g. model update) clamp to valid range.
|
|
102
|
+
return Math.min(visibleCount, totalCount);
|
|
103
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scatter chart renderer (Stage 4 Slice 4E).
|
|
3
|
+
*
|
|
4
|
+
* Scatter charts use numeric (value) x-axis, unlike bar/line/area which
|
|
5
|
+
* use categorical x-axes. Supports 5 scatter styles (OOXML `c:scatterStyle`):
|
|
6
|
+
*
|
|
7
|
+
* - `"line"` — connected polyline, no markers.
|
|
8
|
+
* - `"lineMarker"` — connected polyline + markers at each point.
|
|
9
|
+
* - `"marker"` — markers only (default scatter appearance).
|
|
10
|
+
* - `"smooth"` — smoothed line through points, no markers.
|
|
11
|
+
* - `"smoothMarker"` — smoothed line + markers.
|
|
12
|
+
*
|
|
13
|
+
* Null x or y values skip that data point.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
|
|
18
|
+
import type {
|
|
19
|
+
ScatterChartModel,
|
|
20
|
+
ScatterSeries,
|
|
21
|
+
MarkerSpec,
|
|
22
|
+
} from "../../../io/ooxml/chart/types.ts";
|
|
23
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
24
|
+
import type { PlotAreaLayout } from "../layout/plot-area.ts";
|
|
25
|
+
import { smoothPath, type Point } from "./smooth-curve.ts";
|
|
26
|
+
import { useProgressiveCount } from "./progressive-render.ts";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Public API
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface ScatterChartProps {
|
|
33
|
+
model: ScatterChartModel;
|
|
34
|
+
layout: PlotAreaLayout;
|
|
35
|
+
theme: ResolvedTheme | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ScatterChartImpl({ model, layout, theme }: ScatterChartProps): React.ReactElement {
|
|
39
|
+
const plot = layout.plotRect;
|
|
40
|
+
const { xMin, xMax, yMin, yMax } = computeRange(model);
|
|
41
|
+
const xSpan = Math.max(1e-9, xMax - xMin);
|
|
42
|
+
const ySpan = Math.max(1e-9, yMax - yMin);
|
|
43
|
+
|
|
44
|
+
const totalPoints = Math.max(0, ...model.series.map(s => s.xValues.length));
|
|
45
|
+
const visiblePoints = useProgressiveCount(totalPoints);
|
|
46
|
+
|
|
47
|
+
const toPoint = (x: number, y: number): Point => [
|
|
48
|
+
plot.x + ((x - xMin) / xSpan) * plot.w,
|
|
49
|
+
plot.y + plot.h - ((y - yMin) / ySpan) * plot.h,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const style = model.style;
|
|
53
|
+
const showLine = style === "line" || style === "lineMarker" || style === "smooth" || style === "smoothMarker";
|
|
54
|
+
const smooth = style === "smooth" || style === "smoothMarker";
|
|
55
|
+
const showMarkers = style === "marker" || style === "lineMarker" || style === "smoothMarker";
|
|
56
|
+
|
|
57
|
+
const lines: React.ReactElement[] = [];
|
|
58
|
+
const markers: React.ReactElement[] = [];
|
|
59
|
+
|
|
60
|
+
for (let s = 0; s < model.series.length; s++) {
|
|
61
|
+
const series = model.series[s]!;
|
|
62
|
+
const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
|
|
63
|
+
const points = collectPoints(series, toPoint, visiblePoints);
|
|
64
|
+
if (points.length === 0) continue;
|
|
65
|
+
|
|
66
|
+
if (showLine && points.length >= 2) {
|
|
67
|
+
const d = smooth ? smoothPath(points) : polylinePath(points);
|
|
68
|
+
lines.push(
|
|
69
|
+
<path
|
|
70
|
+
key={`scatter-line-${s}`}
|
|
71
|
+
d={d}
|
|
72
|
+
fill="none"
|
|
73
|
+
stroke={color}
|
|
74
|
+
strokeWidth={2}
|
|
75
|
+
data-role="scatter-line"
|
|
76
|
+
data-series-index={s}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (showMarkers) {
|
|
82
|
+
for (const [i, pt] of points.entries()) {
|
|
83
|
+
markers.push(
|
|
84
|
+
renderMarker({
|
|
85
|
+
key: `m-${s}-${i}`,
|
|
86
|
+
spec: series.marker,
|
|
87
|
+
point: pt,
|
|
88
|
+
color,
|
|
89
|
+
seriesIdx: s,
|
|
90
|
+
dataIdx: i,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<g data-role="scatter-chart" data-style={style}>
|
|
99
|
+
<g data-role="scatter-lines">{lines}</g>
|
|
100
|
+
<g data-role="markers">{markers}</g>
|
|
101
|
+
<g data-role="data-labels" />
|
|
102
|
+
</g>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const ScatterChart = React.memo(
|
|
107
|
+
ScatterChartImpl,
|
|
108
|
+
(prev, next) =>
|
|
109
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
110
|
+
prev.layout.plotRect.w === next.layout.plotRect.w &&
|
|
111
|
+
prev.layout.plotRect.h === next.layout.plotRect.h &&
|
|
112
|
+
prev.theme === next.theme,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Helpers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function computeRange(model: ScatterChartModel): {
|
|
120
|
+
xMin: number; xMax: number; yMin: number; yMax: number;
|
|
121
|
+
} {
|
|
122
|
+
const xAxisMin = model.xAxis.min;
|
|
123
|
+
const xAxisMax = model.xAxis.max;
|
|
124
|
+
const yAxisMin = model.yAxis.min;
|
|
125
|
+
const yAxisMax = model.yAxis.max;
|
|
126
|
+
if (
|
|
127
|
+
xAxisMin !== undefined && xAxisMax !== undefined &&
|
|
128
|
+
yAxisMin !== undefined && yAxisMax !== undefined
|
|
129
|
+
) {
|
|
130
|
+
return { xMin: xAxisMin, xMax: xAxisMax, yMin: yAxisMin, yMax: yAxisMax };
|
|
131
|
+
}
|
|
132
|
+
let xMin = Infinity, xMax = -Infinity;
|
|
133
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
134
|
+
for (const s of model.series) {
|
|
135
|
+
for (let i = 0; i < s.xValues.length; i++) {
|
|
136
|
+
const x = s.xValues[i];
|
|
137
|
+
const y = s.yValues[i];
|
|
138
|
+
if (x === null || y === null || !Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
139
|
+
if (x < xMin) xMin = x;
|
|
140
|
+
if (x > xMax) xMax = x;
|
|
141
|
+
if (y < yMin) yMin = y;
|
|
142
|
+
if (y > yMax) yMax = y;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!Number.isFinite(xMin)) xMin = 0;
|
|
146
|
+
if (!Number.isFinite(xMax)) xMax = 1;
|
|
147
|
+
if (!Number.isFinite(yMin)) yMin = 0;
|
|
148
|
+
if (!Number.isFinite(yMax)) yMax = 1;
|
|
149
|
+
return { xMin, xMax, yMin, yMax };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectPoints(
|
|
153
|
+
series: ScatterSeries,
|
|
154
|
+
toPoint: (x: number, y: number) => Point,
|
|
155
|
+
limit = Infinity,
|
|
156
|
+
): Point[] {
|
|
157
|
+
const out: Point[] = [];
|
|
158
|
+
const n = Math.min(series.xValues.length, series.yValues.length, limit);
|
|
159
|
+
for (let i = 0; i < n; i++) {
|
|
160
|
+
const x = series.xValues[i];
|
|
161
|
+
const y = series.yValues[i];
|
|
162
|
+
if (x === null || y === null || !Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
163
|
+
out.push(toPoint(x, y));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function polylinePath(points: ReadonlyArray<Point>): string {
|
|
169
|
+
if (points.length === 0) return "";
|
|
170
|
+
let d = `M ${points[0]![0]} ${points[0]![1]}`;
|
|
171
|
+
for (let i = 1; i < points.length; i++) {
|
|
172
|
+
d += ` L ${points[i]![0]} ${points[i]![1]}`;
|
|
173
|
+
}
|
|
174
|
+
return d;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface MarkerRenderInput {
|
|
178
|
+
key: string;
|
|
179
|
+
spec: MarkerSpec | undefined;
|
|
180
|
+
point: Point;
|
|
181
|
+
color: string;
|
|
182
|
+
seriesIdx: number;
|
|
183
|
+
dataIdx: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderMarker({ key, spec, point, color, seriesIdx, dataIdx }: MarkerRenderInput): React.ReactElement {
|
|
187
|
+
const symbol = spec?.symbol && spec.symbol !== "auto" ? spec.symbol : "circle";
|
|
188
|
+
const size = spec?.size ?? 5;
|
|
189
|
+
const [cx, cy] = point;
|
|
190
|
+
const shared = {
|
|
191
|
+
fill: color,
|
|
192
|
+
stroke: color,
|
|
193
|
+
strokeWidth: 1,
|
|
194
|
+
"data-role": "marker",
|
|
195
|
+
"data-series-index": seriesIdx,
|
|
196
|
+
"data-point-index": dataIdx,
|
|
197
|
+
} as const;
|
|
198
|
+
|
|
199
|
+
switch (symbol) {
|
|
200
|
+
case "square":
|
|
201
|
+
return <rect key={key} x={cx - size / 2} y={cy - size / 2} width={size} height={size} {...shared} />;
|
|
202
|
+
case "diamond":
|
|
203
|
+
return <polygon key={key} points={`${cx},${cy - size / 2} ${cx + size / 2},${cy} ${cx},${cy + size / 2} ${cx - size / 2},${cy}`} {...shared} />;
|
|
204
|
+
case "triangle":
|
|
205
|
+
return <polygon key={key} points={`${cx},${cy - size / 2} ${cx + size / 2},${cy + size / 2} ${cx - size / 2},${cy + size / 2}`} {...shared} />;
|
|
206
|
+
case "x":
|
|
207
|
+
case "plus": {
|
|
208
|
+
const half = size / 2;
|
|
209
|
+
const rot = symbol === "x" ? 45 : 0;
|
|
210
|
+
return (
|
|
211
|
+
<g key={key} transform={rot === 0 ? undefined : `rotate(${rot} ${cx} ${cy})`} data-role="marker" data-series-index={seriesIdx} data-point-index={dataIdx}>
|
|
212
|
+
<line x1={cx - half} y1={cy} x2={cx + half} y2={cy} stroke={color} strokeWidth={1.5} />
|
|
213
|
+
<line x1={cx} y1={cy - half} x2={cx} y2={cy + half} stroke={color} strokeWidth={1.5} />
|
|
214
|
+
</g>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
case "dot":
|
|
218
|
+
return <circle key={key} cx={cx} cy={cy} r={Math.max(1, size / 4)} {...shared} />;
|
|
219
|
+
case "dash":
|
|
220
|
+
return <line key={key} x1={cx - size / 2} y1={cy} x2={cx + size / 2} y2={cy} stroke={color} strokeWidth={2} data-role="marker" data-series-index={seriesIdx} data-point-index={dataIdx} />;
|
|
221
|
+
case "star":
|
|
222
|
+
case "picture":
|
|
223
|
+
case "circle":
|
|
224
|
+
case "none":
|
|
225
|
+
default:
|
|
226
|
+
return <circle key={key} cx={cx} cy={cy} r={size / 2} {...shared} />;
|
|
227
|
+
}
|
|
228
|
+
}
|