@nowline/layout 0.5.1 → 0.6.0

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 (39) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/layout-context.d.ts +8 -0
  6. package/dist/layout-context.d.ts.map +1 -1
  7. package/dist/layout.d.ts.map +1 -1
  8. package/dist/layout.js +68 -47
  9. package/dist/layout.js.map +1 -1
  10. package/dist/nodes/group-node.d.ts.map +1 -1
  11. package/dist/nodes/group-node.js +2 -1
  12. package/dist/nodes/group-node.js.map +1 -1
  13. package/dist/nodes/include-node.d.ts.map +1 -1
  14. package/dist/nodes/include-node.js +4 -0
  15. package/dist/nodes/include-node.js.map +1 -1
  16. package/dist/nodes/parallel-node.d.ts.map +1 -1
  17. package/dist/nodes/parallel-node.js +2 -1
  18. package/dist/nodes/parallel-node.js.map +1 -1
  19. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  20. package/dist/nodes/roadmap-node.js +4 -0
  21. package/dist/nodes/roadmap-node.js.map +1 -1
  22. package/dist/resolve-today.d.ts +90 -0
  23. package/dist/resolve-today.d.ts.map +1 -0
  24. package/dist/resolve-today.js +197 -0
  25. package/dist/resolve-today.js.map +1 -0
  26. package/dist/schedule.d.ts +38 -0
  27. package/dist/schedule.d.ts.map +1 -0
  28. package/dist/schedule.js +158 -0
  29. package/dist/schedule.js.map +1 -0
  30. package/package.json +2 -2
  31. package/src/index.ts +10 -0
  32. package/src/layout-context.ts +8 -0
  33. package/src/layout.ts +69 -46
  34. package/src/nodes/group-node.ts +2 -1
  35. package/src/nodes/include-node.ts +4 -0
  36. package/src/nodes/parallel-node.ts +2 -1
  37. package/src/nodes/roadmap-node.ts +4 -0
  38. package/src/resolve-today.ts +266 -0
  39. package/src/schedule.ts +220 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowline/layout",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Nowline layout engine — AST → positioned model for rendering",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "d3-scale": "^4.0.2",
26
26
  "d3-time": "^3.1.0",
27
- "@nowline/core": "0.5.1"
27
+ "@nowline/core": "0.6.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/d3-scale": "^4.0.9",
package/src/index.ts CHANGED
@@ -65,6 +65,16 @@ export {
65
65
  } from './item-bar-geometry.js';
66
66
  export type { LayoutOptions, LayoutResult } from './layout.js';
67
67
  export { layoutRoadmap } from './layout.js';
