@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title layout — chart title + axis titles (Stage 3C).
|
|
3
|
+
*
|
|
4
|
+
* Chart title:
|
|
5
|
+
* - Centered horizontally within the reserved title band.
|
|
6
|
+
* - Vertical baseline respects the title font's ascent/descent.
|
|
7
|
+
* - Rich text (c:tx/c:rich) is not yet unpacked — the title.text
|
|
8
|
+
* string from the parser is treated as the full title. Rich runs
|
|
9
|
+
* land in Stage 4G alongside data labels.
|
|
10
|
+
*
|
|
11
|
+
* Axis titles:
|
|
12
|
+
* - Y-axis: rotated −90° (anti-clockwise). Title sits in the leftmost
|
|
13
|
+
* column of the y-axis band, vertically centered on the plot area.
|
|
14
|
+
* - Secondary y-axis: rotated +90° so the baseline reads top-to-bottom
|
|
15
|
+
* on the right side. Title sits in the rightmost column of the
|
|
16
|
+
* secondary y-axis band.
|
|
17
|
+
* - X-axis: no rotation. Title sits below the tick-label band,
|
|
18
|
+
* horizontally centered on the plot area.
|
|
19
|
+
*
|
|
20
|
+
* The helpers return text-placement descriptors (x/y position, font
|
|
21
|
+
* size, rotation, anchor) rather than SVG elements — renderers compose
|
|
22
|
+
* these via `svgText` from `svg-primitives.ts`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Rect } from "./plot-area.ts";
|
|
26
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
27
|
+
import { measureText } from "../render/font-metrics.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Public API
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export type AxisTitleKind = "x" | "y" | "secondaryY";
|
|
34
|
+
|
|
35
|
+
export interface TitlePlacement {
|
|
36
|
+
text: string;
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
fontSizePt: number;
|
|
40
|
+
bold: boolean;
|
|
41
|
+
/** SVG rotation in degrees, clockwise. 0 = upright. */
|
|
42
|
+
rotate: number;
|
|
43
|
+
/** SVG text-anchor: where the (x,y) anchor point lies on the glyph run. */
|
|
44
|
+
anchor: "start" | "middle" | "end";
|
|
45
|
+
/** Vertical alignment of the (x,y) point to the glyph row. */
|
|
46
|
+
baseline: "hanging" | "middle" | "auto";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TitleLayoutOptions {
|
|
50
|
+
/** Font size in points. Word default: 14pt for chart title, 10pt for axis titles. */
|
|
51
|
+
fontSizePt?: number;
|
|
52
|
+
/** Whether the title is rendered in bold. Word chart title default: true. */
|
|
53
|
+
bold?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Chart title
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Place a chart title centered horizontally inside `titleRect`. The
|
|
62
|
+
* returned placement anchors at the middle of the title band and uses
|
|
63
|
+
* `text-anchor: middle` so the glyph run spans equally on each side.
|
|
64
|
+
*
|
|
65
|
+
* Returns `null` when text is empty — callers should not emit a title
|
|
66
|
+
* element at all in that case.
|
|
67
|
+
*/
|
|
68
|
+
export function layoutTitle(
|
|
69
|
+
text: string,
|
|
70
|
+
titleRect: Rect,
|
|
71
|
+
theme: ResolvedTheme | undefined,
|
|
72
|
+
options: TitleLayoutOptions = {},
|
|
73
|
+
): TitlePlacement | null {
|
|
74
|
+
if (!text) return null;
|
|
75
|
+
const fontSizePt = options.fontSizePt ?? 14;
|
|
76
|
+
const bold = options.bold ?? true;
|
|
77
|
+
// Consume theme for future font-fallback resolution.
|
|
78
|
+
void theme;
|
|
79
|
+
const centerX = titleRect.x + titleRect.w / 2;
|
|
80
|
+
const centerY = titleRect.y + titleRect.h / 2;
|
|
81
|
+
return {
|
|
82
|
+
text,
|
|
83
|
+
x: centerX,
|
|
84
|
+
y: centerY,
|
|
85
|
+
fontSizePt,
|
|
86
|
+
bold,
|
|
87
|
+
rotate: 0,
|
|
88
|
+
anchor: "middle",
|
|
89
|
+
baseline: "middle",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Axis titles
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Place an axis title inside the axis-title band. The `kind` determines
|
|
99
|
+
* rotation:
|
|
100
|
+
* - `"y"` → −90° (baseline reads bottom-to-top on the left)
|
|
101
|
+
* - `"secondaryY"` → +90° (baseline reads top-to-bottom on the right)
|
|
102
|
+
* - `"x"` → 0° (no rotation)
|
|
103
|
+
*
|
|
104
|
+
* `bandRect` is the rectangle the plot-area reserved for the axis title
|
|
105
|
+
* itself (NOT the full axis band — just the title sliver). For y / sY
|
|
106
|
+
* this is typically ~14px wide (the `AXIS_TITLE_BAND_PX` constant from
|
|
107
|
+
* plot-area.ts); for x it's ~14px tall.
|
|
108
|
+
*
|
|
109
|
+
* `plotRect` is the plot area the title labels. The title is visually
|
|
110
|
+
* centered against the plot area (not the band) so it aligns with the
|
|
111
|
+
* mid-point of the axis data range.
|
|
112
|
+
*/
|
|
113
|
+
export function layoutAxisTitle(
|
|
114
|
+
text: string,
|
|
115
|
+
kind: AxisTitleKind,
|
|
116
|
+
bandRect: Rect,
|
|
117
|
+
plotRect: Rect,
|
|
118
|
+
theme: ResolvedTheme | undefined,
|
|
119
|
+
options: TitleLayoutOptions = {},
|
|
120
|
+
): TitlePlacement | null {
|
|
121
|
+
if (!text) return null;
|
|
122
|
+
const fontSizePt = options.fontSizePt ?? 10;
|
|
123
|
+
const bold = options.bold ?? false;
|
|
124
|
+
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case "y": {
|
|
127
|
+
// Vertical center on the plot, rotated -90°. Anchor at band center x.
|
|
128
|
+
// Consume theme to keep signature future-proof.
|
|
129
|
+
void theme;
|
|
130
|
+
return {
|
|
131
|
+
text,
|
|
132
|
+
x: bandRect.x + bandRect.w / 2,
|
|
133
|
+
y: plotRect.y + plotRect.h / 2,
|
|
134
|
+
fontSizePt,
|
|
135
|
+
bold,
|
|
136
|
+
rotate: -90,
|
|
137
|
+
anchor: "middle",
|
|
138
|
+
baseline: "middle",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "secondaryY": {
|
|
142
|
+
return {
|
|
143
|
+
text,
|
|
144
|
+
x: bandRect.x + bandRect.w / 2,
|
|
145
|
+
y: plotRect.y + plotRect.h / 2,
|
|
146
|
+
fontSizePt,
|
|
147
|
+
bold,
|
|
148
|
+
rotate: 90,
|
|
149
|
+
anchor: "middle",
|
|
150
|
+
baseline: "middle",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
case "x": {
|
|
154
|
+
return {
|
|
155
|
+
text,
|
|
156
|
+
x: plotRect.x + plotRect.w / 2,
|
|
157
|
+
y: bandRect.y + bandRect.h / 2,
|
|
158
|
+
fontSizePt,
|
|
159
|
+
bold,
|
|
160
|
+
rotate: 0,
|
|
161
|
+
anchor: "middle",
|
|
162
|
+
baseline: "middle",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Measurement helper for band sizing
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Measure the title height for the given text + font size, matching the
|
|
174
|
+
* row height a single-line title would occupy. Callers can use this to
|
|
175
|
+
* validate `titleRect.h` was reserved adequately before rendering.
|
|
176
|
+
*/
|
|
177
|
+
export function measureTitleBandHeight(
|
|
178
|
+
text: string,
|
|
179
|
+
fontSizePt: number,
|
|
180
|
+
theme: ResolvedTheme | undefined,
|
|
181
|
+
): number {
|
|
182
|
+
if (!text) return 0;
|
|
183
|
+
return measureText(text, { fontSizePt, bold: true }, theme).lineHeight;
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|