@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
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
normalizeMarkupDisplay,
|
|
4
|
+
rangesOverlap,
|
|
5
|
+
type MarkupDisplay,
|
|
6
|
+
} from "./comment-decoration-model";
|
|
3
7
|
|
|
4
8
|
export interface RevisionDecorationModel {
|
|
5
9
|
revisions: RevisionDecorationEntry[];
|
|
@@ -13,6 +17,65 @@ export interface RevisionDecorationEntry {
|
|
|
13
17
|
status: TrackedChangeEntrySnapshot["status"];
|
|
14
18
|
actionability: TrackedChangeEntrySnapshot["actionability"];
|
|
15
19
|
isActive: boolean;
|
|
20
|
+
/** L6d.N3 — source author, used to pick the decoration color. */
|
|
21
|
+
authorId?: string;
|
|
22
|
+
/** L6d.N3 — palette slot for `authorId` (0..7, stable via FNV-1a hash). */
|
|
23
|
+
authorPaletteIndex?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* L6d.N3 — author-color assignment.
|
|
28
|
+
*
|
|
29
|
+
* Map `authorId` → one of the 8 `--color-chart-categorical-*` vars
|
|
30
|
+
* using FNV-1a 32-bit hashing. FNV-1a is deterministic, allocation-
|
|
31
|
+
* free, and has better avalanche on short ASCII keys than djb2, so
|
|
32
|
+
* typical author-id alphabets (emails, UUIDs, Slack IDs) distribute
|
|
33
|
+
* evenly across the 8 slots.
|
|
34
|
+
*
|
|
35
|
+
* The palette lives as CSS vars so theme swaps / reduced-contrast
|
|
36
|
+
* overrides in `tokens.css` cascade automatically — we emit `var(...)`
|
|
37
|
+
* strings, never hex literals.
|
|
38
|
+
*/
|
|
39
|
+
export const AUTHOR_PALETTE: readonly string[] = [
|
|
40
|
+
"var(--color-chart-categorical-1)",
|
|
41
|
+
"var(--color-chart-categorical-2)",
|
|
42
|
+
"var(--color-chart-categorical-3)",
|
|
43
|
+
"var(--color-chart-categorical-4)",
|
|
44
|
+
"var(--color-chart-categorical-5)",
|
|
45
|
+
"var(--color-chart-categorical-6)",
|
|
46
|
+
"var(--color-chart-categorical-7)",
|
|
47
|
+
"var(--color-chart-categorical-8)",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* FNV-1a 32-bit hash. Constants from the Fowler–Noll–Vo reference:
|
|
52
|
+
* offset basis = 0x811c9dc5
|
|
53
|
+
* prime = 0x01000193
|
|
54
|
+
* The `>>> 0` keeps the value in unsigned 32-bit territory after each
|
|
55
|
+
* multiplication so two identical inputs always produce the same hash.
|
|
56
|
+
*/
|
|
57
|
+
export function hashAuthorId(authorId: string): number {
|
|
58
|
+
let hash = 0x811c9dc5;
|
|
59
|
+
for (let i = 0; i < authorId.length; i += 1) {
|
|
60
|
+
hash ^= authorId.charCodeAt(i);
|
|
61
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
62
|
+
}
|
|
63
|
+
return hash;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reduce a hash to a palette slot in `[0, AUTHOR_PALETTE.length)`. */
|
|
67
|
+
export function authorPaletteIndex(authorId: string): number {
|
|
68
|
+
return hashAuthorId(authorId) % AUTHOR_PALETTE.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a CSS color string for an author. Returns `undefined` when
|
|
73
|
+
* `authorId` is missing so callers can fall back to the default
|
|
74
|
+
* per-kind class (e.g. `bg-insert-soft`).
|
|
75
|
+
*/
|
|
76
|
+
export function getAuthorColor(authorId: string | undefined): string | undefined {
|
|
77
|
+
if (!authorId) return undefined;
|
|
78
|
+
return AUTHOR_PALETTE[authorPaletteIndex(authorId)];
|
|
16
79
|
}
|
|
17
80
|
|
|
18
81
|
export interface RevisionRangeState {
|
|
@@ -47,6 +110,8 @@ export function createRevisionDecorationModel(
|
|
|
47
110
|
status: rev.status,
|
|
48
111
|
actionability: rev.actionability,
|
|
49
112
|
isActive: rev.revisionId === activeRevisionId,
|
|
113
|
+
authorId: rev.authorId,
|
|
114
|
+
authorPaletteIndex: rev.authorId ? authorPaletteIndex(rev.authorId) : undefined,
|
|
50
115
|
};
|
|
51
116
|
}),
|
|
52
117
|
};
|
|
@@ -94,12 +159,17 @@ export function getRevisionHighlightClass(
|
|
|
94
159
|
|
|
95
160
|
const activeRing = state.hasActive ? " ring-1 ring-accent/30" : "";
|
|
96
161
|
|
|
97
|
-
switch (markupDisplay) {
|
|
98
|
-
case "
|
|
99
|
-
// In
|
|
162
|
+
switch (normalizeMarkupDisplay(markupDisplay)) {
|
|
163
|
+
case "no-markup":
|
|
164
|
+
// In no-markup mode, deletions are hidden entirely (caller should not render).
|
|
100
165
|
// Insertions render as normal text with no decoration.
|
|
101
166
|
return "";
|
|
102
|
-
case "
|
|
167
|
+
case "original":
|
|
168
|
+
// L6d.N2 — "original" shows what the doc looked like BEFORE the
|
|
169
|
+
// pending tracked changes: insertions are hidden (caller checks
|
|
170
|
+
// `shouldHideInOriginalMode`), deletions render as plain body text.
|
|
171
|
+
return "";
|
|
172
|
+
case "simple-markup":
|
|
103
173
|
if (state.hasInsertions) {
|
|
104
174
|
return `underline decoration-insert/60 decoration-1 underline-offset-2 text-primary${activeRing}`;
|
|
105
175
|
}
|
|
@@ -107,7 +177,7 @@ export function getRevisionHighlightClass(
|
|
|
107
177
|
return `text-secondary line-through decoration-danger/70 decoration-1${activeRing}`;
|
|
108
178
|
}
|
|
109
179
|
return activeRing;
|
|
110
|
-
case "all":
|
|
180
|
+
case "all-markup":
|
|
111
181
|
if (state.hasInsertions) {
|
|
112
182
|
return `text-primary bg-insert-soft/80 ring-1 ring-insert/20${activeRing}`;
|
|
113
183
|
}
|
|
@@ -118,6 +188,10 @@ export function getRevisionHighlightClass(
|
|
|
118
188
|
}
|
|
119
189
|
}
|
|
120
190
|
|
|
191
|
+
/**
|
|
192
|
+
* `no-markup` hides deletions (pretending the doc already accepted all
|
|
193
|
+
* pending changes). The caller skips rendering affected spans entirely.
|
|
194
|
+
*/
|
|
121
195
|
export function shouldHideInCleanMode(
|
|
122
196
|
model: RevisionDecorationModel | undefined,
|
|
123
197
|
from: number,
|
|
@@ -126,3 +200,17 @@ export function shouldHideInCleanMode(
|
|
|
126
200
|
const state = getRevisionRangeState(model, from, to);
|
|
127
201
|
return state.hasDeletions;
|
|
128
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* L6d.N2 — `original` mode hides insertions (pretending the pending
|
|
206
|
+
* batch of tracked changes was rejected). Caller skips rendering
|
|
207
|
+
* affected spans entirely.
|
|
208
|
+
*/
|
|
209
|
+
export function shouldHideInOriginalMode(
|
|
210
|
+
model: RevisionDecorationModel | undefined,
|
|
211
|
+
from: number,
|
|
212
|
+
to: number,
|
|
213
|
+
): boolean {
|
|
214
|
+
const state = getRevisionRangeState(model, from, to);
|
|
215
|
+
return state.hasInsertions;
|
|
216
|
+
}
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { RuntimeRenderSnapshot } from "../../api/public-types";
|
|
2
|
+
import {
|
|
3
|
+
normalizeMarkupDisplay,
|
|
4
|
+
type MarkupDisplay,
|
|
5
|
+
} from "../headless/comment-decoration-model";
|
|
2
6
|
|
|
3
7
|
type Revision = RuntimeRenderSnapshot["trackedChanges"]["revisions"][number];
|
|
4
|
-
type MarkupDisplay = "clean" | "simple" | "all";
|
|
5
8
|
|
|
6
9
|
export function selectVisibleRevisions(
|
|
7
10
|
revisions: readonly Revision[],
|
|
8
11
|
markupDisplay: MarkupDisplay,
|
|
9
12
|
): Revision[] {
|
|
10
|
-
switch (markupDisplay) {
|
|
11
|
-
case "
|
|
12
|
-
case "simple":
|
|
13
|
+
switch (normalizeMarkupDisplay(markupDisplay)) {
|
|
14
|
+
case "no-markup":
|
|
15
|
+
case "simple-markup":
|
|
16
|
+
case "original":
|
|
13
17
|
return revisions.filter(
|
|
14
18
|
(revision) =>
|
|
15
19
|
revision.status === "active" && revision.actionability === "actionable",
|
|
16
20
|
);
|
|
17
|
-
case "all":
|
|
21
|
+
case "all-markup":
|
|
18
22
|
return [...revisions];
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -23,7 +27,13 @@ export function describeEmptyRevisionState(
|
|
|
23
27
|
markupDisplay: MarkupDisplay,
|
|
24
28
|
totalCount: number,
|
|
25
29
|
): string {
|
|
26
|
-
|
|
30
|
+
const canonical = normalizeMarkupDisplay(markupDisplay);
|
|
31
|
+
if (
|
|
32
|
+
(canonical === "no-markup" ||
|
|
33
|
+
canonical === "simple-markup" ||
|
|
34
|
+
canonical === "original") &&
|
|
35
|
+
totalCount > 0
|
|
36
|
+
) {
|
|
27
37
|
return "Simple markup keeps the rail focused on actionable live changes. Switch to All to inspect preserve-only or historical revision records.";
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChartSurface — top-level chart renderer dispatch (Stage 4 Slice 4H).
|
|
3
|
+
*
|
|
4
|
+
* Accepts a `ChartModel` discriminated union and dispatches to the
|
|
5
|
+
* appropriate per-family renderer. Emits a single `<svg>` root with:
|
|
6
|
+
*
|
|
7
|
+
* - `role="img"` + `aria-label={describeChart(model)}` for
|
|
8
|
+
* accessibility (Stage 4 exit criteria).
|
|
9
|
+
* - `<defs>` block mounted from a shared `DefsRegistry` so gradient
|
|
10
|
+
* fills registered by sub-renderers surface once in the root.
|
|
11
|
+
* - Deterministic FNV-1a gradient IDs from Stage 3B (no `Math.random`,
|
|
12
|
+
* no counter state — important for Stage 7 pixel-diff stability).
|
|
13
|
+
*
|
|
14
|
+
* ChartSurface is the component consumed by Stage 6's PM `NodeView`
|
|
15
|
+
* bridge (`chart-node-view.tsx`) — it renders independently of
|
|
16
|
+
* `refreshRenderSnapshot()` so it doesn't widen the wholesale-snapshot
|
|
17
|
+
* path (perf invariant #4).
|
|
18
|
+
*
|
|
19
|
+
* The component is wrapped in `React.memo` with a structural hash
|
|
20
|
+
* comparator so re-renders are skipped when `rawXml + width + height`
|
|
21
|
+
* haven't changed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import React from "react";
|
|
25
|
+
import type { ChartModel } from "../../io/ooxml/chart/types.ts";
|
|
26
|
+
import type { ResolvedTheme } from "../../model/canonical-document.ts";
|
|
27
|
+
import { layoutPlotArea, type PlotAreaLayout } from "./layout/plot-area.ts";
|
|
28
|
+
import { AreaChart } from "./render/area.tsx";
|
|
29
|
+
import { BarColumnChart } from "./render/bar-column.tsx";
|
|
30
|
+
import { BubbleChart } from "./render/bubble.tsx";
|
|
31
|
+
import { ComboChart } from "./render/combo.tsx";
|
|
32
|
+
import { DataLabels } from "./render/data-labels.tsx";
|
|
33
|
+
import { DefsRegistry } from "./render/svg-primitives.ts";
|
|
34
|
+
import { LineChart } from "./render/line.tsx";
|
|
35
|
+
import { PieChart } from "./render/pie.tsx";
|
|
36
|
+
import { ScatterChart } from "./render/scatter.tsx";
|
|
37
|
+
import { UnsupportedChart } from "./render/unsupported.tsx";
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface ChartSurfaceProps {
|
|
44
|
+
model: ChartModel;
|
|
45
|
+
/** Rendered size in pixels. */
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
theme?: ResolvedTheme;
|
|
49
|
+
/** Optional pre-computed layout; falls back to `layoutPlotArea(...)`. */
|
|
50
|
+
layout?: PlotAreaLayout;
|
|
51
|
+
/** Fallback-image resolver for unsupported chart types. */
|
|
52
|
+
resolveMediaUrl?: (mediaId: string) => string | undefined;
|
|
53
|
+
/** The chart-preview's media ID, if one was cached in `mc:Fallback`. */
|
|
54
|
+
previewMediaId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ChartSurfaceImpl({
|
|
58
|
+
model,
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
theme,
|
|
62
|
+
layout,
|
|
63
|
+
resolveMediaUrl,
|
|
64
|
+
previewMediaId,
|
|
65
|
+
}: ChartSurfaceProps): React.ReactElement {
|
|
66
|
+
const resolvedLayout = layout ?? layoutPlotArea({ w: width, h: height }, model, theme);
|
|
67
|
+
const defs = new DefsRegistry();
|
|
68
|
+
|
|
69
|
+
const body = dispatchBody({
|
|
70
|
+
model,
|
|
71
|
+
layout: resolvedLayout,
|
|
72
|
+
theme,
|
|
73
|
+
defs,
|
|
74
|
+
resolveMediaUrl,
|
|
75
|
+
previewMediaId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<svg
|
|
80
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
81
|
+
role="img"
|
|
82
|
+
aria-label={describeChart(model)}
|
|
83
|
+
width={width}
|
|
84
|
+
height={height}
|
|
85
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
86
|
+
data-role="chart-surface"
|
|
87
|
+
data-chart-kind={model.kind}
|
|
88
|
+
>
|
|
89
|
+
{!defs.isEmpty() && (
|
|
90
|
+
<defs data-role="chart-defs">
|
|
91
|
+
{renderDefs(defs)}
|
|
92
|
+
</defs>
|
|
93
|
+
)}
|
|
94
|
+
{body}
|
|
95
|
+
<g data-role="data-labels-root">
|
|
96
|
+
<DataLabels model={model} layout={resolvedLayout} theme={theme} />
|
|
97
|
+
</g>
|
|
98
|
+
</svg>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const ChartSurface = React.memo(
|
|
103
|
+
ChartSurfaceImpl,
|
|
104
|
+
(prev, next) =>
|
|
105
|
+
prev.model.rawXml === next.model.rawXml &&
|
|
106
|
+
prev.width === next.width &&
|
|
107
|
+
prev.height === next.height &&
|
|
108
|
+
prev.theme === next.theme &&
|
|
109
|
+
prev.previewMediaId === next.previewMediaId,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Body dispatch
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
interface DispatchInput {
|
|
117
|
+
model: ChartModel;
|
|
118
|
+
layout: PlotAreaLayout;
|
|
119
|
+
theme: ResolvedTheme | undefined;
|
|
120
|
+
defs: DefsRegistry;
|
|
121
|
+
resolveMediaUrl: ChartSurfaceProps["resolveMediaUrl"];
|
|
122
|
+
previewMediaId: ChartSurfaceProps["previewMediaId"];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dispatchBody({
|
|
126
|
+
model,
|
|
127
|
+
layout,
|
|
128
|
+
theme,
|
|
129
|
+
defs,
|
|
130
|
+
resolveMediaUrl,
|
|
131
|
+
previewMediaId,
|
|
132
|
+
}: DispatchInput): React.ReactElement {
|
|
133
|
+
switch (model.kind) {
|
|
134
|
+
case "bar":
|
|
135
|
+
return <BarColumnChart model={model} layout={layout} theme={theme} defs={defs} />;
|
|
136
|
+
case "line":
|
|
137
|
+
return <LineChart model={model} layout={layout} theme={theme} />;
|
|
138
|
+
case "pie":
|
|
139
|
+
return <PieChart model={model} layout={layout} theme={theme} defs={defs} />;
|
|
140
|
+
case "area":
|
|
141
|
+
return <AreaChart model={model} layout={layout} theme={theme} />;
|
|
142
|
+
case "scatter":
|
|
143
|
+
return <ScatterChart model={model} layout={layout} theme={theme} />;
|
|
144
|
+
case "bubble":
|
|
145
|
+
return <BubbleChart model={model} layout={layout} theme={theme} />;
|
|
146
|
+
case "combo":
|
|
147
|
+
return <ComboChart model={model} layout={layout} theme={theme} />;
|
|
148
|
+
case "unsupported":
|
|
149
|
+
return (
|
|
150
|
+
<UnsupportedChart
|
|
151
|
+
model={model}
|
|
152
|
+
layout={layout}
|
|
153
|
+
theme={theme}
|
|
154
|
+
{...(resolveMediaUrl ? { resolveMediaUrl } : {})}
|
|
155
|
+
{...(previewMediaId ? { previewMediaId } : {})}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// DefsRegistry → SVG <defs> children
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function renderDefs(defs: DefsRegistry): React.ReactElement[] {
|
|
166
|
+
return defs.toDefsEntries().map((entry) => {
|
|
167
|
+
if (entry.kind === "linearGradient") {
|
|
168
|
+
const { x1, y1, x2, y2 } = angleToCoords(entry.angle);
|
|
169
|
+
return (
|
|
170
|
+
<linearGradient
|
|
171
|
+
key={entry.id}
|
|
172
|
+
id={entry.id}
|
|
173
|
+
x1={x1}
|
|
174
|
+
y1={y1}
|
|
175
|
+
x2={x2}
|
|
176
|
+
y2={y2}
|
|
177
|
+
>
|
|
178
|
+
{entry.stops.map((stop, i) => (
|
|
179
|
+
<stop key={i} offset={stop.offset} stopColor={stop.color} />
|
|
180
|
+
))}
|
|
181
|
+
</linearGradient>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return <React.Fragment key="__empty__" />;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Convert a gradient angle (degrees, OOXML clockwise-from-horizontal)
|
|
190
|
+
* to SVG `x1/y1/x2/y2` percentage values. 0° = left-to-right,
|
|
191
|
+
* 90° = top-to-bottom.
|
|
192
|
+
*/
|
|
193
|
+
function angleToCoords(angleDeg: number): {
|
|
194
|
+
x1: string; y1: string; x2: string; y2: string;
|
|
195
|
+
} {
|
|
196
|
+
const rad = (angleDeg * Math.PI) / 180;
|
|
197
|
+
const dx = Math.cos(rad);
|
|
198
|
+
const dy = Math.sin(rad);
|
|
199
|
+
const x1 = dx < 0 ? "100%" : "0%";
|
|
200
|
+
const x2 = dx < 0 ? "0%" : "100%";
|
|
201
|
+
const y1 = dy < 0 ? "100%" : "0%";
|
|
202
|
+
const y2 = dy < 0 ? "0%" : "100%";
|
|
203
|
+
return { x1, y1, x2, y2 };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Accessibility labels
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function describeChart(model: ChartModel): string {
|
|
211
|
+
const title = model.kind !== "unsupported" ? model.title?.text : undefined;
|
|
212
|
+
const kindLabel = kindToLabel(model);
|
|
213
|
+
if (title) return `${kindLabel}: ${title}`;
|
|
214
|
+
return kindLabel;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function kindToLabel(model: ChartModel): string {
|
|
218
|
+
switch (model.kind) {
|
|
219
|
+
case "bar":
|
|
220
|
+
return model.direction === "bar" ? "Bar chart" : "Column chart";
|
|
221
|
+
case "line":
|
|
222
|
+
return "Line chart";
|
|
223
|
+
case "pie":
|
|
224
|
+
return model.doughnut ? "Doughnut chart" : "Pie chart";
|
|
225
|
+
case "area":
|
|
226
|
+
return "Area chart";
|
|
227
|
+
case "scatter":
|
|
228
|
+
return "Scatter chart";
|
|
229
|
+
case "bubble":
|
|
230
|
+
return "Bubble chart";
|
|
231
|
+
case "combo":
|
|
232
|
+
return "Combination chart";
|
|
233
|
+
case "unsupported":
|
|
234
|
+
return "Chart (unsupported type)";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -86,17 +86,19 @@ export function generateValueTicks(input: ValueTickInput): TickResult {
|
|
|
86
86
|
);
|
|
87
87
|
const minorStep = input.minorUnit ?? step / 5;
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// C6: Use step-relative epsilon so tiny ranges (e.g. max ≈ 1e-15) never
|
|
90
|
+
// produce millions of iterations from an absolute 1e-9 overshoot.
|
|
91
|
+
const firstMajor = Math.ceil(min / step - step * 1e-9) * step;
|
|
90
92
|
const major: number[] = [];
|
|
91
|
-
for (let t = firstMajor; t <= max + 1e-9; t += step) {
|
|
93
|
+
for (let t = firstMajor; t <= max + step * 1e-9; t += step) {
|
|
92
94
|
// Round to the step's decimal precision to avoid floating drift.
|
|
93
95
|
major.push(roundToStep(t, step));
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
const minor: number[] = [];
|
|
97
99
|
if (minorStep > 0 && minorStep < step) {
|
|
98
|
-
const firstMinor = Math.ceil(min / minorStep - 1e-9) * minorStep;
|
|
99
|
-
for (let t = firstMinor; t <= max + 1e-9; t += minorStep) {
|
|
100
|
+
const firstMinor = Math.ceil(min / minorStep - minorStep * 1e-9) * minorStep;
|
|
101
|
+
for (let t = firstMinor; t <= max + minorStep * 1e-9; t += minorStep) {
|
|
100
102
|
// Skip positions that coincide with major ticks (mod epsilon).
|
|
101
103
|
const snapped = roundToStep(t, minorStep);
|
|
102
104
|
if (!isMajorPosition(snapped, step)) minor.push(snapped);
|
|
@@ -265,11 +267,17 @@ export function generateDateTicks(input: DateTickInput): TickResult {
|
|
|
265
267
|
|
|
266
268
|
const minorUnit = input.minorTimeUnit ?? unit;
|
|
267
269
|
const minorStepSize = Math.max(1, Math.floor(input.minorUnit ?? 1));
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
// C7: Use a Set with rounded keys for the major-position filter so that
|
|
271
|
+
// serial-date round-trip drift (sub-ms) doesn't leak duplicate ticks.
|
|
272
|
+
// Rounding to the nearest integer is safe because date serial positions
|
|
273
|
+
// differ by at least 1 (minimum granularity is 1 day).
|
|
274
|
+
let minor: number[] = [];
|
|
275
|
+
if (input.minorUnit !== undefined || input.minorTimeUnit !== undefined) {
|
|
276
|
+
const majorSet = new Set(major.map((v) => Math.round(v)));
|
|
277
|
+
minor = stepByTimeUnit(min, max, minorUnit, minorStepSize).filter(
|
|
278
|
+
(pos) => !majorSet.has(Math.round(pos)),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
273
281
|
|
|
274
282
|
return { major, minor };
|
|
275
283
|
}
|