68
+ export {
69
+ civilDateInZone,
70
+ type NormalizedZone,
71
+ normalizeZone,
72
+ type ResolveTodayOptions,
73
+ resolveToday,
74
+ TimezoneError,
75
+ } from './resolve-today.js';
76
+ export type { RoadmapSchedule, ScheduledItem, ScheduleOptions } from './schedule.js';
77
+ export { scheduleRoadmap } from './schedule.js';
68
78
  export {
69
79
  darkTheme,
70
80
  grayscaleTheme,
@@ -145,6 +145,14 @@ export interface LayoutContext {
145
145
  */
146
146
  swimlaneBottomY: number;
147
147
  chartRightX: number;
148
+ /**
149
+ * Monotonic counters for id-less `parallel` / `group` blocks. Each
150
+ * swimlane-loop pass resets both to zero; blocks without an explicit
151
+ * `name` receive internal flow-key handles (`parallel-1`, `group-1`, …).
152
+ * These handles are not referenceable from `after:` / `before:` / `on:`.
153
+ */
154
+ nextParallelId: number;
155
+ nextGroupId: number;
148
156
  }
149
157
 
150
158
  /**
package/src/layout.ts CHANGED
@@ -674,55 +674,60 @@ function sequenceItem(
674
674
  const decorationsRightX = Math.max(itemBox.x + itemBox.width, spillCursor);
675
675
 
676
676
  const id = node.name;
677
+ // Reference-target edges resolve `after:` / `before:` lookups, so only
678
+ // EXPLICIT ids register here — an id-less item never becomes a
679
+ // referenceable target. Entity edges live in LOGICAL space so chained
680
+ // items / `after:` references sit on the column boundary, not on the
681
+ // visually inset bar edge. The visible 12 px gutter between bars then
682
+ // becomes a clean attach corridor.
677
683
  if (id) {
678
- // Entity edges live in LOGICAL space so chained items / `after:`
679
- // references / dependency-arrow attach points sit on the column
680
- // boundary, not on the visually inset bar edge. The visible 12 px
681
- // gutter between bars then becomes a clean attach corridor.
682
684
  ctx.entityLeftEdges.set(id, logicalLeft);
683
685
  ctx.entityRightEdges.set(id, logicalRight);
684
- ctx.entityMidpoints.set(id, {
685
- x: (logicalLeft + logicalRight) / 2,
686
- y: itemBox.y + itemBox.height / 2,
687
- });
688
- // Visual edges — where dependency arrows actually attach. These
689
- // sit ITEM_INSET_PX inside the column boundaries so the arrows
690
- // emerge from the painted bar edge instead of the inter-column
691
- // gutter. See LayoutContext.entityVisualLeftX/RightX.
692
- ctx.entityVisualLeftX.set(id, itemBox.x);
693
- ctx.entityVisualRightX.set(id, itemBox.x + itemBox.width);
694
- // Dependency-arrow source point. Default = the bar's right
695
- // edge at row midpoint. When the caption spills past the
696
- // bar's right edge (`textSpills`), the spilled title /
697
- // meta occupy the area immediately right of the bar at
698
- // row midline. Keep X on the bar's right edge so the
699
- // arrow visually leaves the bar's side, but drop Y to the
700
- // vertical center of the bottom progress strip so the
701
- // arrow runs UNDERNEATH the spilled text rather than
702
- // through it. Mirrors the slack-arrow attach below.
703
- const arrowSource: Point = textSpills
704
- ? {
705
- x: itemBox.x + itemBox.width,
706
- y: itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2,
707
- }
708
- : {
709
- x: itemBox.x + itemBox.width,
710
- y: itemBox.y + itemBox.height / 2,
711
- };
712
- ctx.itemArrowSource.set(id, arrowSource);
713
- ctx.itemFlowKey.set(id, ctx.currentFlowKey);
714
- // Slack-arrow attach Y. Defaults to the bar's row midpoint; when
715
- // the caption spills past the bar's right edge, drop to the
716
- // progress-strip's vertical center so the arrow aligns with the
717
- // bottom-edge progress bar instead of running through the
718
- // adjacent title/meta text. The `/ 2` keeps the attach point on
719
- // the strip's vertical center if `PROGRESS_STRIP_HEIGHT_PX` is
720
- // ever bumped.
721
- const slackAttachY = textSpills
722
- ? itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2
723
- : itemBox.y + itemBox.height / 2;
724
- ctx.itemSlackAttachY.set(id, slackAttachY);
725
686
  }
687
+ // Drawing / flow maps key on a registration handle EVERY item has: the
688
+ // explicit id when present, else a synthetic, non-referenceable handle
689
+ // (see `syntheticItemKey`). This lets a title-only item register its own
690
+ // dependency-arrow target geometry and join flow-key dedup without
691
+ // entering the human-referenceable namespace above.
692
+ const drawKey = id ?? syntheticItemKey(node);
693
+ ctx.entityMidpoints.set(drawKey, {
694
+ x: (logicalLeft + logicalRight) / 2,
695
+ y: itemBox.y + itemBox.height / 2,
696
+ });
697
+ // Visual edges — where dependency arrows actually attach. These sit
698
+ // ITEM_INSET_PX inside the column boundaries so the arrows emerge from
699
+ // the painted bar edge instead of the inter-column gutter. See
700
+ // LayoutContext.entityVisualLeftX/RightX.
701
+ ctx.entityVisualLeftX.set(drawKey, itemBox.x);
702
+ ctx.entityVisualRightX.set(drawKey, itemBox.x + itemBox.width);
703
+ // Dependency-arrow source point. Default = the bar's right edge at row
704
+ // midpoint. When the caption spills past the bar's right edge
705
+ // (`textSpills`), the spilled title / meta occupy the area immediately
706
+ // right of the bar at row midline. Keep X on the bar's right edge so the
707
+ // arrow visually leaves the bar's side, but drop Y to the vertical center
708
+ // of the bottom progress strip so the arrow runs UNDERNEATH the spilled
709
+ // text rather than through it. Mirrors the slack-arrow attach below.
710
+ const arrowSource: Point = textSpills
711
+ ? {
712
+ x: itemBox.x + itemBox.width,
713
+ y: itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2,
714
+ }
715
+ : {
716
+ x: itemBox.x + itemBox.width,
717
+ y: itemBox.y + itemBox.height / 2,
718
+ };
719
+ ctx.itemArrowSource.set(drawKey, arrowSource);
720
+ ctx.itemFlowKey.set(drawKey, ctx.currentFlowKey);
721
+ // Slack-arrow attach Y. Defaults to the bar's row midpoint; when the
722
+ // caption spills past the bar's right edge, drop to the progress-strip's
723
+ // vertical center so the arrow aligns with the bottom-edge progress bar
724
+ // instead of running through the adjacent title/meta text. The `/ 2`
725
+ // keeps the attach point on the strip's vertical center if
726
+ // `PROGRESS_STRIP_HEIGHT_PX` is ever bumped.
727
+ const slackAttachY = textSpills
728
+ ? itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2
729
+ : itemBox.y + itemBox.height / 2;
730
+ ctx.itemSlackAttachY.set(drawKey, slackAttachY);
726
731
 
727
732
  cursor.x = logicalRight;
728
733
  cursor.maxX = Math.max(cursor.maxX, cursor.x);
@@ -816,6 +821,22 @@ function estimateTextWidth(text: string, fontSize: number): number {
816
821
  return text.length * fontSize * 0.58;
817
822
  }
818
823
 
824
+ /**
825
+ * Internal drawing handle for an id-less item. Title-only items have no
826
+ * `node.name`, yet they still need a stable key to register their own
827
+ * dependency-arrow target geometry and join flow-key dedup. The handle is
828
+ * derived from the item's source position so it is deterministic and
829
+ * byte-stable, and the `#item@line:col` form cannot be produced by the
830
+ * grammar's id rule — so `after:` / `before:` / `on:` can never resolve to it
831
+ * (an id-less item stays non-referenceable; declare an explicit id to
832
+ * reference it). Distinct items always start at distinct positions, so the
833
+ * handle is unique within a file.
834
+ */
835
+ function syntheticItemKey(node: ItemDeclaration): string {
836
+ const start = node.$cstNode?.range.start;
837
+ return `#item@${start ? start.line + 1 : 0}:${start ? start.character + 1 : 0}`;
838
+ }
839
+
819
840
  /**
820
841
  * Compute the extra vertical px the bar grows when its spilled chip
821
842
  * column would otherwise extend below the (single-row) bottom. The
@@ -1482,7 +1503,9 @@ function collectItems(swimlanes: SwimlaneDeclaration[]): Map<string, ItemDeclara
1482
1503
  const out = new Map<string, ItemDeclaration>();
1483
1504
  const walk = (node: ItemDeclaration | GroupBlock | ParallelBlock): void => {
1484
1505
  if (isItemDeclaration(node)) {
1485
- if (node.name) out.set(node.name, node);
1506
+ // Title-only items register under a synthetic, non-referenceable
1507
+ // handle so they still appear as dependency-edge targets.
1508
+ out.set(node.name ?? syntheticItemKey(node), node);
1486
1509
  return;
1487
1510
  }
1488
1511
  if (isParallelBlock(node)) {
@@ -90,7 +90,8 @@ export class GroupNode {
90
90
  // form one sub-flow under the parent's flow path. See
91
91
  // `LayoutContext.currentFlowKey`.
92
92
  const previousFlowKey = ctx.currentFlowKey;
93
- ctx.currentFlowKey = `${previousFlowKey}/group:${node.name ?? 'g'}`;
93
+ ctx.nextGroupId += 1;
94
+ ctx.currentFlowKey = `${previousFlowKey}/group:${node.name ?? `group-${ctx.nextGroupId}`}`;
94
95
  // Mirrors `renderGroup`'s `hasFill` decision so the painted box
95
96
  // and the layout's reservation agree on whether a chiclet exists.
96
97
  const hasChiclet = style.bg !== 'none' && style.bg !== '#ffffff' && Boolean(title);
@@ -110,12 +110,16 @@ export function buildIncludeRegions(
110
110
  chartBottomY: innerStartY,
111
111
  swimlaneBottomY: innerStartY,
112
112
  chartRightX: ctx.chartRightX,
113
+ nextParallelId: 0,
114
+ nextGroupId: 0,
113
115
  };
114
116
  const nestedSwimlanes: PositionedSwimlane[] = [];
115
117
  let cursorY = innerStartY;
116
118
  let bandIndex = 0;
117
119
  let nestedContentRightX = childCtx.timeline.originX;
118
120
  for (const lane of region.content.swimlanes.values()) {
121
+ childCtx.nextParallelId = 0;
122
+ childCtx.nextGroupId = 0;
119
123
  const { positioned, usedHeight, usedRightX } = new SwimlaneNode(
120
124
  { lane, bandIndex },
121
125
  deps,
@@ -55,7 +55,8 @@ export class ParallelNode {
55
55
  // sub-tracks therefore stay in different flows for milestone
56
56
  // slack-arrow dedupe.
57
57
  const previousFlowKey = ctx.currentFlowKey;
58
- const parId = node.name ?? 'p';
58
+ ctx.nextParallelId += 1;
59
+ const parId = node.name ?? `parallel-${ctx.nextParallelId}`;
59
60
 
60
61
  let childIndex = 0;
61
62
  for (const child of node.content) {
@@ -381,6 +381,8 @@ export class RoadmapNode {
381
381
  chartBottomY: 0,
382
382
  swimlaneBottomY: 0,
383
383
  chartRightX: finalChartRightX,
384
+ nextParallelId: 0,
385
+ nextGroupId: 0,
384
386
  };
385
387
 
386
388
  // Seed the entity-edge maps from the date-pinned pack so item
@@ -426,6 +428,8 @@ export class RoadmapNode {
426
428
  nextY: number;
427
429
  maxRightX: number;
428
430
  } => {
431
+ ctx.nextParallelId = 0;
432
+ ctx.nextGroupId = 0;
429
433
  const out: PositionedSwimlane[] = [];
430
434
  let cursorY = ctx.chartTopY;
431
435
  let bIndex = 0;
@@ -0,0 +1,266 @@
1
+ // resolveToday — shared timezone-aware now-line date resolver.
2
+ //
3
+ // Every surface (CLI, VS Code extension, embed, browser SPA) calls this helper
4
+ // to decide which civil calendar date to mark as "now" on the chart. Layout
5
+ // itself stays a pure function of its inputs and never calls this — callers
6
+ // resolve the date first, then pass `today: Date` into `layoutRoadmap`.
7
+ //
8
+ // Design contract (see plan: timezone-aware now-line resolution):
9
+ //
10
+ // - "Today" is the viewer's local civil date by default. UTC is opt-in via
11
+ // --timezone UTC. This matches iCalendar floating-date semantics: authored
12
+ // dates in .nowline files are already floating (zone-free); the only thing
13
+ // that benefits from a timezone is the clock-based "today" default.
14
+ //
15
+ // - Dates on the axis, item bars, milestones, and anchors are floating and
16
+ // are NEVER affected by --timezone. Only the now-line moves.
17
+ //
18
+ // - An explicit --now value always wins over --timezone. When --now carries
19
+ // an embedded ISO 8601 offset/Z, that offset determines the civil day; the
20
+ // --timezone is ignored. --timezone only governs the clock-based default
21
+ // (when --now is omitted).
22
+
23
+ // ---- Types and errors -------------------------------------------------------
24
+
25
+ /**
26
+ * Structured representation of a normalised --timezone / timezone option.
27
+ * Produced by {@link normalizeZone}; consumed by {@link civilDateInZone}.
28
+ */
29
+ export type NormalizedZone =
30
+ | { kind: 'local' }
31
+ | { kind: 'utc' }
32
+ | { kind: 'offset'; ms: number }
33
+ | { kind: 'iana'; name: string };
34
+
35
+ /**
36
+ * Thrown by {@link normalizeZone} when the raw timezone string is not
37
+ * recognised. CLI surfaces catch this and re-throw as `CliError`.
38
+ */
39
+ export class TimezoneError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = 'TimezoneError';
43
+ }
44
+ }
45
+
46
+ // ---- Zone normalisation -----------------------------------------------------
47
+
48
+ /**
49
+ * Normalise a raw --timezone / timezone option string into a {@link NormalizedZone}.
50
+ *
51
+ * Accepted forms (case-insensitive for keywords):
52
+ * - empty / `'local'` → host/viewer local zone (default)
53
+ * - `'UTC'` → UTC
54
+ * - ISO 8601 offset: `Z`, `±HH`, `±HH:MM`, `±HHMM` → fixed offset (DST-naive)
55
+ * - IANA name e.g. `'America/Los_Angeles'` → validated via Intl
56
+ *
57
+ * Throws {@link TimezoneError} for strings not recognised by `Intl.DateTimeFormat`.
58
+ * Ambiguous abbreviations (`PST`, `IST`, …) are rejected on platforms where `Intl`
59
+ * rejects them (Linux ICU typically rejects them); on macOS Node 26 they may be
60
+ * accepted and silently mapped to a platform-specific IANA zone — a known platform
61
+ * caveat. Use explicit IANA names for portable results.
62
+ * and any unrecognised string. (`GMT` and `Etc/GMT±N` are accepted by `Intl`
63
+ * but are undocumented; `Etc/GMT±N` has an inverted sign convention.)
64
+ */
65
+ export function normalizeZone(raw: string | undefined): NormalizedZone {
66
+ if (!raw || raw.toLowerCase() === 'local') return { kind: 'local' };
67
+ if (raw.toUpperCase() === 'UTC') return { kind: 'utc' };
68
+
69
+ // ISO 8601 offset shorthand: Z, ±HH, ±HH:MM, ±HHMM
70
+ if (raw === 'Z') return { kind: 'offset', ms: 0 };
71
+ const offsetMatch = /^([+-])(\d{2})(?::?(\d{2}))?$/.exec(raw);
72
+ if (offsetMatch) {
73
+ const sign = offsetMatch[1] === '+' ? 1 : -1;
74
+ const hours = parseInt(offsetMatch[2], 10);
75
+ const minutes = offsetMatch[3] !== undefined ? parseInt(offsetMatch[3], 10) : 0;
76
+ if (hours > 23 || minutes > 59) {
77
+ throw new TimezoneError(
78
+ `nowline: invalid timezone offset "${raw}". Hours must be 0–23 and minutes 0–59.`,
79
+ );
80
+ }
81
+ return { kind: 'offset', ms: sign * (hours * 60 + minutes) * 60_000 };
82
+ }
83
+
84
+ // IANA name — validate with Intl; ambiguous abbreviations (PST, IST, …)
85
+ // are not valid IANA names and will throw here.
86
+ try {
87
+ const canonical = Intl.DateTimeFormat(undefined, { timeZone: raw }).resolvedOptions()
88
+ .timeZone;
89
+ return { kind: 'iana', name: canonical };
90
+ } catch {
91
+ throw new TimezoneError(
92
+ `nowline: unrecognised timezone "${raw}". ` +
93
+ `Use "local", "UTC", an ISO 8601 offset (e.g. "+05:30", "-07:00", "Z"), ` +
94
+ `or an IANA timezone name (e.g. "America/Los_Angeles", "Asia/Kolkata").`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // ---- Civil-date extraction --------------------------------------------------
100
+
101
+ /**
102
+ * Extract the civil `YYYY-MM-DD` date of `instant` in the given zone and
103
+ * return it as a UTC-midnight `Date` (matching the layout engine's convention
104
+ * for authored dates).
105
+ */
106
+ export function civilDateInZone(instant: Date, zone: NormalizedZone): Date {
107
+ switch (zone.kind) {
108
+ case 'local': {
109
+ // JS local time — getFullYear/Month/Date already reflect the host zone.
110
+ return new Date(Date.UTC(instant.getFullYear(), instant.getMonth(), instant.getDate()));
111
+ }
112
+ case 'utc': {
113
+ return new Date(
114
+ Date.UTC(instant.getUTCFullYear(), instant.getUTCMonth(), instant.getUTCDate()),
115
+ );
116
+ }
117
+ case 'offset': {
118
+ // Shift the instant by the fixed offset, then read UTC components.
119
+ // No Intl dependency — portable to older embed browsers.
120
+ const shifted = new Date(instant.getTime() + zone.ms);
121
+ return new Date(
122
+ Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()),
123
+ );
124
+ }
125
+ case 'iana': {
126
+ // Use en-CA locale so formatToParts gives YYYY-MM-DD numeric fields.
127
+ const parts = new Intl.DateTimeFormat('en-CA', {
128
+ timeZone: zone.name,
129
+ year: 'numeric',
130
+ month: '2-digit',
131
+ day: '2-digit',
132
+ }).formatToParts(instant);
133
+ let y = 0;
134
+ let m = 0;
135
+ let d = 0;
136
+ for (const p of parts) {
137
+ if (p.type === 'year') y = parseInt(p.value, 10);
138
+ else if (p.type === 'month') m = parseInt(p.value, 10) - 1;
139
+ else if (p.type === 'day') d = parseInt(p.value, 10);
140
+ }
141
+ return new Date(Date.UTC(y, m, d));
142
+ }
143
+ }
144
+ }
145
+
146
+ // ---- Now-value parsing ------------------------------------------------------
147
+
148
+ // Bare YYYY-MM-DD (floating date, zone-independent)
149
+ const BARE_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
150
+
151
+ // ISO 8601 instant with explicit Z or ±HH / ±HH:MM / ±HHMM offset.
152
+ // Captures: [1] datetime part, [2] sign or Z, [3] hours, [4] minutes (optional)
153
+ const ISO_WITH_OFFSET_RE =
154
+ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z|([+-])(\d{2})(?::?(\d{2}))?)$/;
155
+
156
+ // ISO 8601 with time but no offset (floating local date-time).
157
+ // Captures: [1] year, [2] month, [3] day
158
+ const ISO_FLOATING_RE = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
159
+
160
+ // ---- Public API -------------------------------------------------------------
161
+
162
+ export interface ResolveTodayOptions {
163
+ /**
164
+ * Raw now value: the CLI's `--now` string, a pre-resolved `Date`, `null`
165
+ * to suppress the now-line, or `undefined` to use the clock default.
166
+ *
167
+ * String forms accepted:
168
+ * - `'-'` → suppress (same as `null`)
169
+ * - `'YYYY-MM-DD'` → floating date; zone ignored
170
+ * - ISO 8601 + Z/offset → embedded offset wins; zone ignored
171
+ * - ISO 8601 without offset → floating; written date part used; zone ignored
172
+ */
173
+ now?: string | Date | null;
174
+ /**
175
+ * Effective timezone for the clock-based default. Produced by
176
+ * {@link normalizeZone}. Defaults to `{ kind: 'local' }` (host/viewer zone).
177
+ * Only consulted when `now` is omitted.
178
+ */
179
+ zone?: NormalizedZone;
180
+ /**
181
+ * Clock factory — injected for tests; defaults to `() => new Date()`.
182
+ * Only called when `now` is omitted.
183
+ */
184
+ clock?: () => Date;
185
+ }
186
+
187
+ /**
188
+ * Resolve the now-line anchor date.
189
+ *
190
+ * Returns a UTC-midnight `Date` (the civil day at which to draw the red
191
+ * now-line), or `undefined` to suppress the now-line entirely.
192
+ *
193
+ * Precedence (from highest to lowest):
194
+ * 1. `now === '-'` / `null` → `undefined` (suppress)
195
+ * 2. `now` is a `Date` object → returned as-is (caller must supply UTC midnight)
196
+ * 3. `now` bare `YYYY-MM-DD` → floating; `Date.UTC(y, m, d)` regardless of zone
197
+ * 4. `now` ISO 8601 + Z/offset → embedded offset wins; zone ignored
198
+ * 5. `now` ISO 8601 without offset→ floating; written date part; zone ignored
199
+ * 6. `now` omitted → civil date of `clock()` in effective `zone`
200
+ *
201
+ * The `zone` is ONLY consulted for case 6 (clock-based default). Authored
202
+ * dates — item bars, milestones, anchors, the axis ticks — are floating and
203
+ * are never affected by this function.
204
+ */
205
+ export function resolveToday(opts: ResolveTodayOptions = {}): Date | undefined {
206
+ const zone: NormalizedZone = opts.zone ?? { kind: 'local' };
207
+ const clock = opts.clock ?? (() => new Date());
208
+ const now = opts.now;
209
+
210
+ // 1. Suppress sentinel
211
+ if (now === '-' || now === null) return undefined;
212
+
213
+ // 2. Pre-resolved Date — pass through as-is
214
+ if (now instanceof Date) return now;
215
+
216
+ if (now !== undefined) {
217
+ // 3. Bare YYYY-MM-DD — floating; zone ignored
218
+ const bareMatch = BARE_DATE_RE.exec(now);
219
+ if (bareMatch) {
220
+ return new Date(
221
+ Date.UTC(
222
+ parseInt(bareMatch[1], 10),
223
+ parseInt(bareMatch[2], 10) - 1,
224
+ parseInt(bareMatch[3], 10),
225
+ ),
226
+ );
227
+ }
228
+
229
+ // 4. ISO 8601 instant with explicit Z or offset — embedded offset WINS
230
+ const withOffset = ISO_WITH_OFFSET_RE.exec(now);
231
+ if (withOffset) {
232
+ const instant = new Date(now);
233
+ if (!Number.isNaN(instant.getTime())) {
234
+ const suffix = withOffset[2]; // 'Z' or '±HH:MM'
235
+ if (suffix === 'Z') {
236
+ return civilDateInZone(instant, { kind: 'utc' });
237
+ }
238
+ // Fixed offset embedded in the string
239
+ const sign = withOffset[3] === '+' ? 1 : -1;
240
+ const hh = parseInt(withOffset[4], 10);
241
+ const mm = withOffset[5] !== undefined ? parseInt(withOffset[5], 10) : 0;
242
+ const offsetMs = sign * (hh * 60 + mm) * 60_000;
243
+ return civilDateInZone(instant, { kind: 'offset', ms: offsetMs });
244
+ }
245
+ }
246
+
247
+ // 5. ISO 8601 without offset — floating; use written date part; zone ignored
248
+ const floating = ISO_FLOATING_RE.exec(now);
249
+ if (floating) {
250
+ return new Date(
251
+ Date.UTC(
252
+ parseInt(floating[1], 10),
253
+ parseInt(floating[2], 10) - 1,
254
+ parseInt(floating[3], 10),
255
+ ),
256
+ );
257
+ }
258
+
259
+ // Unrecognised string — caller should have validated before calling.
260
+ // Return undefined so a bad value doesn't crash the render pipeline.
261
+ return undefined;
262
+ }
263
+
264
+ // 6. No now given — civil date of clock() in the effective zone
265
+ return civilDateInZone(clock(), zone);
266
+ }