@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.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout-context.d.ts +8 -0
- package/dist/layout-context.d.ts.map +1 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +68 -47
- package/dist/layout.js.map +1 -1
- package/dist/nodes/group-node.d.ts.map +1 -1
- package/dist/nodes/group-node.js +2 -1
- package/dist/nodes/group-node.js.map +1 -1
- package/dist/nodes/include-node.d.ts.map +1 -1
- package/dist/nodes/include-node.js +4 -0
- package/dist/nodes/include-node.js.map +1 -1
- package/dist/nodes/parallel-node.d.ts.map +1 -1
- package/dist/nodes/parallel-node.js +2 -1
- package/dist/nodes/parallel-node.js.map +1 -1
- package/dist/nodes/roadmap-node.d.ts.map +1 -1
- package/dist/nodes/roadmap-node.js +4 -0
- package/dist/nodes/roadmap-node.js.map +1 -1
- package/dist/resolve-today.d.ts +90 -0
- package/dist/resolve-today.d.ts.map +1 -0
- package/dist/resolve-today.js +197 -0
- package/dist/resolve-today.js.map +1 -0
- package/dist/schedule.d.ts +38 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +158 -0
- package/dist/schedule.js.map +1 -0
- package/package.json +2 -2
- package/src/index.ts +10 -0
- package/src/layout-context.ts +8 -0
- package/src/layout.ts +69 -46
- package/src/nodes/group-node.ts +2 -1
- package/src/nodes/include-node.ts +4 -0
- package/src/nodes/parallel-node.ts +2 -1
- package/src/nodes/roadmap-node.ts +4 -0
- package/src/resolve-today.ts +266 -0
- package/src/schedule.ts +220 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowline/layout",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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,
|
package/src/layout-context.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/nodes/group-node.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|