@beyondwork/docx-react-component 1.0.53 → 1.0.55
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 +125 -7
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +27 -3
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- 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 +115 -13
- 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/page-number-format.ts +207 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +57 -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/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- 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,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart-side text measurement helper (Stage 3B).
|
|
3
|
+
*
|
|
4
|
+
* Exposes `measureText(text, txPr, theme): TextMetrics` with two backends:
|
|
5
|
+
* - **Canvas** (`CanvasRenderingContext2D.measureText`) when
|
|
6
|
+
* `globalThis.document` is defined (real browser / jsdom with canvas).
|
|
7
|
+
* - **Empirical** glyph-width lookup table when running in SSR or Node
|
|
8
|
+
* test environments without a real DOM.
|
|
9
|
+
*
|
|
10
|
+
* Results are LRU-cached by (text × fontFamily × sizeHalfPoints × bold ×
|
|
11
|
+
* italic) so repeated calls in a single render cycle are effectively free.
|
|
12
|
+
* The cache is intentionally module-level (shared across renders) because
|
|
13
|
+
* chart font choices are low-cardinality.
|
|
14
|
+
*
|
|
15
|
+
* The canvas backend is NOT created on module import — it's lazy-created on
|
|
16
|
+
* the first measurement call so the module can be imported in SSR without
|
|
17
|
+
* side-effects.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { TextProperties } from "../../../io/ooxml/chart/types.ts";
|
|
21
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public API
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface ChartTextMetrics {
|
|
28
|
+
width: number;
|
|
29
|
+
/** Cap-height approximation — useful for vertical centering. */
|
|
30
|
+
ascent: number;
|
|
31
|
+
descent: number;
|
|
32
|
+
lineHeight: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Measure `text` rendered with `txPr` (chart text properties) in the
|
|
37
|
+
* given `theme`. Falls back to the empirical backend when a canvas context
|
|
38
|
+
* is not available (SSR, Node tests).
|
|
39
|
+
*
|
|
40
|
+
* `theme` is accepted for future font-fallback resolution; currently only
|
|
41
|
+
* `txPr.fontFamily` / `txPr.fontSizePt` / `txPr.bold` / `txPr.italic` are
|
|
42
|
+
* used (theme-linked font schemes deferred to Stage 6).
|
|
43
|
+
*/
|
|
44
|
+
export function measureText(
|
|
45
|
+
text: string,
|
|
46
|
+
txPr: TextProperties | undefined,
|
|
47
|
+
_theme: ResolvedTheme | undefined,
|
|
48
|
+
): ChartTextMetrics {
|
|
49
|
+
const family = txPr?.fontFamily ?? DEFAULT_FONT_FAMILY;
|
|
50
|
+
const sizePt = txPr?.fontSizePt ?? DEFAULT_FONT_SIZE_PT;
|
|
51
|
+
const bold = txPr?.bold ?? false;
|
|
52
|
+
const italic = txPr?.italic ?? false;
|
|
53
|
+
|
|
54
|
+
const key = cacheKey(text, family, sizePt, bold, italic);
|
|
55
|
+
const cached = lruGet(key);
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
|
|
58
|
+
const result = canvasContext
|
|
59
|
+
? measureViaCanvas(text, family, sizePt, bold, italic)
|
|
60
|
+
: measureEmpirical(text, family, sizePt, bold, italic);
|
|
61
|
+
|
|
62
|
+
lruSet(key, result);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Defaults
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const DEFAULT_FONT_FAMILY = "Calibri, Carlito, 'Segoe UI', 'Liberation Sans', Arial, sans-serif";
|
|
71
|
+
const DEFAULT_FONT_SIZE_PT = 10;
|
|
72
|
+
/** pt → px at 96 DPI: 1pt = 96/72 px. */
|
|
73
|
+
const PT_TO_PX = 96 / 72;
|
|
74
|
+
const LINE_HEIGHT_RATIO = 1.2;
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Canvas backend (lazy init)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
let canvasContext: CanvasRenderingContext2D | null = null;
|
|
81
|
+
|
|
82
|
+
function getCanvasContext(): CanvasRenderingContext2D | null {
|
|
83
|
+
if (canvasContext !== null) return canvasContext;
|
|
84
|
+
if (typeof globalThis.document === "undefined") return null;
|
|
85
|
+
try {
|
|
86
|
+
const canvas = globalThis.document.createElement("canvas");
|
|
87
|
+
canvasContext = canvas.getContext("2d");
|
|
88
|
+
} catch {
|
|
89
|
+
canvasContext = null;
|
|
90
|
+
}
|
|
91
|
+
return canvasContext;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Initialise lazily on first call (not at import time).
|
|
95
|
+
function ensureCanvas(): void {
|
|
96
|
+
if (canvasContext === null) getCanvasContext();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function measureViaCanvas(
|
|
100
|
+
text: string,
|
|
101
|
+
family: string,
|
|
102
|
+
sizePt: number,
|
|
103
|
+
bold: boolean,
|
|
104
|
+
italic: boolean,
|
|
105
|
+
): ChartTextMetrics {
|
|
106
|
+
const ctx = getCanvasContext();
|
|
107
|
+
if (!ctx) return measureEmpirical(text, family, sizePt, bold, italic);
|
|
108
|
+
|
|
109
|
+
const sizePx = sizePt * PT_TO_PX;
|
|
110
|
+
const weight = bold ? "bold" : "normal";
|
|
111
|
+
const style = italic ? "italic" : "normal";
|
|
112
|
+
ctx.font = `${style} ${weight} ${sizePx}px ${family}`;
|
|
113
|
+
const m = ctx.measureText(text);
|
|
114
|
+
|
|
115
|
+
const ascent = m.actualBoundingBoxAscent ?? sizePx * 0.75;
|
|
116
|
+
const descent = m.actualBoundingBoxDescent ?? sizePx * 0.2;
|
|
117
|
+
return {
|
|
118
|
+
width: m.width,
|
|
119
|
+
ascent,
|
|
120
|
+
descent,
|
|
121
|
+
lineHeight: sizePx * LINE_HEIGHT_RATIO,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Empirical backend — glyph-width tables
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Average character width in em units per font-family (normalised to 1 em).
|
|
131
|
+
* Values derived from Calibri / Arial measurements at regular weight.
|
|
132
|
+
* Bold adds ~12%; italic is nearly identical in advance width.
|
|
133
|
+
*/
|
|
134
|
+
const GLYPH_AVG_EM: Record<string, number> = {
|
|
135
|
+
calibri: 0.44,
|
|
136
|
+
carlito: 0.44,
|
|
137
|
+
arial: 0.50,
|
|
138
|
+
helvetica: 0.50,
|
|
139
|
+
"segoe ui": 0.48,
|
|
140
|
+
"liberation sans": 0.50,
|
|
141
|
+
times: 0.46,
|
|
142
|
+
"times new roman": 0.46,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Per-glyph width table (em units, Calibri-like proportions). */
|
|
146
|
+
const GLYPH_EM: Readonly<Record<string, number>> = {
|
|
147
|
+
// Digits (monospace-like in Calibri)
|
|
148
|
+
"0": 0.50, "1": 0.44, "2": 0.50, "3": 0.50, "4": 0.50,
|
|
149
|
+
"5": 0.50, "6": 0.50, "7": 0.46, "8": 0.50, "9": 0.50,
|
|
150
|
+
// Common punctuation / symbols
|
|
151
|
+
" ": 0.26, ".": 0.22, ",": 0.22, "-": 0.30, "+": 0.52,
|
|
152
|
+
"%": 0.68, "$": 0.50, "€": 0.56, "£": 0.48, "/": 0.30,
|
|
153
|
+
":": 0.22, ";": 0.22, "(": 0.28, ")": 0.28, "[": 0.26, "]": 0.26,
|
|
154
|
+
// Upper-case
|
|
155
|
+
A: 0.60, B: 0.58, C: 0.58, D: 0.64, E: 0.52, F: 0.50,
|
|
156
|
+
G: 0.64, H: 0.64, I: 0.22, J: 0.40, K: 0.58, L: 0.50,
|
|
157
|
+
M: 0.72, N: 0.64, O: 0.68, P: 0.56, Q: 0.68, R: 0.60,
|
|
158
|
+
S: 0.52, T: 0.54, U: 0.64, V: 0.60, W: 0.82, X: 0.56,
|
|
159
|
+
Y: 0.56, Z: 0.54,
|
|
160
|
+
// Lower-case
|
|
161
|
+
a: 0.46, b: 0.50, c: 0.42, d: 0.50, e: 0.46, f: 0.28,
|
|
162
|
+
g: 0.50, h: 0.50, i: 0.22, j: 0.22, k: 0.48, l: 0.22,
|
|
163
|
+
m: 0.74, n: 0.50, o: 0.48, p: 0.50, q: 0.50, r: 0.30,
|
|
164
|
+
s: 0.40, t: 0.34, u: 0.50, v: 0.46, w: 0.64, x: 0.46,
|
|
165
|
+
y: 0.46, z: 0.40,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function avgGlyphEm(family: string): number {
|
|
169
|
+
const key = family.toLowerCase().split(",")[0]!.trim().replace(/^['"]|['"]$/g, "");
|
|
170
|
+
return GLYPH_AVG_EM[key] ?? 0.50;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function measureEmpirical(
|
|
174
|
+
text: string,
|
|
175
|
+
family: string,
|
|
176
|
+
sizePt: number,
|
|
177
|
+
bold: boolean,
|
|
178
|
+
_italic: boolean,
|
|
179
|
+
): ChartTextMetrics {
|
|
180
|
+
const sizePx = sizePt * PT_TO_PX;
|
|
181
|
+
const boldFactor = bold ? 1.12 : 1.0;
|
|
182
|
+
const fallbackEm = avgGlyphEm(family);
|
|
183
|
+
|
|
184
|
+
let widthEm = 0;
|
|
185
|
+
for (const ch of text) {
|
|
186
|
+
widthEm += GLYPH_EM[ch] ?? fallbackEm;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const width = widthEm * sizePx * boldFactor;
|
|
190
|
+
return {
|
|
191
|
+
width,
|
|
192
|
+
ascent: sizePx * 0.75,
|
|
193
|
+
descent: sizePx * 0.2,
|
|
194
|
+
lineHeight: sizePx * LINE_HEIGHT_RATIO,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// LRU cache (simple doubly-linked-list, capacity 256)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const LRU_CAPACITY = 256;
|
|
203
|
+
|
|
204
|
+
interface LRUNode {
|
|
205
|
+
key: string;
|
|
206
|
+
value: ChartTextMetrics;
|
|
207
|
+
prev: LRUNode | null;
|
|
208
|
+
next: LRUNode | null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lruMap = new Map<string, LRUNode>();
|
|
212
|
+
let lruHead: LRUNode | null = null; // most-recently used
|
|
213
|
+
let lruTail: LRUNode | null = null; // least-recently used
|
|
214
|
+
|
|
215
|
+
function cacheKey(
|
|
216
|
+
text: string,
|
|
217
|
+
family: string,
|
|
218
|
+
sizePt: number,
|
|
219
|
+
bold: boolean,
|
|
220
|
+
italic: boolean,
|
|
221
|
+
): string {
|
|
222
|
+
// Half-points to avoid float key drift (1.5pt → "3").
|
|
223
|
+
const halfPts = Math.round(sizePt * 2);
|
|
224
|
+
return `${text}\x00${family}\x00${halfPts}\x00${bold ? 1 : 0}\x00${italic ? 1 : 0}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function lruGet(key: string): ChartTextMetrics | undefined {
|
|
228
|
+
const node = lruMap.get(key);
|
|
229
|
+
if (!node) return undefined;
|
|
230
|
+
moveToHead(node);
|
|
231
|
+
return node.value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function lruSet(key: string, value: ChartTextMetrics): void {
|
|
235
|
+
const existing = lruMap.get(key);
|
|
236
|
+
if (existing) {
|
|
237
|
+
existing.value = value;
|
|
238
|
+
moveToHead(existing);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const node: LRUNode = { key, value, prev: null, next: null };
|
|
242
|
+
lruMap.set(key, node);
|
|
243
|
+
insertHead(node);
|
|
244
|
+
if (lruMap.size > LRU_CAPACITY) evictTail();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function insertHead(node: LRUNode): void {
|
|
248
|
+
node.next = lruHead;
|
|
249
|
+
node.prev = null;
|
|
250
|
+
if (lruHead) lruHead.prev = node;
|
|
251
|
+
lruHead = node;
|
|
252
|
+
if (!lruTail) lruTail = node;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function moveToHead(node: LRUNode): void {
|
|
256
|
+
if (node === lruHead) return;
|
|
257
|
+
if (node.prev) node.prev.next = node.next;
|
|
258
|
+
if (node.next) node.next.prev = node.prev;
|
|
259
|
+
if (node === lruTail) lruTail = node.prev;
|
|
260
|
+
node.prev = null;
|
|
261
|
+
node.next = lruHead;
|
|
262
|
+
if (lruHead) lruHead.prev = node;
|
|
263
|
+
lruHead = node;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function evictTail(): void {
|
|
267
|
+
if (!lruTail) return;
|
|
268
|
+
lruMap.delete(lruTail.key);
|
|
269
|
+
if (lruTail.prev) lruTail.prev.next = null;
|
|
270
|
+
lruTail = lruTail.prev;
|
|
271
|
+
if (!lruTail) lruHead = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Flush the module-level LRU cache. Called by tests to ensure isolation
|
|
276
|
+
* between test cases that change backend availability.
|
|
277
|
+
*/
|
|
278
|
+
export function _flushMetricsCache(): void {
|
|
279
|
+
lruMap.clear();
|
|
280
|
+
lruHead = null;
|
|
281
|
+
lruTail = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Override the canvas context for testing. Pass `null` to force the
|
|
286
|
+
* empirical backend; pass a mock `CanvasRenderingContext2D` to exercise
|
|
287
|
+
* the canvas path without a real DOM.
|
|
288
|
+
*/
|
|
289
|
+
export function _setCanvasContextForTesting(
|
|
290
|
+
ctx: CanvasRenderingContext2D | null,
|
|
291
|
+
): void {
|
|
292
|
+
canvasContext = ctx;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Eagerly try to acquire the canvas context if a document is already
|
|
296
|
+
// available (e.g. jsdom in test environments that mount a DOM). A failure
|
|
297
|
+
// here is fine — the empirical backend kicks in.
|
|
298
|
+
ensureCanvas();
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gridline generator — major / minor lines for cartesian chart axes (Stage 3C).
|
|
3
|
+
*
|
|
4
|
+
* Gridlines visualize the tick lattice inside the plot area. Major lines
|
|
5
|
+
* sit at every major tick; minor lines sit at each intermediate minor
|
|
6
|
+
* tick. Word's default appearance:
|
|
7
|
+
*
|
|
8
|
+
* - Color: theme `tx1` with `lumMod 15000 + lumOff 85000` (≈ 15% grey on
|
|
9
|
+
* a white background). Renders as `#D9D9D9` on the default Office theme.
|
|
10
|
+
* - Stroke width: 0.75 pt.
|
|
11
|
+
* - No dash (solid).
|
|
12
|
+
*
|
|
13
|
+
* The generator emits pure geometric line descriptors (x1, y1, x2, y2)
|
|
14
|
+
* so renderers can compose them via `svgLine` from `svg-primitives.ts`.
|
|
15
|
+
* Gridline **rendering** — turning the descriptors into SVG elements —
|
|
16
|
+
* is the renderer's responsibility; this module is pure math.
|
|
17
|
+
*
|
|
18
|
+
* Axis orientation rules (for value axes):
|
|
19
|
+
* - Y-axis (left side of plot): horizontal gridlines at each y tick
|
|
20
|
+
* position, spanning the full plot width.
|
|
21
|
+
* - X-axis (bottom of plot): vertical gridlines at each x tick
|
|
22
|
+
* position, spanning the full plot height.
|
|
23
|
+
*
|
|
24
|
+
* `crossBetween` (B5 fix) applies to *category* axes on bar/column charts
|
|
25
|
+
* only. When `"between"` (bar default), gridlines fall between categories
|
|
26
|
+
* (at category edges), not at category centers. When `"midCat"` (line
|
|
27
|
+
* default), gridlines align with category centers. For value axes this
|
|
28
|
+
* setting has no effect.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Rect } from "../layout/plot-area.ts";
|
|
32
|
+
import type { TickResult } from "../layout/axis-layout.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export type GridlineAxis = "x" | "y";
|
|
39
|
+
|
|
40
|
+
export interface GridlineSegment {
|
|
41
|
+
x1: number;
|
|
42
|
+
y1: number;
|
|
43
|
+
x2: number;
|
|
44
|
+
y2: number;
|
|
45
|
+
major: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GridlineInput {
|
|
49
|
+
/** "x" = vertical lines at x-tick positions; "y" = horizontal at y-tick. */
|
|
50
|
+
axis: GridlineAxis;
|
|
51
|
+
plotRect: Rect;
|
|
52
|
+
/** Major + minor tick positions (from generateValueTicks / etc.). */
|
|
53
|
+
ticks: TickResult;
|
|
54
|
+
/** Data-range bounds that the plot mapping uses. */
|
|
55
|
+
min: number;
|
|
56
|
+
max: number;
|
|
57
|
+
/**
|
|
58
|
+
* Reverse flag from `ValueAxis.reverse` (`c:scaling/orientation=maxMin`).
|
|
59
|
+
* When true, the max end sits at the low-coordinate side and the
|
|
60
|
+
* mapping flips.
|
|
61
|
+
*/
|
|
62
|
+
reverse?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CategoryGridlineInput {
|
|
66
|
+
plotRect: Rect;
|
|
67
|
+
/** Number of categories (bar/line/area x-axis slot count). */
|
|
68
|
+
categoryCount: number;
|
|
69
|
+
/**
|
|
70
|
+
* `c:crossBetween` from the value axis that crosses this category axis.
|
|
71
|
+
* `"between"` → lines at category edges (bar/column convention).
|
|
72
|
+
* `"midCat"` → lines at category centers (line/area convention).
|
|
73
|
+
* Undefined defaults to `"midCat"` per Word's observed behavior.
|
|
74
|
+
*/
|
|
75
|
+
crossBetween?: "between" | "midCat";
|
|
76
|
+
/** Optional skip factor to match tick-mark-skip on dense axes. */
|
|
77
|
+
tickMarkSkip?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Value-axis gridlines
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate major + minor gridline segments for a value or date axis.
|
|
86
|
+
* Output order: majors first (ascending tick position), then minors.
|
|
87
|
+
*/
|
|
88
|
+
export function generateValueGridlines(input: GridlineInput): GridlineSegment[] {
|
|
89
|
+
const out: GridlineSegment[] = [];
|
|
90
|
+
const span = input.max - input.min;
|
|
91
|
+
if (span === 0 || !Number.isFinite(span)) return out;
|
|
92
|
+
|
|
93
|
+
const mapPosition = makeMapPosition(input);
|
|
94
|
+
for (const pos of input.ticks.major) {
|
|
95
|
+
out.push(buildSegment(mapPosition(pos), input.axis, input.plotRect, true));
|
|
96
|
+
}
|
|
97
|
+
for (const pos of input.ticks.minor) {
|
|
98
|
+
out.push(buildSegment(mapPosition(pos), input.axis, input.plotRect, false));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a single gridline descriptor at the given coordinate.
|
|
105
|
+
* - x-axis: vertical line, spans full plot height at this x-coord.
|
|
106
|
+
* - y-axis: horizontal line, spans full plot width at this y-coord.
|
|
107
|
+
*/
|
|
108
|
+
function buildSegment(
|
|
109
|
+
coord: number,
|
|
110
|
+
axis: GridlineAxis,
|
|
111
|
+
plotRect: Rect,
|
|
112
|
+
major: boolean,
|
|
113
|
+
): GridlineSegment {
|
|
114
|
+
if (axis === "x") {
|
|
115
|
+
return {
|
|
116
|
+
x1: coord,
|
|
117
|
+
y1: plotRect.y,
|
|
118
|
+
x2: coord,
|
|
119
|
+
y2: plotRect.y + plotRect.h,
|
|
120
|
+
major,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
x1: plotRect.x,
|
|
125
|
+
y1: coord,
|
|
126
|
+
x2: plotRect.x + plotRect.w,
|
|
127
|
+
y2: coord,
|
|
128
|
+
major,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a value→plot-coordinate mapper for the given axis. `reverse`
|
|
134
|
+
* flips the mapping so `max` sits at the low-coordinate side.
|
|
135
|
+
*
|
|
136
|
+
* For y-axis (axis='y'), pixel Y grows downward but data typically
|
|
137
|
+
* grows upward, so the default mapping inverts: `min` at `y + h`,
|
|
138
|
+
* `max` at `y`. `reverse=true` flips that back.
|
|
139
|
+
*/
|
|
140
|
+
function makeMapPosition(input: GridlineInput): (value: number) => number {
|
|
141
|
+
const { axis, plotRect, min, max, reverse } = input;
|
|
142
|
+
const span = max - min;
|
|
143
|
+
|
|
144
|
+
if (axis === "x") {
|
|
145
|
+
if (reverse) {
|
|
146
|
+
return (v) => plotRect.x + ((max - v) / span) * plotRect.w;
|
|
147
|
+
}
|
|
148
|
+
return (v) => plotRect.x + ((v - min) / span) * plotRect.w;
|
|
149
|
+
}
|
|
150
|
+
// y
|
|
151
|
+
if (reverse) {
|
|
152
|
+
return (v) => plotRect.y + ((v - min) / span) * plotRect.h;
|
|
153
|
+
}
|
|
154
|
+
return (v) => plotRect.y + plotRect.h - ((v - min) / span) * plotRect.h;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Category-axis gridlines (B5 — crossBetween honored)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generate gridlines for a category axis. Category axes live under
|
|
163
|
+
* bar/column/line/area charts; `crossBetween` decides whether lines
|
|
164
|
+
* fall between categories (at slot edges) or at slot centers.
|
|
165
|
+
*
|
|
166
|
+
* - `"between"` (bar default): N+1 edge lines at slot boundaries.
|
|
167
|
+
* - `"midCat"` (line default): N center lines at slot mid-points.
|
|
168
|
+
*
|
|
169
|
+
* `tickMarkSkip` (Word's `c:tickMarkSkip`) thins out dense axes by
|
|
170
|
+
* emitting every Nth line. Defaults to 1 (all lines).
|
|
171
|
+
*/
|
|
172
|
+
export function generateCategoryGridlines(
|
|
173
|
+
input: CategoryGridlineInput,
|
|
174
|
+
): GridlineSegment[] {
|
|
175
|
+
const { plotRect, categoryCount } = input;
|
|
176
|
+
if (categoryCount <= 0) return [];
|
|
177
|
+
const mode = input.crossBetween ?? "midCat";
|
|
178
|
+
const skip = Math.max(1, Math.floor(input.tickMarkSkip ?? 1));
|
|
179
|
+
const out: GridlineSegment[] = [];
|
|
180
|
+
const slotWidth = plotRect.w / categoryCount;
|
|
181
|
+
|
|
182
|
+
if (mode === "between") {
|
|
183
|
+
// Edges at 0, 1, 2, …, categoryCount → N+1 positions.
|
|
184
|
+
for (let i = 0; i <= categoryCount; i += skip) {
|
|
185
|
+
const x = plotRect.x + i * slotWidth;
|
|
186
|
+
out.push({
|
|
187
|
+
x1: x,
|
|
188
|
+
y1: plotRect.y,
|
|
189
|
+
x2: x,
|
|
190
|
+
y2: plotRect.y + plotRect.h,
|
|
191
|
+
major: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
// midCat: centers at 0.5, 1.5, …
|
|
197
|
+
for (let i = 0; i < categoryCount; i += skip) {
|
|
198
|
+
const x = plotRect.x + (i + 0.5) * slotWidth;
|
|
199
|
+
out.push({
|
|
200
|
+
x1: x,
|
|
201
|
+
y1: plotRect.y,
|
|
202
|
+
x2: x,
|
|
203
|
+
y2: plotRect.y + plotRect.h,
|
|
204
|
+
major: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Default appearance helpers
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Word-default gridline color (theme `tx1` with `lumMod 15000 +
|
|
216
|
+
* lumOff 85000` on a black `tx1`, which evaluates to `#D9D9D9`). Kept
|
|
217
|
+
* as a helper so renderers can ask for the default or substitute a
|
|
218
|
+
* theme-resolved alternative.
|
|
219
|
+
*/
|
|
220
|
+
export const DEFAULT_MAJOR_GRIDLINE_COLOR = "#D9D9D9";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Minor gridline color is lighter than major. Word renders minor lines
|
|
224
|
+
* at `tx1 + lumMod 5000 + lumOff 95000` which resolves to `#F2F2F2`.
|
|
225
|
+
*/
|
|
226
|
+
export const DEFAULT_MINOR_GRIDLINE_COLOR = "#F2F2F2";
|
|
227
|
+
|
|
228
|
+
export const DEFAULT_GRIDLINE_STROKE_WIDTH_PT = 0.75;
|