@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.
Files changed (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. 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 { rangesOverlap, type MarkupDisplay } from "./comment-decoration-model";
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 "clean":
99
- // In clean mode, deletions are hidden entirely (caller should not render).
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 "simple":
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 "clean":
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
- if ((markupDisplay === "clean" || markupDisplay === "simple") && totalCount > 0) {
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
- const firstMajor = Math.ceil(min / step - 1e-9) * step;
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
- const minor = input.minorUnit !== undefined || input.minorTimeUnit !== undefined
269
- ? stepByTimeUnit(min, max, minorUnit, minorStepSize).filter(
270
- (pos) => !major.includes(pos),
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
  }