@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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +35 -7
  3. package/src/io/docx-session.ts +30 -6
  4. package/src/runtime/collab/checkpoint-store.ts +1 -1
  5. package/src/runtime/collab/event-types.ts +4 -0
  6. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  7. package/src/runtime/document-runtime.ts +23 -9
  8. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  9. package/src/runtime/layout/layout-engine-version.ts +58 -1
  10. package/src/runtime/layout/layout-invalidation.ts +150 -30
  11. package/src/runtime/layout/page-graph.ts +19 -0
  12. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  13. package/src/runtime/layout/project-block-fragments.ts +27 -0
  14. package/src/runtime/layout/public-facet.ts +27 -0
  15. package/src/runtime/render/render-frame-diff.ts +38 -2
  16. package/src/ui/WordReviewEditor.tsx +6 -3
  17. package/src/ui/headless/comment-decoration-model.ts +60 -5
  18. package/src/ui/headless/revision-decoration-model.ts +94 -6
  19. package/src/ui/shared/revision-filters.ts +16 -6
  20. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  21. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  22. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  23. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  24. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  25. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  26. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  27. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  28. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  29. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  30. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  31. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  32. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  33. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  34. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  35. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  36. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  37. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  38. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  39. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  40. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  41. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  42. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  43. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  44. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  46. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  47. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  48. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  49. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  50. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  51. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  52. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  53. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  54. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  55. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  57. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  58. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  59. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  60. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  61. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  62. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  63. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  65. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  66. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  67. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  68. package/src/ui-tailwind/index.ts +11 -0
  69. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  70. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  71. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  72. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  73. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  74. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  75. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  76. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  77. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  78. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  79. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  80. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  81. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  82. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  83. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  84. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  85. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  86. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -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
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Legend layout — entry placement with multi-row wrapping (Stage 3C).
3
+ *
4
+ * The layout is pure geometry: given a rectangle the plot-area reserved
5
+ * for the legend, a list of entries (label + swatch color), and the
6
+ * declared position (`b`/`t`/`l`/`r`/`tr`), compute the position of each
7
+ * swatch + label and the bounding box actually consumed.
8
+ *
9
+ * Rules (matching Word's observed behavior at 100% zoom):
10
+ *
11
+ * - Horizontal legends (position `b` / `t`) flow entries left-to-right
12
+ * and wrap to a new row when the next entry would overflow the
13
+ * available width. Entries within a row are gap-separated.
14
+ *
15
+ * - Vertical legends (position `l` / `r` / `tr`) stack entries top-to-
16
+ * bottom, one per row. Excess entries are not clipped — the bbox
17
+ * grows downward and the caller is responsible for deciding how to
18
+ * handle overflow.
19
+ *
20
+ * - Every entry gets a swatch of a fixed pixel width, followed by a
21
+ * gap, followed by the label. The label width is computed from the
22
+ * real `measureText` helper (Slice 3B).
23
+ *
24
+ * The returned `entries` array preserves input order so renderers can
25
+ * walk entries alongside their swatch color without a second lookup.
26
+ */
27
+
28
+ import type { Rect } from "./plot-area.ts";
29
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
30
+ import { measureText } from "../render/font-metrics.ts";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Public API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type LegendPosition = "b" | "t" | "l" | "r" | "tr";
37
+
38
+ export interface LegendEntryInput {
39
+ label: string;
40
+ /** Resolved sRGB color for the swatch (e.g. "#4472C4"). */
41
+ color: string;
42
+ }
43
+
44
+ export interface LegendEntryRect {
45
+ label: string;
46
+ color: string;
47
+ swatchRect: Rect;
48
+ labelRect: Rect;
49
+ }
50
+
51
+ export interface LegendLayout {
52
+ /** Bounding box actually consumed inside the input `rect`. */
53
+ bbox: Rect;
54
+ /** Per-entry placement, in input order. */
55
+ entries: LegendEntryRect[];
56
+ }
57
+
58
+ export interface LegendLayoutOptions {
59
+ /** Font size in points for the legend label. Word default: 10pt. */
60
+ labelFontSizePt?: number;
61
+ /** Swatch width in pixels. Word default: ~10px for chart legends. */
62
+ swatchWidthPx?: number;
63
+ /** Swatch height in pixels. Defaults to `swatchWidthPx`. */
64
+ swatchHeightPx?: number;
65
+ /** Gap between swatch and label in pixels. Word default: 4px. */
66
+ swatchToLabelGapPx?: number;
67
+ /** Horizontal gap between entries in a row. Word default: 12px. */
68
+ entryGapPx?: number;
69
+ /** Vertical gap between rows. Default 2px. */
70
+ rowGapPx?: number;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Defaults
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const DEFAULT_LABEL_FONT_PT = 10;
78
+ const DEFAULT_SWATCH_WIDTH = 10;
79
+ const DEFAULT_SWATCH_TO_LABEL_GAP = 4;
80
+ const DEFAULT_ENTRY_GAP = 12;
81
+ const DEFAULT_ROW_GAP = 2;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Implementation
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Compute the layout of legend entries inside `rect`, honoring
89
+ * `position`. Returns the consumed `bbox` (so callers can re-tighten
90
+ * the plot-area rectangle) and per-entry swatch + label sub-rects.
91
+ */
92
+ export function layoutLegend(
93
+ entries: ReadonlyArray<LegendEntryInput>,
94
+ rect: Rect,
95
+ position: LegendPosition,
96
+ theme: ResolvedTheme | undefined,
97
+ options: LegendLayoutOptions = {},
98
+ ): LegendLayout {
99
+ if (entries.length === 0) {
100
+ return {
101
+ bbox: { x: rect.x, y: rect.y, w: 0, h: 0 },
102
+ entries: [],
103
+ };
104
+ }
105
+
106
+ const labelFontPt = options.labelFontSizePt ?? DEFAULT_LABEL_FONT_PT;
107
+ const swatchW = options.swatchWidthPx ?? DEFAULT_SWATCH_WIDTH;
108
+ const swatchH = options.swatchHeightPx ?? swatchW;
109
+ const swatchGap = options.swatchToLabelGapPx ?? DEFAULT_SWATCH_TO_LABEL_GAP;
110
+ const entryGap = options.entryGapPx ?? DEFAULT_ENTRY_GAP;
111
+ const rowGap = options.rowGapPx ?? DEFAULT_ROW_GAP;
112
+ const txPr = { fontSizePt: labelFontPt };
113
+
114
+ // Pre-measure every label so wrap decisions can be made without
115
+ // re-measuring (the LRU cache in font-metrics makes this O(unique)).
116
+ const measured = entries.map((e) => {
117
+ const m = measureText(e.label, txPr, theme);
118
+ return {
119
+ entry: e,
120
+ labelWidth: m.width,
121
+ lineHeight: m.lineHeight,
122
+ };
123
+ });
124
+ const rowHeight = Math.max(swatchH, measured[0]!.lineHeight);
125
+ const entryWidth = (m: typeof measured[0]): number =>
126
+ swatchW + swatchGap + m.labelWidth;
127
+
128
+ // Vertical legends (l / r / tr) — one entry per row, no wrapping.
129
+ if (position === "l" || position === "r" || position === "tr") {
130
+ const maxWidth = Math.max(...measured.map(entryWidth));
131
+ const outEntries: LegendEntryRect[] = [];
132
+ let cursorY = rect.y;
133
+ for (const m of measured) {
134
+ const entryRect = placeVertical(m, rect.x, cursorY, swatchW, swatchH, swatchGap, rowHeight);
135
+ outEntries.push(entryRect);
136
+ cursorY += rowHeight + rowGap;
137
+ }
138
+ const consumedH = measured.length * rowHeight + (measured.length - 1) * rowGap;
139
+ return {
140
+ bbox: { x: rect.x, y: rect.y, w: maxWidth, h: consumedH },
141
+ entries: outEntries,
142
+ };
143
+ }
144
+
145
+ // Horizontal legends (b / t) — row-flow with wrapping.
146
+ const outEntries: LegendEntryRect[] = [];
147
+ let cursorX = rect.x;
148
+ let cursorY = rect.y;
149
+ let rowStart = 0; // index of first entry in current row
150
+ let maxConsumedW = 0;
151
+
152
+ for (let i = 0; i < measured.length; i++) {
153
+ const m = measured[i]!;
154
+ const w = entryWidth(m);
155
+ const isRowStart = i === rowStart;
156
+ const nextX = isRowStart ? cursorX + w : cursorX + entryGap + w;
157
+ const overflows = !isRowStart && nextX > rect.x + rect.w;
158
+ if (overflows) {
159
+ // Wrap: center-align current row inside rect.w, move cursor.
160
+ const consumed = cursorX - rect.x;
161
+ if (consumed > maxConsumedW) maxConsumedW = consumed;
162
+ cursorX = rect.x;
163
+ cursorY += rowHeight + rowGap;
164
+ rowStart = i;
165
+ }
166
+ const xStart = cursorX + (i === rowStart ? 0 : entryGap);
167
+ outEntries.push(placeHorizontal(m, xStart, cursorY, swatchW, swatchH, swatchGap, rowHeight));
168
+ cursorX = xStart + w;
169
+ }
170
+ const finalRowConsumed = cursorX - rect.x;
171
+ if (finalRowConsumed > maxConsumedW) maxConsumedW = finalRowConsumed;
172
+ const totalHeight = cursorY + rowHeight - rect.y;
173
+
174
+ return {
175
+ bbox: { x: rect.x, y: rect.y, w: maxConsumedW, h: totalHeight },
176
+ entries: outEntries,
177
+ };
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Placement helpers
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function placeHorizontal(
185
+ m: { entry: LegendEntryInput; labelWidth: number; lineHeight: number },
186
+ x: number,
187
+ y: number,
188
+ swatchW: number,
189
+ swatchH: number,
190
+ swatchGap: number,
191
+ rowH: number,
192
+ ): LegendEntryRect {
193
+ // Vertically center swatch + label inside the row.
194
+ const swatchY = y + (rowH - swatchH) / 2;
195
+ const labelY = y + (rowH - m.lineHeight) / 2;
196
+ return {
197
+ label: m.entry.label,
198
+ color: m.entry.color,
199
+ swatchRect: { x, y: swatchY, w: swatchW, h: swatchH },
200
+ labelRect: {
201
+ x: x + swatchW + swatchGap,
202
+ y: labelY,
203
+ w: m.labelWidth,
204
+ h: m.lineHeight,
205
+ },
206
+ };
207
+ }
208
+
209
+ function placeVertical(
210
+ m: { entry: LegendEntryInput; labelWidth: number; lineHeight: number },
211
+ x: number,
212
+ y: number,
213
+ swatchW: number,
214
+ swatchH: number,
215
+ swatchGap: number,
216
+ rowH: number,
217
+ ): LegendEntryRect {
218
+ const swatchY = y + (rowH - swatchH) / 2;
219
+ const labelY = y + (rowH - m.lineHeight) / 2;
220
+ return {
221
+ label: m.entry.label,
222
+ color: m.entry.color,
223
+ swatchRect: { x, y: swatchY, w: swatchW, h: swatchH },
224
+ labelRect: {
225
+ x: x + swatchW + swatchGap,
226
+ y: labelY,
227
+ w: m.labelWidth,
228
+ h: m.lineHeight,
229
+ },
230
+ };
231
+ }