@beyondwork/docx-react-component 1.0.50 → 1.0.52
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/README.md +8 -5
- package/package.json +40 -29
- package/src/api/public-types.ts +9 -0
- package/src/runtime/layout/layout-engine-version.ts +42 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +8 -1
- package/src/runtime/render/render-kernel.ts +40 -10
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +3 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R.1 SelectionLayer — named module for cursor movement, validation, and
|
|
3
|
+
* anchor projection. See `docs/plans/lane-1-editing-foundation.md` §R.1.
|
|
4
|
+
*
|
|
5
|
+
* This file is the layer's single entry point. Callers consume
|
|
6
|
+
* `SelectionLayer.move(doc, sel, op)` / `SelectionLayer.validate(doc, sel)`
|
|
7
|
+
* rather than reaching into individual helpers. Phase 6a ships the six
|
|
8
|
+
* layout-independent primitives (char/word/paragraph × left/right). Phase 6b
|
|
9
|
+
* adds `moveUp` / `moveDown` / `moveLineStart` / `moveLineEnd` once Lane 3a
|
|
10
|
+
* P9's layout facet exposes per-run column info.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DocumentRootNode } from "../../model/canonical-document.ts";
|
|
14
|
+
import type { CanonicalDocumentEnvelope, SelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
15
|
+
import {
|
|
16
|
+
moveCharLeft,
|
|
17
|
+
moveCharRight,
|
|
18
|
+
moveParagraphEnd,
|
|
19
|
+
moveParagraphStart,
|
|
20
|
+
moveWordLeft,
|
|
21
|
+
moveWordRight,
|
|
22
|
+
type CursorMoveOptions,
|
|
23
|
+
type CursorSelection,
|
|
24
|
+
} from "./cursor-ops.ts";
|
|
25
|
+
import { validateSelectionAgainstDocument } from "./post-edit-validator.ts";
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
CursorMoveOptions,
|
|
29
|
+
CursorSelection,
|
|
30
|
+
} from "./cursor-ops.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cursor-move operations supported by the layer. Phase 6b will extend this
|
|
34
|
+
* with `"up" | "down" | "line-start" | "line-end"`.
|
|
35
|
+
*/
|
|
36
|
+
export type CursorMoveOp =
|
|
37
|
+
| "char-left"
|
|
38
|
+
| "char-right"
|
|
39
|
+
| "word-left"
|
|
40
|
+
| "word-right"
|
|
41
|
+
| "paragraph-start"
|
|
42
|
+
| "paragraph-end";
|
|
43
|
+
|
|
44
|
+
export interface SelectionLayer {
|
|
45
|
+
/**
|
|
46
|
+
* Apply a cursor-move operation and return the resulting selection. Pure;
|
|
47
|
+
* does not mutate any argument.
|
|
48
|
+
*/
|
|
49
|
+
move(
|
|
50
|
+
doc: DocumentRootNode,
|
|
51
|
+
selection: CursorSelection,
|
|
52
|
+
op: CursorMoveOp,
|
|
53
|
+
options?: CursorMoveOptions,
|
|
54
|
+
): CursorSelection;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the I5 post-edit selection validator. Snaps orphaned offsets into the
|
|
58
|
+
* nearest valid position. Pure; returns the (possibly adjusted) selection.
|
|
59
|
+
*/
|
|
60
|
+
validate(
|
|
61
|
+
doc: CanonicalDocumentEnvelope,
|
|
62
|
+
selection: SelectionSnapshot,
|
|
63
|
+
maxOffset: number,
|
|
64
|
+
): SelectionSnapshot;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default SelectionLayer instance. Stateless — the same instance is safe to
|
|
69
|
+
* share across runtimes.
|
|
70
|
+
*/
|
|
71
|
+
export const selectionLayer: SelectionLayer = {
|
|
72
|
+
move(doc, selection, op, options) {
|
|
73
|
+
switch (op) {
|
|
74
|
+
case "char-left":
|
|
75
|
+
return moveCharLeft(doc, selection, options);
|
|
76
|
+
case "char-right":
|
|
77
|
+
return moveCharRight(doc, selection, options);
|
|
78
|
+
case "word-left":
|
|
79
|
+
return moveWordLeft(doc, selection, options);
|
|
80
|
+
case "word-right":
|
|
81
|
+
return moveWordRight(doc, selection, options);
|
|
82
|
+
case "paragraph-start":
|
|
83
|
+
return moveParagraphStart(doc, selection, options);
|
|
84
|
+
case "paragraph-end":
|
|
85
|
+
return moveParagraphEnd(doc, selection, options);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
validate(doc, selection, maxOffset) {
|
|
89
|
+
return validateSelectionAgainstDocument(doc, selection, maxOffset);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
resolveNumberingMarkerRunFormatting,
|
|
53
53
|
} from "./paragraph-style-resolver.ts";
|
|
54
54
|
import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
|
|
55
|
+
import { concretizeThemeColors } from "./theme-color-resolver.ts";
|
|
55
56
|
import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
|
|
56
57
|
|
|
57
58
|
interface ParagraphAccumulator {
|
|
@@ -874,6 +875,11 @@ function appendInlineSegments(
|
|
|
874
875
|
characterStyleId: undefined,
|
|
875
876
|
direct: directRunFormatting,
|
|
876
877
|
};
|
|
878
|
+
// L2.c — non-hyperlink body text also gets theme-color concretization
|
|
879
|
+
// so paragraph styles declaring `<w:color w:themeColor="accent1"/>`
|
|
880
|
+
// render with their theme slot's hex instead of falling back to
|
|
881
|
+
// default black. Hyperlink branch already resolves theme via the
|
|
882
|
+
// Hyperlink-style cascade; only the non-hyperlink path was missing.
|
|
877
883
|
const resolvedRunFormatting = cullBuild
|
|
878
884
|
? {}
|
|
879
885
|
: hyperlinkHref
|
|
@@ -882,7 +888,10 @@ function appendInlineSegments(
|
|
|
882
888
|
document.styles,
|
|
883
889
|
document.subParts?.resolvedTheme,
|
|
884
890
|
)
|
|
885
|
-
:
|
|
891
|
+
: concretizeThemeColors(
|
|
892
|
+
resolveEffectiveRunFormatting(runResolveInput, document.styles),
|
|
893
|
+
document.subParts?.resolvedTheme,
|
|
894
|
+
);
|
|
886
895
|
paragraph.segments.push({
|
|
887
896
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
888
897
|
kind: "text",
|
|
@@ -57,6 +57,52 @@ export function resolveThemeColorHex(
|
|
|
57
57
|
return applyThemeTintShade(baseHex, rPr.colorThemeTint, rPr.colorThemeShade);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Post-process a cascade of run formatting to concretize any
|
|
62
|
+
* theme-color-only reference into a paintable `colorHex`.
|
|
63
|
+
*
|
|
64
|
+
* L2.c body-text call site: `surface-projection.ts` runs the standard
|
|
65
|
+
* `resolveEffectiveRunFormatting` cascade, then pipes the result through
|
|
66
|
+
* this function with the document's resolved theme. Runs that declared
|
|
67
|
+
* `<w:color w:themeColor="accent1"/>` without a concrete `w:val` end up
|
|
68
|
+
* with `colorHex` set to the theme-resolved hex so downstream
|
|
69
|
+
* `pm-state-from-snapshot` can paint them.
|
|
70
|
+
*
|
|
71
|
+
* The theme-slot fields (`colorThemeSlot` / `Tint` / `Shade`) stay on
|
|
72
|
+
* the cascade so that the export path can re-emit the original theme
|
|
73
|
+
* reference — this function never overwrites them, it only adds
|
|
74
|
+
* `colorHex` when it was absent or `"auto"`.
|
|
75
|
+
*
|
|
76
|
+
* No-ops when:
|
|
77
|
+
* - no theme-slot is declared (nothing to resolve),
|
|
78
|
+
* - `colorHex` is already a concrete non-`"auto"` hex (direct wins),
|
|
79
|
+
* - theme is missing or the slot is unknown (graceful fallback),
|
|
80
|
+
* - resolver returns `undefined` (e.g. malformed tint/shade).
|
|
81
|
+
*/
|
|
82
|
+
export function concretizeThemeColors(
|
|
83
|
+
cascade: CanonicalRunFormatting,
|
|
84
|
+
theme: ResolvedTheme | undefined,
|
|
85
|
+
): CanonicalRunFormatting {
|
|
86
|
+
if (!cascade.colorThemeSlot) return cascade;
|
|
87
|
+
if (cascade.colorHex && cascade.colorHex !== "auto") return cascade;
|
|
88
|
+
// When colorHex is "auto" we intentionally bypass it during theme
|
|
89
|
+
// resolution: Word's cascade treats `<w:color w:val="auto"
|
|
90
|
+
// w:themeColor="accent1"/>` as "paint with the theme slot; auto is
|
|
91
|
+
// the fallback if theme is missing." Pass a stripped input to the
|
|
92
|
+
// resolver so `colorHex === "auto"` doesn't short-circuit.
|
|
93
|
+
const resolved = resolveThemeColorHex(
|
|
94
|
+
{
|
|
95
|
+
colorThemeSlot: cascade.colorThemeSlot,
|
|
96
|
+
colorThemeTint: cascade.colorThemeTint,
|
|
97
|
+
colorThemeShade: cascade.colorThemeShade,
|
|
98
|
+
},
|
|
99
|
+
theme,
|
|
100
|
+
);
|
|
101
|
+
if (!resolved || resolved === "auto") return cascade;
|
|
102
|
+
if (resolved === cascade.colorHex) return cascade;
|
|
103
|
+
return { ...cascade, colorHex: resolved };
|
|
104
|
+
}
|
|
105
|
+
|
|
60
106
|
/**
|
|
61
107
|
* Apply `w:themeTint` (shift toward white) and/or `w:themeShade` (shift
|
|
62
108
|
* toward black) to a base hex colour. Pure function.
|
|
@@ -782,6 +782,7 @@ export function __createWordReviewEditorRefBridge(
|
|
|
782
782
|
clearWorkflowOverlay: () => {
|
|
783
783
|
runtime.clearWorkflowOverlay();
|
|
784
784
|
},
|
|
785
|
+
getWorkflowOverlay: () => clonePublicValue(runtime.getWorkflowOverlay()),
|
|
785
786
|
setSharedWorkflowState: (state) => {
|
|
786
787
|
runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
|
|
787
788
|
},
|
|
@@ -1833,6 +1834,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1833
1834
|
clearWorkflowOverlay: () => {
|
|
1834
1835
|
activeRuntime.clearWorkflowOverlay();
|
|
1835
1836
|
},
|
|
1837
|
+
getWorkflowOverlay: () =>
|
|
1838
|
+
clonePublicValue(activeRuntime.getWorkflowOverlay()),
|
|
1836
1839
|
setSharedWorkflowState: (state) => {
|
|
1837
1840
|
activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
|
|
1838
1841
|
},
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axis tick generation for chart rendering (Stage 3A, pure math).
|
|
3
|
+
*
|
|
4
|
+
* Three axis kinds:
|
|
5
|
+
* - **value** — numeric axis with a "nice" auto-step (when the model
|
|
6
|
+
* doesn't pin `majorUnit`) derived from a d3-style heuristic:
|
|
7
|
+
* pick from {1, 2, 2.5, 5} × 10^n targeting 5-8 major ticks.
|
|
8
|
+
* Log-scale value axes use `logBase` (default 10) to generate
|
|
9
|
+
* ticks at base^k covering the domain.
|
|
10
|
+
* - **category** — axis labels come from the model's pre-resolved
|
|
11
|
+
* category array; `tickLabelSkip` lets Word-authored charts skip
|
|
12
|
+
* every N-th label.
|
|
13
|
+
* - **date** — Excel serial dates (days since 1899-12-30). We respect
|
|
14
|
+
* `baseTimeUnit` ∈ {days, months, years} by stepping through the
|
|
15
|
+
* domain in units of that period; `majorTimeUnit` overrides the
|
|
16
|
+
* step when present.
|
|
17
|
+
*
|
|
18
|
+
* All functions are pure (no DOM, no React, no runtime imports) so
|
|
19
|
+
* Stage 3 layout math is unit-testable without a browser.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Value axis — linear + log
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ValueTickInput {
|
|
27
|
+
min: number;
|
|
28
|
+
max: number;
|
|
29
|
+
/** Override for major step. When present, ticks are strictly multiples. */
|
|
30
|
+
majorUnit?: number;
|
|
31
|
+
/** Override for minor step. Defaults to majorUnit / 5 when omitted. */
|
|
32
|
+
minorUnit?: number;
|
|
33
|
+
/** Log-scale base. Omitted for linear axes. */
|
|
34
|
+
logBase?: number;
|
|
35
|
+
/** Target major-tick count range for the auto-step heuristic. */
|
|
36
|
+
targetTickCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TickResult {
|
|
40
|
+
/** Major tick positions, sorted ascending. */
|
|
41
|
+
major: number[];
|
|
42
|
+
/** Minor tick positions (between majors), sorted ascending. */
|
|
43
|
+
minor: number[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TARGET_TICK_COUNT = 6;
|
|
47
|
+
// d3-scale convention. Omitting 2.5 keeps ticks on "round" values
|
|
48
|
+
// (multiples of 1/2/5/10/20/50/…) that match Word's tick choices more
|
|
49
|
+
// closely than the extended-Wilkinson mantissa list.
|
|
50
|
+
const NICE_STEP_MANTISSAS = [1, 2, 5, 10];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate tick positions for a value axis.
|
|
54
|
+
*
|
|
55
|
+
* When `majorUnit` is supplied, ticks are strict multiples of it
|
|
56
|
+
* covering `[min, max]`. Otherwise the auto-step heuristic picks a
|
|
57
|
+
* "nice" step from `NICE_STEP_MANTISSAS × 10^n` that produces roughly
|
|
58
|
+
* `targetTickCount` (default 6) major ticks.
|
|
59
|
+
*
|
|
60
|
+
* Log-scale axes generate ticks at `logBase^k` for integer k covering
|
|
61
|
+
* the domain, plus minor ticks at {2,3,…,base-1} × logBase^k between
|
|
62
|
+
* majors (standard log-axis convention, matching Word's output).
|
|
63
|
+
*
|
|
64
|
+
* Degenerate domains (min > max, min === max, non-finite values) fall
|
|
65
|
+
* back to a single `[min, min]` tick so callers always get at least
|
|
66
|
+
* one position to render an axis line.
|
|
67
|
+
*/
|
|
68
|
+
export function generateValueTicks(input: ValueTickInput): TickResult {
|
|
69
|
+
if (!Number.isFinite(input.min) || !Number.isFinite(input.max)) {
|
|
70
|
+
return { major: [input.min], minor: [] };
|
|
71
|
+
}
|
|
72
|
+
if (input.min === input.max) {
|
|
73
|
+
return { major: [input.min], minor: [] };
|
|
74
|
+
}
|
|
75
|
+
const [min, max] = input.min < input.max
|
|
76
|
+
? [input.min, input.max]
|
|
77
|
+
: [input.max, input.min];
|
|
78
|
+
|
|
79
|
+
if (input.logBase && input.logBase > 1) {
|
|
80
|
+
return generateLogTicks(min, max, input.logBase);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const step = input.majorUnit ?? niceStep(
|
|
84
|
+
max - min,
|
|
85
|
+
input.targetTickCount ?? DEFAULT_TARGET_TICK_COUNT,
|
|
86
|
+
);
|
|
87
|
+
const minorStep = input.minorUnit ?? step / 5;
|
|
88
|
+
|
|
89
|
+
const firstMajor = Math.ceil(min / step - 1e-9) * step;
|
|
90
|
+
const major: number[] = [];
|
|
91
|
+
for (let t = firstMajor; t <= max + 1e-9; t += step) {
|
|
92
|
+
// Round to the step's decimal precision to avoid floating drift.
|
|
93
|
+
major.push(roundToStep(t, step));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const minor: number[] = [];
|
|
97
|
+
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
|
+
// Skip positions that coincide with major ticks (mod epsilon).
|
|
101
|
+
const snapped = roundToStep(t, minorStep);
|
|
102
|
+
if (!isMajorPosition(snapped, step)) minor.push(snapped);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { major, minor };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pick a "nice" step size for a given range + target tick count using
|
|
111
|
+
* the d3-scale-style heuristic: find the power-of-ten bucket, then
|
|
112
|
+
* pick from {1, 2, 2.5, 5, 10} the mantissa that yields the closest
|
|
113
|
+
* number of ticks to `target`.
|
|
114
|
+
*/
|
|
115
|
+
export function niceStep(range: number, target: number): number {
|
|
116
|
+
if (range <= 0 || !Number.isFinite(range)) return 1;
|
|
117
|
+
const rough = range / Math.max(1, target);
|
|
118
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
119
|
+
const normalized = rough / magnitude;
|
|
120
|
+
// Pick the mantissa producing tick count closest to target.
|
|
121
|
+
let best = NICE_STEP_MANTISSAS[0]!;
|
|
122
|
+
let bestDelta = Infinity;
|
|
123
|
+
for (const m of NICE_STEP_MANTISSAS) {
|
|
124
|
+
if (m < normalized) continue;
|
|
125
|
+
const step = m * magnitude;
|
|
126
|
+
const count = Math.floor(range / step) + 1;
|
|
127
|
+
const delta = Math.abs(count - target);
|
|
128
|
+
if (delta < bestDelta) {
|
|
129
|
+
bestDelta = delta;
|
|
130
|
+
best = m;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return best * magnitude;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function generateLogTicks(min: number, max: number, base: number): TickResult {
|
|
137
|
+
// For log scale, clamp min > 0 to avoid -Infinity.
|
|
138
|
+
const safeMin = Math.max(min, Number.MIN_VALUE);
|
|
139
|
+
const lo = Math.floor(logBase(safeMin, base));
|
|
140
|
+
const hi = Math.ceil(logBase(max, base));
|
|
141
|
+
const major: number[] = [];
|
|
142
|
+
const minor: number[] = [];
|
|
143
|
+
for (let k = lo; k <= hi; k++) {
|
|
144
|
+
const power = Math.pow(base, k);
|
|
145
|
+
if (power >= min - 1e-12 && power <= max + 1e-12) {
|
|
146
|
+
major.push(power);
|
|
147
|
+
}
|
|
148
|
+
// Minor ticks at 2×, 3×, …, (base-1)× within each decade.
|
|
149
|
+
for (let m = 2; m < base; m++) {
|
|
150
|
+
const minorPos = m * power;
|
|
151
|
+
if (minorPos >= min && minorPos <= max) {
|
|
152
|
+
minor.push(minorPos);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { major, minor };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function logBase(x: number, base: number): number {
|
|
160
|
+
return Math.log(x) / Math.log(base);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function roundToStep(value: number, step: number): number {
|
|
164
|
+
// Round to the decimal precision implied by the step so e.g.
|
|
165
|
+
// `step=0.2` produces values like 0.2, 0.4, 0.6 without 0.30000004.
|
|
166
|
+
// Also normalise -0 to 0 since `Math.ceil(-1e-9) * step === -0` and
|
|
167
|
+
// `deepStrictEqual` distinguishes -0 from 0.
|
|
168
|
+
if (step === 0) return value;
|
|
169
|
+
const precision = Math.max(0, -Math.floor(Math.log10(Math.abs(step))) + 2);
|
|
170
|
+
const factor = Math.pow(10, precision);
|
|
171
|
+
const rounded = Math.round(value * factor) / factor;
|
|
172
|
+
return rounded === 0 ? 0 : rounded;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isMajorPosition(value: number, step: number): boolean {
|
|
176
|
+
const mod = Math.abs(value / step - Math.round(value / step));
|
|
177
|
+
return mod < 1e-6;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Category axis — label pass-through with optional skip
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export interface CategoryTickInput {
|
|
185
|
+
labels: ReadonlyArray<string>;
|
|
186
|
+
/** c:tickLabelSkip — show every N-th label. 1 (default) shows all. */
|
|
187
|
+
tickLabelSkip?: number;
|
|
188
|
+
/** c:tickMarkSkip — show tick mark every N-th category. */
|
|
189
|
+
tickMarkSkip?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface CategoryTickResult {
|
|
193
|
+
/**
|
|
194
|
+
* Emitted tick positions as category index → label pairs. Index is
|
|
195
|
+
* the position in the original `labels` array; label is the rendered
|
|
196
|
+
* string ("" when skipped by `tickLabelSkip`). Renderers map index
|
|
197
|
+
* to plot-area coordinate via the category slot width.
|
|
198
|
+
*/
|
|
199
|
+
ticks: Array<{ index: number; label: string; major: boolean }>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function generateCategoryTicks(input: CategoryTickInput): CategoryTickResult {
|
|
203
|
+
const labelSkip = Math.max(1, Math.floor(input.tickLabelSkip ?? 1));
|
|
204
|
+
const markSkip = Math.max(1, Math.floor(input.tickMarkSkip ?? 1));
|
|
205
|
+
const ticks: CategoryTickResult["ticks"] = [];
|
|
206
|
+
for (let i = 0; i < input.labels.length; i++) {
|
|
207
|
+
const isLabeled = i % labelSkip === 0;
|
|
208
|
+
const isMajor = i % markSkip === 0;
|
|
209
|
+
ticks.push({
|
|
210
|
+
index: i,
|
|
211
|
+
label: isLabeled ? input.labels[i]! : "",
|
|
212
|
+
major: isMajor,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { ticks };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Date axis — Excel serial dates
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export type TimeUnit = "days" | "months" | "years";
|
|
223
|
+
|
|
224
|
+
export interface DateTickInput {
|
|
225
|
+
/** Excel serial date (days since 1899-12-30). */
|
|
226
|
+
min: number;
|
|
227
|
+
max: number;
|
|
228
|
+
/** c:baseTimeUnit — sets the default step for auto-generation. */
|
|
229
|
+
baseTimeUnit?: TimeUnit;
|
|
230
|
+
/** c:majorUnit + c:majorTimeUnit — pinned major step. */
|
|
231
|
+
majorUnit?: number;
|
|
232
|
+
majorTimeUnit?: TimeUnit;
|
|
233
|
+
/** c:minorUnit + c:minorTimeUnit — pinned minor step. */
|
|
234
|
+
minorUnit?: number;
|
|
235
|
+
minorTimeUnit?: TimeUnit;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Generate tick positions for a date axis. Returns Excel serial-date
|
|
240
|
+
* numbers so the renderer can format them via the chart's number-format
|
|
241
|
+
* code (`"mmm-yy"`, `"yyyy-mm-dd"`, etc.) using `formatNumber` from
|
|
242
|
+
* `number-format.ts`.
|
|
243
|
+
*
|
|
244
|
+
* When the domain spans ≤ 2 months and no explicit unit, ticks fall at
|
|
245
|
+
* day boundaries; ≤ 2 years → month boundaries; larger → year boundaries.
|
|
246
|
+
* This mirrors Excel's default tick auto-selection.
|
|
247
|
+
*/
|
|
248
|
+
export function generateDateTicks(input: DateTickInput): TickResult {
|
|
249
|
+
if (!Number.isFinite(input.min) || !Number.isFinite(input.max)) {
|
|
250
|
+
return { major: [input.min], minor: [] };
|
|
251
|
+
}
|
|
252
|
+
if (input.min === input.max) {
|
|
253
|
+
return { major: [input.min], minor: [] };
|
|
254
|
+
}
|
|
255
|
+
const [min, max] = input.min < input.max
|
|
256
|
+
? [input.min, input.max]
|
|
257
|
+
: [input.max, input.min];
|
|
258
|
+
|
|
259
|
+
const unit = input.majorTimeUnit
|
|
260
|
+
?? input.baseTimeUnit
|
|
261
|
+
?? autoPickTimeUnit(max - min);
|
|
262
|
+
const step = Math.max(1, Math.floor(input.majorUnit ?? 1));
|
|
263
|
+
|
|
264
|
+
const major = stepByTimeUnit(min, max, unit, step);
|
|
265
|
+
|
|
266
|
+
const minorUnit = input.minorTimeUnit ?? unit;
|
|
267
|
+
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
|
+
: [];
|
|
273
|
+
|
|
274
|
+
return { major, minor };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function autoPickTimeUnit(rangeDays: number): TimeUnit {
|
|
278
|
+
if (rangeDays <= 62) return "days";
|
|
279
|
+
if (rangeDays <= 730) return "months";
|
|
280
|
+
return "years";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stepByTimeUnit(
|
|
284
|
+
min: number,
|
|
285
|
+
max: number,
|
|
286
|
+
unit: TimeUnit,
|
|
287
|
+
stepN: number,
|
|
288
|
+
): number[] {
|
|
289
|
+
const ticks: number[] = [];
|
|
290
|
+
if (unit === "days") {
|
|
291
|
+
const start = Math.ceil(min / stepN) * stepN;
|
|
292
|
+
for (let t = start; t <= max; t += stepN) {
|
|
293
|
+
ticks.push(t);
|
|
294
|
+
}
|
|
295
|
+
return ticks;
|
|
296
|
+
}
|
|
297
|
+
// Month / year: walk via Date to respect variable month lengths.
|
|
298
|
+
let { y, m, d } = serialToYMD(Math.ceil(min));
|
|
299
|
+
// Snap d to 1st for month/year stepping.
|
|
300
|
+
d = 1;
|
|
301
|
+
// Snap m to 1st for year stepping.
|
|
302
|
+
if (unit === "years") m = 1;
|
|
303
|
+
let serial = ymdToSerial(y, m, d);
|
|
304
|
+
if (serial < min) {
|
|
305
|
+
if (unit === "months") {
|
|
306
|
+
m += 1;
|
|
307
|
+
if (m > 12) { m = 1; y += 1; }
|
|
308
|
+
} else {
|
|
309
|
+
y += 1;
|
|
310
|
+
}
|
|
311
|
+
serial = ymdToSerial(y, m, d);
|
|
312
|
+
}
|
|
313
|
+
while (serial <= max) {
|
|
314
|
+
ticks.push(serial);
|
|
315
|
+
if (unit === "months") {
|
|
316
|
+
m += stepN;
|
|
317
|
+
while (m > 12) { m -= 12; y += 1; }
|
|
318
|
+
} else {
|
|
319
|
+
y += stepN;
|
|
320
|
+
}
|
|
321
|
+
serial = ymdToSerial(y, m, d);
|
|
322
|
+
}
|
|
323
|
+
return ticks;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert an Excel serial date to a {y, m, d} triple. Excel's epoch is
|
|
328
|
+
* 1899-12-30 (treating the bogus 1900-02-29 leap-day as day 60).
|
|
329
|
+
*/
|
|
330
|
+
function serialToYMD(serial: number): { y: number; m: number; d: number } {
|
|
331
|
+
// 1899-12-30 baseline → offset of 25569 aligns with Unix epoch.
|
|
332
|
+
const ms = (serial - 25569) * 86_400_000;
|
|
333
|
+
const d = new Date(ms);
|
|
334
|
+
return {
|
|
335
|
+
y: d.getUTCFullYear(),
|
|
336
|
+
m: d.getUTCMonth() + 1,
|
|
337
|
+
d: d.getUTCDate(),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function ymdToSerial(y: number, m: number, d: number): number {
|
|
342
|
+
const ms = Date.UTC(y, m - 1, d);
|
|
343
|
+
return ms / 86_400_000 + 25569;
|
|
344
|
+
}
|