@beyondwork/docx-react-component 1.0.53 → 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 +1 -1
- package/src/api/public-types.ts +35 -7
- package/src/io/docx-session.ts +30 -6
- 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 +23 -9
- 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/render/render-frame-diff.ts +38 -2
- package/src/ui/WordReviewEditor.tsx +6 -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/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- 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,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line chart renderer (Stage 4 Slice 4B).
|
|
3
|
+
*
|
|
4
|
+
* Handles straight + smoothed + markers + gap handling. Respects
|
|
5
|
+
* `dispBlanksAs` (B3):
|
|
6
|
+
* - `"gap"`: break the polyline at null indices — emit separate path
|
|
7
|
+
* segments on either side of each gap.
|
|
8
|
+
* - `"zero"`: substitute null → 0 before rendering.
|
|
9
|
+
* - `"span"`: connect across null indices as if they were absent
|
|
10
|
+
* (the neighbors' chord closes the gap).
|
|
11
|
+
*
|
|
12
|
+
* Smoothing uses the in-tree Catmull-Rom → cubic-Bézier helper from
|
|
13
|
+
* `smooth-curve.ts` (Slice 4B). Per-series `smooth` override trumps the
|
|
14
|
+
* model-level `smooth` flag; same for `marker`.
|
|
15
|
+
*
|
|
16
|
+
* Stacked / percentStacked groupings sum values per category to derive
|
|
17
|
+
* cumulative y coordinates, matching Word's behavior.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import React from "react";
|
|
21
|
+
import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
|
|
22
|
+
import type {
|
|
23
|
+
LineChartModel,
|
|
24
|
+
LineSeries,
|
|
25
|
+
MarkerSpec,
|
|
26
|
+
} from "../../../io/ooxml/chart/types.ts";
|
|
27
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
28
|
+
import type { PlotAreaLayout } from "../layout/plot-area.ts";
|
|
29
|
+
import { smoothPath, type Point } from "./smooth-curve.ts";
|
|
30
|
+
import { useProgressiveCount } from "./progressive-render.ts";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Public API
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface LineChartProps {
|
|
37
|
+
model: LineChartModel;
|
|
38
|
+
layout: PlotAreaLayout;
|
|
39
|
+
theme: ResolvedTheme | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function LineChartImpl({ model, layout, theme }: LineChartProps): React.ReactElement {
|
|
43
|
+
const plot = layout.plotRect;
|
|
44
|
+
const { valueMin, valueMax } = computeValueRange(model);
|
|
45
|
+
const categoryCount = Math.max(
|
|
46
|
+
1,
|
|
47
|
+
model.categoryAxis.kind === "category"
|
|
48
|
+
? model.categoryAxis.categoryLabels.length
|
|
49
|
+
: model.series[0]?.values.length ?? 1,
|
|
50
|
+
);
|
|
51
|
+
const visibleCategories = useProgressiveCount(categoryCount);
|
|
52
|
+
const slotWidth = plot.w / categoryCount;
|
|
53
|
+
const valueSpan = Math.max(1e-9, valueMax - valueMin);
|
|
54
|
+
|
|
55
|
+
/** Map (categoryIdx, value) → plot-coordinate point. */
|
|
56
|
+
const toPoint = (c: number, v: number): Point => {
|
|
57
|
+
const x = plot.x + (c + 0.5) * slotWidth;
|
|
58
|
+
const frac = (v - valueMin) / valueSpan;
|
|
59
|
+
const y = plot.y + plot.h - frac * plot.h;
|
|
60
|
+
return [x, y];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const lines: React.ReactElement[] = [];
|
|
64
|
+
const markers: React.ReactElement[] = [];
|
|
65
|
+
|
|
66
|
+
// Pre-compute per-series values (handling stacking).
|
|
67
|
+
const effectiveValues = computeEffectiveValues(model);
|
|
68
|
+
|
|
69
|
+
for (let s = 0; s < model.series.length; s++) {
|
|
70
|
+
const series = model.series[s]!;
|
|
71
|
+
const values = effectiveValues[s]!;
|
|
72
|
+
const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
|
|
73
|
+
const smooth = series.smooth ?? model.smooth;
|
|
74
|
+
const showMarkers = (series.marker?.symbol ?? null) !== "none"
|
|
75
|
+
&& ((series.marker !== undefined) || model.marker);
|
|
76
|
+
|
|
77
|
+
// Apply dispBlanksAs (B3); limit to visibleCategories for progressive render.
|
|
78
|
+
const visibleValues = visibleCategories < values.length
|
|
79
|
+
? values.slice(0, visibleCategories)
|
|
80
|
+
: values;
|
|
81
|
+
const segments = applyDispBlanksAs(visibleValues, model.dispBlanksAs);
|
|
82
|
+
|
|
83
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
84
|
+
const segment = segments[segIdx]!;
|
|
85
|
+
const points: Point[] = segment.map(({ c, v }) => toPoint(c, v));
|
|
86
|
+
if (points.length === 0) continue;
|
|
87
|
+
const d = smooth ? smoothPath(points) : polylinePath(points);
|
|
88
|
+
lines.push(
|
|
89
|
+
<path
|
|
90
|
+
key={`line-${s}-${segIdx}`}
|
|
91
|
+
d={d}
|
|
92
|
+
fill="none"
|
|
93
|
+
stroke={color}
|
|
94
|
+
strokeWidth={2}
|
|
95
|
+
data-role="line"
|
|
96
|
+
data-series-index={s}
|
|
97
|
+
data-segment-index={segIdx}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (showMarkers) {
|
|
102
|
+
for (const [i, pt] of points.entries()) {
|
|
103
|
+
markers.push(
|
|
104
|
+
renderMarker({
|
|
105
|
+
key: `m-${s}-${segIdx}-${i}`,
|
|
106
|
+
spec: series.marker,
|
|
107
|
+
point: pt,
|
|
108
|
+
color,
|
|
109
|
+
seriesIdx: s,
|
|
110
|
+
categoryIdx: segment[i]!.c,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<g data-role="line-chart" data-grouping={model.grouping}>
|
|
120
|
+
<g data-role="lines">{lines}</g>
|
|
121
|
+
<g data-role="markers">{markers}</g>
|
|
122
|
+
<g data-role="data-labels" />
|
|
123
|
+
</g>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const LineChart = React.memo(
|
|
128
|
+
LineChartImpl,
|
|
129
|
+
(prev, next) =>
|
|
130
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
131
|
+
prev.layout.plotRect.w === next.layout.plotRect.w &&
|
|
132
|
+
prev.layout.plotRect.h === next.layout.plotRect.h &&
|
|
133
|
+
prev.theme === next.theme,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Path helpers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function polylinePath(points: ReadonlyArray<Point>): string {
|
|
141
|
+
if (points.length === 0) return "";
|
|
142
|
+
const first = points[0]!;
|
|
143
|
+
let d = `M ${first[0]} ${first[1]}`;
|
|
144
|
+
for (let i = 1; i < points.length; i++) {
|
|
145
|
+
const p = points[i]!;
|
|
146
|
+
d += ` L ${p[0]} ${p[1]}`;
|
|
147
|
+
}
|
|
148
|
+
return d;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Value extraction (B3 — dispBlanksAs)
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
interface PointValue {
|
|
156
|
+
c: number;
|
|
157
|
+
v: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Convert a series' raw value array into contiguous segments based on
|
|
162
|
+
* `dispBlanksAs`. Each segment is a list of {categoryIdx, value} pairs
|
|
163
|
+
* that the renderer emits as a single path.
|
|
164
|
+
*
|
|
165
|
+
* - `"gap"`: nulls split the series into multiple segments (the path
|
|
166
|
+
* literally breaks at the gap).
|
|
167
|
+
* - `"zero"`: nulls become 0; single continuous segment.
|
|
168
|
+
* - `"span"`: nulls dropped; single segment connects neighbors directly.
|
|
169
|
+
*/
|
|
170
|
+
function applyDispBlanksAs(
|
|
171
|
+
values: ReadonlyArray<number | null>,
|
|
172
|
+
mode: "gap" | "zero" | "span",
|
|
173
|
+
): PointValue[][] {
|
|
174
|
+
switch (mode) {
|
|
175
|
+
case "gap": {
|
|
176
|
+
const segments: PointValue[][] = [];
|
|
177
|
+
let current: PointValue[] = [];
|
|
178
|
+
for (let c = 0; c < values.length; c++) {
|
|
179
|
+
const v = values[c];
|
|
180
|
+
if (v === null || v === undefined || !Number.isFinite(v)) {
|
|
181
|
+
if (current.length > 0) {
|
|
182
|
+
segments.push(current);
|
|
183
|
+
current = [];
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
current.push({ c, v });
|
|
188
|
+
}
|
|
189
|
+
if (current.length > 0) segments.push(current);
|
|
190
|
+
return segments;
|
|
191
|
+
}
|
|
192
|
+
case "zero": {
|
|
193
|
+
const out: PointValue[] = [];
|
|
194
|
+
for (let c = 0; c < values.length; c++) {
|
|
195
|
+
const v = values[c];
|
|
196
|
+
out.push({ c, v: v === null || v === undefined || !Number.isFinite(v) ? 0 : v });
|
|
197
|
+
}
|
|
198
|
+
return [out];
|
|
199
|
+
}
|
|
200
|
+
case "span": {
|
|
201
|
+
const out: PointValue[] = [];
|
|
202
|
+
for (let c = 0; c < values.length; c++) {
|
|
203
|
+
const v = values[c];
|
|
204
|
+
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
205
|
+
out.push({ c, v });
|
|
206
|
+
}
|
|
207
|
+
return out.length > 0 ? [out] : [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Stacking
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function computeEffectiveValues(
|
|
217
|
+
model: LineChartModel,
|
|
218
|
+
): Array<Array<number | null>> {
|
|
219
|
+
if (model.grouping === "standard") {
|
|
220
|
+
return model.series.map((s) => s.values.slice());
|
|
221
|
+
}
|
|
222
|
+
const categoryCount = Math.max(
|
|
223
|
+
0,
|
|
224
|
+
model.series[0]?.values.length ?? 0,
|
|
225
|
+
);
|
|
226
|
+
const out: Array<Array<number | null>> = [];
|
|
227
|
+
|
|
228
|
+
if (model.grouping === "stacked") {
|
|
229
|
+
const running = new Array<number>(categoryCount).fill(0);
|
|
230
|
+
for (const series of model.series) {
|
|
231
|
+
const row: Array<number | null> = [];
|
|
232
|
+
for (let c = 0; c < categoryCount; c++) {
|
|
233
|
+
const v = series.values[c];
|
|
234
|
+
if (v === null || v === undefined || !Number.isFinite(v)) {
|
|
235
|
+
row.push(null);
|
|
236
|
+
} else {
|
|
237
|
+
running[c] = running[c]! + v;
|
|
238
|
+
row.push(running[c]!);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
out.push(row);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
// percentStacked
|
|
246
|
+
const totals = new Array<number>(categoryCount).fill(0);
|
|
247
|
+
for (const series of model.series) {
|
|
248
|
+
for (let c = 0; c < categoryCount; c++) {
|
|
249
|
+
const v = series.values[c];
|
|
250
|
+
if (v === null || v === undefined || !Number.isFinite(v)) continue;
|
|
251
|
+
totals[c] = totals[c]! + v;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const running = new Array<number>(categoryCount).fill(0);
|
|
255
|
+
for (const series of model.series) {
|
|
256
|
+
const row: Array<number | null> = [];
|
|
257
|
+
for (let c = 0; c < categoryCount; c++) {
|
|
258
|
+
const v = series.values[c];
|
|
259
|
+
const total = totals[c]!;
|
|
260
|
+
if (v === null || v === undefined || !Number.isFinite(v) || total === 0) {
|
|
261
|
+
row.push(null);
|
|
262
|
+
} else {
|
|
263
|
+
running[c] = running[c]! + (v / total) * 100;
|
|
264
|
+
row.push(running[c]!);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
out.push(row);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function computeValueRange(model: LineChartModel): {
|
|
273
|
+
valueMin: number;
|
|
274
|
+
valueMax: number;
|
|
275
|
+
} {
|
|
276
|
+
if (model.valueAxis.min !== undefined && model.valueAxis.max !== undefined) {
|
|
277
|
+
return { valueMin: model.valueAxis.min, valueMax: model.valueAxis.max };
|
|
278
|
+
}
|
|
279
|
+
if (model.grouping === "percentStacked") {
|
|
280
|
+
return { valueMin: 0, valueMax: 100 };
|
|
281
|
+
}
|
|
282
|
+
const effective = computeEffectiveValues(model);
|
|
283
|
+
let min = 0;
|
|
284
|
+
let max = 0;
|
|
285
|
+
for (const row of effective) {
|
|
286
|
+
for (const v of row) {
|
|
287
|
+
if (v === null) continue;
|
|
288
|
+
if (v > max) max = v;
|
|
289
|
+
if (v < min) min = v;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { valueMin: min, valueMax: max };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Markers
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
interface MarkerRenderInput {
|
|
300
|
+
key: string;
|
|
301
|
+
spec: MarkerSpec | undefined;
|
|
302
|
+
point: Point;
|
|
303
|
+
color: string;
|
|
304
|
+
seriesIdx: number;
|
|
305
|
+
categoryIdx: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderMarker({ key, spec, point, color, seriesIdx, categoryIdx }: MarkerRenderInput): React.ReactElement {
|
|
309
|
+
const symbol = spec?.symbol && spec.symbol !== "auto" ? spec.symbol : "circle";
|
|
310
|
+
const size = spec?.size ?? 5;
|
|
311
|
+
const [cx, cy] = point;
|
|
312
|
+
const shared = {
|
|
313
|
+
fill: color,
|
|
314
|
+
stroke: color,
|
|
315
|
+
strokeWidth: 1,
|
|
316
|
+
"data-role": "marker",
|
|
317
|
+
"data-series-index": seriesIdx,
|
|
318
|
+
"data-category-index": categoryIdx,
|
|
319
|
+
} as const;
|
|
320
|
+
|
|
321
|
+
switch (symbol) {
|
|
322
|
+
case "square":
|
|
323
|
+
return <rect key={key} x={cx - size / 2} y={cy - size / 2} width={size} height={size} {...shared} />;
|
|
324
|
+
case "diamond":
|
|
325
|
+
return (
|
|
326
|
+
<polygon
|
|
327
|
+
key={key}
|
|
328
|
+
points={`${cx},${cy - size / 2} ${cx + size / 2},${cy} ${cx},${cy + size / 2} ${cx - size / 2},${cy}`}
|
|
329
|
+
{...shared}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
case "triangle":
|
|
333
|
+
return (
|
|
334
|
+
<polygon
|
|
335
|
+
key={key}
|
|
336
|
+
points={`${cx},${cy - size / 2} ${cx + size / 2},${cy + size / 2} ${cx - size / 2},${cy + size / 2}`}
|
|
337
|
+
{...shared}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
case "x":
|
|
341
|
+
case "plus": {
|
|
342
|
+
const half = size / 2;
|
|
343
|
+
const rot = symbol === "x" ? 45 : 0;
|
|
344
|
+
return (
|
|
345
|
+
<g key={key} transform={rot === 0 ? undefined : `rotate(${rot} ${cx} ${cy})`} data-role="marker" data-series-index={seriesIdx} data-category-index={categoryIdx}>
|
|
346
|
+
<line x1={cx - half} y1={cy} x2={cx + half} y2={cy} stroke={color} strokeWidth={1.5} />
|
|
347
|
+
<line x1={cx} y1={cy - half} x2={cx} y2={cy + half} stroke={color} strokeWidth={1.5} />
|
|
348
|
+
</g>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
case "dash":
|
|
352
|
+
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-category-index={categoryIdx} />;
|
|
353
|
+
case "dot":
|
|
354
|
+
return <circle key={key} cx={cx} cy={cy} r={Math.max(1, size / 4)} {...shared} />;
|
|
355
|
+
case "star":
|
|
356
|
+
case "picture":
|
|
357
|
+
case "circle":
|
|
358
|
+
case "none":
|
|
359
|
+
default:
|
|
360
|
+
// Fallback: circle.
|
|
361
|
+
return <circle key={key} cx={cx} cy={cy} r={size / 2} {...shared} />;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* (Stage 3A, pure math).
|
|
4
4
|
*
|
|
5
5
|
* Supports the top ~20 real-world format codes:
|
|
6
|
-
* -
|
|
6
|
+
* - Multi-section: `positive;negative;zero;text` — `;` outside quoted
|
|
7
|
+
* literals separates sections; value sign selects the active section.
|
|
8
|
+
* - Digit placeholders: `0` (required digit), `#` (optional digit —
|
|
9
|
+
* suppresses trailing zeros and leading integer zero when unneeded).
|
|
7
10
|
* - Decimal point: `.`.
|
|
8
11
|
* - Thousands separator: `,` between digit placeholders.
|
|
9
12
|
* - Percent: `%` — scales value by 100 and appends `%`.
|
|
@@ -24,6 +27,48 @@
|
|
|
24
27
|
* renderer never produces `NaN` or an exception at render time.
|
|
25
28
|
*/
|
|
26
29
|
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Section splitting — C1
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split a format code on `;` separators that appear outside quoted
|
|
36
|
+
* literal strings. Returns a 1-4 element array:
|
|
37
|
+
* [positive, negative?, zero?, text?]
|
|
38
|
+
*
|
|
39
|
+
* Semicolons inside `"..."` quoted strings are NOT treated as separators.
|
|
40
|
+
*/
|
|
41
|
+
function splitFormatSections(code: string): string[] {
|
|
42
|
+
const sections: string[] = [];
|
|
43
|
+
let current = "";
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < code.length) {
|
|
46
|
+
const ch = code[i]!;
|
|
47
|
+
if (ch === '"') {
|
|
48
|
+
// Include the entire quoted substring verbatim (no split inside).
|
|
49
|
+
const end = code.indexOf('"', i + 1);
|
|
50
|
+
if (end === -1) {
|
|
51
|
+
current += code.slice(i);
|
|
52
|
+
i = code.length;
|
|
53
|
+
} else {
|
|
54
|
+
current += code.slice(i, end + 1);
|
|
55
|
+
i = end + 1;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === ";") {
|
|
60
|
+
sections.push(current);
|
|
61
|
+
current = "";
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
current += ch;
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
sections.push(current);
|
|
69
|
+
return sections;
|
|
70
|
+
}
|
|
71
|
+
|
|
27
72
|
// ---------------------------------------------------------------------------
|
|
28
73
|
// Public entry point
|
|
29
74
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +81,20 @@ export function formatNumber(value: number, code: string | undefined): string {
|
|
|
36
81
|
|
|
37
82
|
if (!Number.isFinite(value)) return "";
|
|
38
83
|
|
|
84
|
+
// C1: Multi-section format (positive;negative;zero;text). Split first,
|
|
85
|
+
// before routing to date/scientific/decimal, so each section is treated
|
|
86
|
+
// as an independent single-section code. Negative section receives
|
|
87
|
+
// Math.abs(value) so its template governs sign presentation (e.g. "(#,##0)").
|
|
88
|
+
const sections = splitFormatSections(code);
|
|
89
|
+
if (sections.length > 1) {
|
|
90
|
+
let idx = 0;
|
|
91
|
+
if (value < 0 && sections.length >= 2) idx = 1;
|
|
92
|
+
else if (value === 0 && sections.length >= 3) idx = 2;
|
|
93
|
+
const selectedSection = sections[idx]!;
|
|
94
|
+
const selectedValue = idx === 1 ? Math.abs(value) : value;
|
|
95
|
+
return formatNumber(selectedValue, selectedSection);
|
|
96
|
+
}
|
|
97
|
+
|
|
39
98
|
// Date/time codes contain y/m/d/h/s letter tokens outside literals.
|
|
40
99
|
// Detect and route to the date formatter.
|
|
41
100
|
if (isDateFormatCode(code)) {
|
|
@@ -71,6 +130,10 @@ function formatDecimal(value: number, code: string): string {
|
|
|
71
130
|
const hasPercent = tokens.some((t) => t.kind === "percent");
|
|
72
131
|
if (hasPercent) working *= 100;
|
|
73
132
|
|
|
133
|
+
// C2: Track sign separately — the negative prefix is emitted at the very
|
|
134
|
+
// front of the stitch output (before any currency symbol or digit run).
|
|
135
|
+
const isNegative = working < 0;
|
|
136
|
+
|
|
74
137
|
const decimalIdx = digitRun.indexOf(".");
|
|
75
138
|
const fractionDigits = decimalIdx >= 0 ? digitRun.length - decimalIdx - 1 : 0;
|
|
76
139
|
const useThousands = /,(?=[0#])/.test(digitRun);
|
|
@@ -80,35 +143,72 @@ function formatDecimal(value: number, code: string): string {
|
|
|
80
143
|
.toFixed(Math.max(0, fractionDigits))
|
|
81
144
|
.split(".");
|
|
82
145
|
|
|
146
|
+
// C3: Optional-digit (#) handling.
|
|
147
|
+
// Count trailing `#` chars in the fraction part of the format template.
|
|
148
|
+
// These are optional: trim the corresponding trailing zeros from the
|
|
149
|
+
// formatted fraction so "1.5" never renders as "1.50" under "#.##".
|
|
150
|
+
const fracFormat = decimalIdx >= 0 ? digitRun.slice(decimalIdx + 1) : "";
|
|
151
|
+
const trailingOptional = fracFormat.match(/#*$/)?.[0].length ?? 0;
|
|
152
|
+
|
|
153
|
+
// Suppress the leading "0" when the integer format is all-optional (#)
|
|
154
|
+
// and the integer value is actually zero (e.g. "#.##" on 0.5 → ".5").
|
|
155
|
+
const intFormat = decimalIdx >= 0 ? digitRun.slice(0, decimalIdx) : digitRun;
|
|
156
|
+
const suppressLeadingZero = !intFormat.includes("0") && intPart === "0";
|
|
157
|
+
|
|
158
|
+
let trimmedFrac = fracPart;
|
|
159
|
+
if (trailingOptional > 0 && trimmedFrac !== undefined) {
|
|
160
|
+
let trimCount = 0;
|
|
161
|
+
for (let fi = trimmedFrac.length - 1; fi >= 0 && trimCount < trailingOptional; fi--) {
|
|
162
|
+
if (trimmedFrac[fi] === "0") trimCount++;
|
|
163
|
+
else break;
|
|
164
|
+
}
|
|
165
|
+
if (trimCount > 0) trimmedFrac = trimmedFrac.slice(0, trimmedFrac.length - trimCount);
|
|
166
|
+
}
|
|
167
|
+
|
|
83
168
|
const intGrouped = useThousands ? groupThousands(intPart!) : intPart!;
|
|
169
|
+
const intDisplay = suppressLeadingZero ? "" : intGrouped;
|
|
84
170
|
const formatted =
|
|
85
|
-
|
|
86
|
-
? `${
|
|
87
|
-
:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
171
|
+
trimmedFrac !== undefined && trimmedFrac.length > 0
|
|
172
|
+
? `${intDisplay}.${trimmedFrac}`
|
|
173
|
+
: intDisplay;
|
|
174
|
+
|
|
175
|
+
// C2 + C3: Stitch prefix/suffix literals around the numeric body.
|
|
176
|
+
// The negative sign is prepended exactly once, immediately before the
|
|
177
|
+
// first currency symbol or digit run — so "$-1,234.50" never appears;
|
|
178
|
+
// the output is always "-$1,234.50".
|
|
179
|
+
let negativeEmitted = false;
|
|
93
180
|
let produced = false;
|
|
94
181
|
const parts: string[] = [];
|
|
95
182
|
for (const t of tokens) {
|
|
96
|
-
if (t.kind === "
|
|
183
|
+
if (t.kind === "currency") {
|
|
184
|
+
if (isNegative && !negativeEmitted) {
|
|
185
|
+
parts.push("-");
|
|
186
|
+
negativeEmitted = true;
|
|
187
|
+
}
|
|
188
|
+
parts.push(t.value);
|
|
189
|
+
} else if (t.kind === "digits") {
|
|
97
190
|
if (!produced) {
|
|
98
|
-
|
|
191
|
+
if (isNegative && !negativeEmitted) {
|
|
192
|
+
parts.push("-");
|
|
193
|
+
negativeEmitted = true;
|
|
194
|
+
}
|
|
195
|
+
parts.push(formatted);
|
|
99
196
|
produced = true;
|
|
100
197
|
}
|
|
101
|
-
//
|
|
102
|
-
//
|
|
198
|
+
// Drop subsequent digit runs — single-section numeric templates emit
|
|
199
|
+
// one run. Multi-section formats (positive;negative;zero;text) are
|
|
200
|
+
// handled by C1's section splitter above, so two-run single-section
|
|
201
|
+
// formats are unusual; the first run governs intentionally.
|
|
103
202
|
} else if (t.kind === "literal") {
|
|
104
203
|
parts.push(t.value);
|
|
105
204
|
} else if (t.kind === "percent") {
|
|
106
205
|
parts.push("%");
|
|
107
|
-
} else if (t.kind === "currency") {
|
|
108
|
-
parts.push(t.value);
|
|
109
206
|
}
|
|
110
207
|
}
|
|
111
|
-
if (!produced)
|
|
208
|
+
if (!produced) {
|
|
209
|
+
if (isNegative && !negativeEmitted) parts.push("-");
|
|
210
|
+
parts.push(formatted);
|
|
211
|
+
}
|
|
112
212
|
return parts.join("");
|
|
113
213
|
}
|
|
114
214
|
|
|
@@ -130,6 +230,10 @@ function formatScientific(value: number, code: string): string {
|
|
|
130
230
|
const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
|
|
131
231
|
const mantissa = value / Math.pow(10, exp);
|
|
132
232
|
const mantissaStr = mantissa.toFixed(Math.max(0, fracDigits));
|
|
233
|
+
// signToken "+" emits sign for both positive and negative exponents.
|
|
234
|
+
// signToken "" (no explicit sign) emits only "-" for negative exponents.
|
|
235
|
+
// Verified: "0.0E00" on 0.0001 → "1.0E-04" (the negative case emits "-"
|
|
236
|
+
// via the right branch of the ternary below).
|
|
133
237
|
const sign = signToken === "+" ? (exp >= 0 ? "+" : "-") : exp < 0 ? "-" : "";
|
|
134
238
|
const expStr = String(Math.abs(exp)).padStart(match[4]!.length, "0");
|
|
135
239
|
return `${mantissaStr}E${sign}${expStr}`;
|