@nowline/layout 0.2.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 (183) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +103 -0
  3. package/dist/band-scale.d.ts +56 -0
  4. package/dist/band-scale.d.ts.map +1 -0
  5. package/dist/band-scale.js +86 -0
  6. package/dist/band-scale.js.map +1 -0
  7. package/dist/calendar.d.ts +79 -0
  8. package/dist/calendar.d.ts.map +1 -0
  9. package/dist/calendar.js +210 -0
  10. package/dist/calendar.js.map +1 -0
  11. package/dist/capacity.d.ts +72 -0
  12. package/dist/capacity.d.ts.map +1 -0
  13. package/dist/capacity.js +163 -0
  14. package/dist/capacity.js.map +1 -0
  15. package/dist/dsl-utils.d.ts +5 -0
  16. package/dist/dsl-utils.d.ts.map +1 -0
  17. package/dist/dsl-utils.js +28 -0
  18. package/dist/dsl-utils.js.map +1 -0
  19. package/dist/edge-routing.d.ts +89 -0
  20. package/dist/edge-routing.d.ts.map +1 -0
  21. package/dist/edge-routing.js +435 -0
  22. package/dist/edge-routing.js.map +1 -0
  23. package/dist/frame-tab-geometry.d.ts +78 -0
  24. package/dist/frame-tab-geometry.d.ts.map +1 -0
  25. package/dist/frame-tab-geometry.js +115 -0
  26. package/dist/frame-tab-geometry.js.map +1 -0
  27. package/dist/header-card-geometry.d.ts +29 -0
  28. package/dist/header-card-geometry.d.ts.map +1 -0
  29. package/dist/header-card-geometry.js +41 -0
  30. package/dist/header-card-geometry.js.map +1 -0
  31. package/dist/i18n.d.ts +48 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +114 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/include-chrome-geometry.d.ts +86 -0
  36. package/dist/include-chrome-geometry.d.ts.map +1 -0
  37. package/dist/include-chrome-geometry.js +104 -0
  38. package/dist/include-chrome-geometry.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +10 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/item-bar-geometry.d.ts +127 -0
  44. package/dist/item-bar-geometry.d.ts.map +1 -0
  45. package/dist/item-bar-geometry.js +173 -0
  46. package/dist/item-bar-geometry.js.map +1 -0
  47. package/dist/lane-utilization.d.ts +90 -0
  48. package/dist/lane-utilization.d.ts.map +1 -0
  49. package/dist/lane-utilization.js +206 -0
  50. package/dist/lane-utilization.js.map +1 -0
  51. package/dist/layout-context.d.ts +143 -0
  52. package/dist/layout-context.d.ts.map +1 -0
  53. package/dist/layout-context.js +8 -0
  54. package/dist/layout-context.js.map +1 -0
  55. package/dist/layout.d.ts +18 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +1213 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/nodes/anchor-node.d.ts +16 -0
  60. package/dist/nodes/anchor-node.d.ts.map +1 -0
  61. package/dist/nodes/anchor-node.js +68 -0
  62. package/dist/nodes/anchor-node.js.map +1 -0
  63. package/dist/nodes/footnote-node.d.ts +10 -0
  64. package/dist/nodes/footnote-node.d.ts.map +1 -0
  65. package/dist/nodes/footnote-node.js +41 -0
  66. package/dist/nodes/footnote-node.js.map +1 -0
  67. package/dist/nodes/group-node.d.ts +29 -0
  68. package/dist/nodes/group-node.d.ts.map +1 -0
  69. package/dist/nodes/group-node.js +187 -0
  70. package/dist/nodes/group-node.js.map +1 -0
  71. package/dist/nodes/include-node.d.ts +16 -0
  72. package/dist/nodes/include-node.d.ts.map +1 -0
  73. package/dist/nodes/include-node.js +117 -0
  74. package/dist/nodes/include-node.js.map +1 -0
  75. package/dist/nodes/item-node.d.ts +51 -0
  76. package/dist/nodes/item-node.d.ts.map +1 -0
  77. package/dist/nodes/item-node.js +108 -0
  78. package/dist/nodes/item-node.js.map +1 -0
  79. package/dist/nodes/marker-geometry.d.ts +22 -0
  80. package/dist/nodes/marker-geometry.d.ts.map +1 -0
  81. package/dist/nodes/marker-geometry.js +38 -0
  82. package/dist/nodes/marker-geometry.js.map +1 -0
  83. package/dist/nodes/milestone-node.d.ts +48 -0
  84. package/dist/nodes/milestone-node.d.ts.map +1 -0
  85. package/dist/nodes/milestone-node.js +210 -0
  86. package/dist/nodes/milestone-node.js.map +1 -0
  87. package/dist/nodes/parallel-node.d.ts +21 -0
  88. package/dist/nodes/parallel-node.d.ts.map +1 -0
  89. package/dist/nodes/parallel-node.js +80 -0
  90. package/dist/nodes/parallel-node.js.map +1 -0
  91. package/dist/nodes/roadmap-node.d.ts +76 -0
  92. package/dist/nodes/roadmap-node.d.ts.map +1 -0
  93. package/dist/nodes/roadmap-node.js +788 -0
  94. package/dist/nodes/roadmap-node.js.map +1 -0
  95. package/dist/nodes/swimlane-node.d.ts +38 -0
  96. package/dist/nodes/swimlane-node.d.ts.map +1 -0
  97. package/dist/nodes/swimlane-node.js +308 -0
  98. package/dist/nodes/swimlane-node.js.map +1 -0
  99. package/dist/renderable.d.ts +44 -0
  100. package/dist/renderable.d.ts.map +1 -0
  101. package/dist/renderable.js +21 -0
  102. package/dist/renderable.js.map +1 -0
  103. package/dist/row-packer.d.ts +125 -0
  104. package/dist/row-packer.d.ts.map +1 -0
  105. package/dist/row-packer.js +169 -0
  106. package/dist/row-packer.js.map +1 -0
  107. package/dist/style-resolution.d.ts +14 -0
  108. package/dist/style-resolution.d.ts.map +1 -0
  109. package/dist/style-resolution.js +191 -0
  110. package/dist/style-resolution.js.map +1 -0
  111. package/dist/themes/dark.d.ts +4 -0
  112. package/dist/themes/dark.d.ts.map +1 -0
  113. package/dist/themes/dark.js +241 -0
  114. package/dist/themes/dark.js.map +1 -0
  115. package/dist/themes/index.d.ts +15 -0
  116. package/dist/themes/index.d.ts.map +1 -0
  117. package/dist/themes/index.js +30 -0
  118. package/dist/themes/index.js.map +1 -0
  119. package/dist/themes/light.d.ts +4 -0
  120. package/dist/themes/light.d.ts.map +1 -0
  121. package/dist/themes/light.js +248 -0
  122. package/dist/themes/light.js.map +1 -0
  123. package/dist/themes/shape.d.ts +194 -0
  124. package/dist/themes/shape.d.ts.map +1 -0
  125. package/dist/themes/shape.js +6 -0
  126. package/dist/themes/shape.js.map +1 -0
  127. package/dist/themes/shared.d.ts +145 -0
  128. package/dist/themes/shared.d.ts.map +1 -0
  129. package/dist/themes/shared.js +310 -0
  130. package/dist/themes/shared.js.map +1 -0
  131. package/dist/time-scale.d.ts +39 -0
  132. package/dist/time-scale.d.ts.map +1 -0
  133. package/dist/time-scale.js +62 -0
  134. package/dist/time-scale.js.map +1 -0
  135. package/dist/types.d.ts +483 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +6 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/view-preset.d.ts +23 -0
  140. package/dist/view-preset.d.ts.map +1 -0
  141. package/dist/view-preset.js +146 -0
  142. package/dist/view-preset.js.map +1 -0
  143. package/dist/working-calendar.d.ts +14 -0
  144. package/dist/working-calendar.d.ts.map +1 -0
  145. package/dist/working-calendar.js +74 -0
  146. package/dist/working-calendar.js.map +1 -0
  147. package/package.json +37 -0
  148. package/src/band-scale.ts +115 -0
  149. package/src/calendar.ts +244 -0
  150. package/src/capacity.ts +191 -0
  151. package/src/dsl-utils.ts +30 -0
  152. package/src/edge-routing.ts +550 -0
  153. package/src/frame-tab-geometry.ts +165 -0
  154. package/src/header-card-geometry.ts +48 -0
  155. package/src/i18n.ts +124 -0
  156. package/src/include-chrome-geometry.ts +156 -0
  157. package/src/index.ts +116 -0
  158. package/src/item-bar-geometry.ts +222 -0
  159. package/src/lane-utilization.ts +259 -0
  160. package/src/layout-context.ts +182 -0
  161. package/src/layout.ts +1446 -0
  162. package/src/nodes/anchor-node.ts +77 -0
  163. package/src/nodes/footnote-node.ts +60 -0
  164. package/src/nodes/group-node.ts +252 -0
  165. package/src/nodes/include-node.ts +168 -0
  166. package/src/nodes/item-node.ts +171 -0
  167. package/src/nodes/marker-geometry.ts +43 -0
  168. package/src/nodes/milestone-node.ts +263 -0
  169. package/src/nodes/parallel-node.ts +101 -0
  170. package/src/nodes/roadmap-node.ts +957 -0
  171. package/src/nodes/swimlane-node.ts +423 -0
  172. package/src/renderable.ts +68 -0
  173. package/src/row-packer.ts +271 -0
  174. package/src/style-resolution.ts +243 -0
  175. package/src/themes/dark.ts +244 -0
  176. package/src/themes/index.ts +36 -0
  177. package/src/themes/light.ts +251 -0
  178. package/src/themes/shape.ts +230 -0
  179. package/src/themes/shared.ts +369 -0
  180. package/src/time-scale.ts +78 -0
  181. package/src/types.ts +607 -0
  182. package/src/view-preset.ts +180 -0
  183. package/src/working-calendar.ts +91 -0
@@ -0,0 +1,230 @@
1
+ // The `Theme` interface is the single place that enumerates every role a
2
+ // theme must define. Every theme (light, dark, future custom) imports this
3
+ // and declares `const <name>Theme: Theme = { ... }`. `tsc` refuses to compile
4
+ // if any role is omitted — that's our primary drift-prevention mechanism.
5
+
6
+ import type { BorderKind, BracketKind, FontFamily, FontWeight, ShadowKind } from '../types.js';
7
+
8
+ // Per-entity DSL-style defaults. Every property from specs/dsl.md §
9
+ // Style Properties appears here so tsc enforces parity across themes.
10
+ // All color roles are concrete hex strings; `bg` may be 'none' (transparent).
11
+ export interface EntityStyle {
12
+ bg: string;
13
+ fg: string;
14
+ text: string;
15
+ border: BorderKind;
16
+ icon: string;
17
+ shadow: ShadowKind;
18
+ font: FontFamily;
19
+ weight: FontWeight;
20
+ italic: boolean;
21
+ textSize: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
22
+ padding: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
23
+ spacing: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
24
+ headerHeight: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
25
+ cornerRadius: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
26
+ bracket: BracketKind;
27
+ // Glyph used as the suffix on capacity numbers (lane badge / item suffix).
28
+ // Holds whatever the author wrote (built-in name, custom glyph id, or an
29
+ // inline Unicode literal) — interpretation is the renderer's job. Default
30
+ // is `multiplier` so unannotated capacity values render as `5×`.
31
+ capacityIcon: string;
32
+ }
33
+
34
+ export interface Theme {
35
+ name: 'light' | 'dark' | string;
36
+ // Surfaces — base colors drawn under every entity.
37
+ surface: {
38
+ page: string; // overall background
39
+ chart: string; // content area background
40
+ headerBox: string; // header/title block background
41
+ };
42
+ // Per-entity DSL defaults. Mirrors the DSL's `default <entity>` level.
43
+ entities: {
44
+ roadmap: EntityStyle;
45
+ swimlane: EntityStyle;
46
+ item: EntityStyle;
47
+ parallel: EntityStyle;
48
+ group: EntityStyle;
49
+ anchor: EntityStyle;
50
+ milestone: EntityStyle;
51
+ footnote: EntityStyle;
52
+ label: EntityStyle;
53
+ };
54
+ // Alternating swimlane band tints (even/odd index).
55
+ swimlane: {
56
+ bandEven: string;
57
+ bandOdd: string;
58
+ separator: string;
59
+ frameTabText: string;
60
+ frameTabMuted: string;
61
+ // m2.5d: tokens lifted out of the renderer's inline `theme === 'dark'`
62
+ // branches in `renderSwimlane`.
63
+ border: string;
64
+ tabFill: string;
65
+ tabStroke: string;
66
+ tabText: string;
67
+ ownerText: string;
68
+ footnoteIndicator: string;
69
+ rowTintEven: string; // alternating row tint (even rows)
70
+ rowTintOdd: string; // alternating row tint (odd rows)
71
+ // m13: tri-state lane utilization underline. Each token paints one
72
+ // classification band along the bottom edge of the lane band when
73
+ // the lane has `capacity:` and at least one item contributing load.
74
+ // See specs/rendering.md § Lane utilization underline.
75
+ utilizationOk: string; // green; load below `warn-at` (incl. zero)
76
+ utilizationWarn: string; // yellow; load in `[warn-at, over-at)`
77
+ utilizationOver: string; // red; load >= `over-at`
78
+ };
79
+ timeline: {
80
+ gridLine: string;
81
+ // Faint dotted line drawn at every tick boundary (not just majors)
82
+ // when the roadmap's resolved `minor-grid` style is `true`. A step
83
+ // lighter than `gridLine` so the major lines still dominate.
84
+ minorGridLine: string;
85
+ tickMark: string;
86
+ labelText: string;
87
+ // m2.5d: lifted from renderTimeline.
88
+ panelFill: string;
89
+ border: string;
90
+ };
91
+ // m2.5d: all renderer-side palette tokens previously inlined as
92
+ // `theme === 'dark' ? darkColor : lightColor` ternaries. Each new
93
+ // token reads from one of the existing theme objects so the
94
+ // renderer becomes pure data → SVG.
95
+ header: {
96
+ cardBorder: string;
97
+ author: string;
98
+ };
99
+ item: {
100
+ overflowX: string; // red X mark on overrun tail
101
+ linkIconFg: string; // generic link icon color
102
+ overflowTailFill: string;
103
+ overflowTailStroke: string;
104
+ overflowCaption: string; // "past <id>" caption color
105
+ };
106
+ parallel: {
107
+ bracketStroke: string;
108
+ };
109
+ anchorDiamond: {
110
+ fill: string;
111
+ stroke: string;
112
+ label: string;
113
+ cutLine: string;
114
+ };
115
+ milestoneDiamond: {
116
+ fill: string;
117
+ label: string;
118
+ cutLineNormal: string;
119
+ cutLineOverrun: string;
120
+ slack: string;
121
+ };
122
+ footnotePanel: {
123
+ fill: string;
124
+ border: string;
125
+ header: string;
126
+ title: string;
127
+ description: string;
128
+ number: string;
129
+ };
130
+ nowline: {
131
+ stroke: string;
132
+ labelText: string;
133
+ labelBg: string;
134
+ };
135
+ milestone: {
136
+ dashedInk: string; // used on floating/overrun slack arrows
137
+ overrun: string; // accent for overrun highlight
138
+ };
139
+ anchor: {
140
+ predecessorLine: string; // non-binding slack arrow color
141
+ };
142
+ dependency: {
143
+ edgeStroke: string;
144
+ overflowStroke: string;
145
+ };
146
+ footnote: {
147
+ indicatorText: string;
148
+ descriptionMuted: string;
149
+ };
150
+ includeRegion: {
151
+ border: string;
152
+ label: string;
153
+ badge: string;
154
+ // m2.5d: lifted from renderIncludeRegion.
155
+ fill: string;
156
+ tabFill: string;
157
+ tabStroke: string;
158
+ tabText: string;
159
+ badgeFill: string;
160
+ badgeStroke: string;
161
+ badgeText: string;
162
+ };
163
+ // m2.5d: lifted from renderEdge marker defs.
164
+ arrowhead: {
165
+ neutral: string;
166
+ light: string;
167
+ dark: string;
168
+ };
169
+ // Five built-in statuses plus neutral fallback for custom statuses.
170
+ status: {
171
+ done: string;
172
+ inProgress: string;
173
+ atRisk: string;
174
+ blocked: string;
175
+ planned: string;
176
+ neutral: string;
177
+ };
178
+ /**
179
+ * Upper-right status-dot colors. Two palettes — the renderer
180
+ * picks `onLight` for bars whose effective bg is light/pale and
181
+ * `onDark` for bars whose bg is dark/saturated. This lets the
182
+ * dot stay recognizably status-hued on the pale status-tint
183
+ * bars used by default AND on the saturated mid-tone bars that
184
+ * a label's `style:` ref can paint (e.g. `bg:blue` →
185
+ * `#1e88e5` in light theme, `#60a5fa` in dark theme). Both
186
+ * palettes appear in both themes — bar luminance is independent
187
+ * of overall theme, since a label can tint a bar bright or dark
188
+ * regardless of whether the chart background is light or dark.
189
+ */
190
+ statusDot: {
191
+ onLight: {
192
+ done: string;
193
+ inProgress: string;
194
+ atRisk: string;
195
+ blocked: string;
196
+ planned: string;
197
+ neutral: string;
198
+ };
199
+ onDark: {
200
+ done: string;
201
+ inProgress: string;
202
+ atRisk: string;
203
+ blocked: string;
204
+ planned: string;
205
+ neutral: string;
206
+ };
207
+ };
208
+ attribution: {
209
+ mark: string;
210
+ link: string;
211
+ };
212
+ diagnostic: {
213
+ overlayBg: string;
214
+ errorText: string;
215
+ };
216
+ }
217
+
218
+ // Named-color resolver. DSL allows a named color like `blue`, a hex, or
219
+ // `none`. Themes own the mapping from named → hex (different per theme).
220
+ export interface NamedColors {
221
+ red: string;
222
+ blue: string;
223
+ yellow: string;
224
+ green: string;
225
+ orange: string;
226
+ purple: string;
227
+ gray: string;
228
+ navy: string;
229
+ white: string;
230
+ }
@@ -0,0 +1,369 @@
1
+ // Values that are currently identical across every theme. The split is
2
+ // allowed to move: if a theme needs its own padding or shadow tuning, the
3
+ // value migrates into the Theme interface + both per-theme files. Nothing
4
+ // in the layout engine assumes a particular split.
5
+
6
+ export const SPACING_PX: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', number> = {
7
+ none: 0,
8
+ xs: 2,
9
+ sm: 4,
10
+ md: 8,
11
+ lg: 16,
12
+ xl: 24,
13
+ };
14
+
15
+ export const PADDING_PX: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', number> = {
16
+ none: 0,
17
+ xs: 4,
18
+ sm: 8,
19
+ md: 12,
20
+ lg: 20,
21
+ xl: 32,
22
+ };
23
+
24
+ export const HEADER_HEIGHT_PX: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', number> = {
25
+ none: 0,
26
+ xs: 24,
27
+ sm: 36,
28
+ md: 56,
29
+ lg: 80,
30
+ xl: 112,
31
+ };
32
+
33
+ export const TEXT_SIZE_PX: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', number> = {
34
+ none: 0,
35
+ xs: 10,
36
+ sm: 12,
37
+ md: 14,
38
+ lg: 18,
39
+ xl: 24,
40
+ };
41
+
42
+ export const CORNER_RADIUS_PX: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full', number> =
43
+ {
44
+ none: 0,
45
+ xs: 2,
46
+ sm: 4,
47
+ md: 8,
48
+ lg: 12,
49
+ xl: 20,
50
+ full: 9999,
51
+ };
52
+
53
+ export const LOGO_SIZE_PX: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl', number> = {
54
+ xs: 18,
55
+ sm: 24,
56
+ md: 36,
57
+ lg: 48,
58
+ xl: 72,
59
+ };
60
+
61
+ // Row pitch (formerly `ITEM_ROW_HEIGHT = 64`) and bar height (formerly
62
+ // `ITEM_ROW_HEIGHT - 8 = 56`) are owned by `BandScale` as of m2.5b.
63
+ // `defaultRowBand()` keeps the legacy 64/56 split byte-stable; new
64
+ // callers should consume `bandScale.step()` and `bandScale.bandwidth()`.
65
+
66
+ // Minimum item bar width so zero-duration items remain visible.
67
+ export const MIN_ITEM_WIDTH = 8;
68
+
69
+ // Height (px) of the bottom progress strip drawn along the item bar's
70
+ // bottom edge. Single source of truth used by:
71
+ // - the renderer (strip rect height + bottom-edge offset)
72
+ // - layout (label-chip Y reservation above the strip)
73
+ // - milestone slack-arrow attach Y (when a predecessor's caption
74
+ // spills past the bar's right edge, the arrow drops to the strip's
75
+ // vertical center so it visually aligns with the progress bar
76
+ // instead of running through the spilled text)
77
+ //
78
+ // Bumping this updates all three call sites consistently — the strip
79
+ // grows, chips lift, and the slack arrow stays centered on the strip.
80
+ export const PROGRESS_STRIP_HEIGHT_PX = 4;
81
+
82
+ // Timeline tick header band — a single row that holds the date labels
83
+ // (e.g. "Jan 05", "Feb 14"). Layout sizes the panel; the renderer
84
+ // paints the labels at a fixed baseline offset from the panel's top.
85
+ //
86
+ // Bumping the panel height without re-centering the baseline mis-centers
87
+ // the dates — keep both knobs together so a change to either one is
88
+ // visible at a glance.
89
+ export const TIMELINE_TICK_PANEL_HEIGHT_PX = 24;
90
+
91
+ /**
92
+ * Baseline Y of the tick-label text relative to the tick panel's top.
93
+ * Approximately vertically centers a 10 pt label in the panel.
94
+ */
95
+ export const TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX = 15;
96
+
97
+ /**
98
+ * Horizontal breathing room (px) added to a track-block's right edge
99
+ * after sequencing. Applied to `group { ... }` and `parallel { ... }`
100
+ * blocks so successive blocks don't crowd each other. Items butt up
101
+ * against their neighbors (no gutter) — only block boundaries get this
102
+ * gutter.
103
+ */
104
+ export const TRACK_BLOCK_TAIL_GUTTER_PX = 8;
105
+
106
+ // Frame-tab chiclet — the rounded label tab that overhangs a swimlane
107
+ // frame and the dashed include-region border. Both surfaces share the
108
+ // same height + label baseline so the chiclets read as siblings.
109
+ //
110
+ // The `FRAME_TAB_LABEL_BASELINE_OFFSET_PX` is tuned for a 12 pt label
111
+ // roughly vertically centered in the tab; bumping the height without
112
+ // adjusting the baseline pushes labels off-center.
113
+
114
+ /** Height (px) of the frame-tab chiclet (swimlane + include region). */
115
+ export const FRAME_TAB_HEIGHT_PX = 22;
116
+
117
+ /** Baseline Y of the chiclet label relative to the tab's top edge. */
118
+ export const FRAME_TAB_LABEL_BASELINE_OFFSET_PX = 15;
119
+
120
+ // Group title chiclet — the small label tab anchored flush in the upper-left
121
+ // corner of a styled group's bounding box. Smaller than the frame-tab so it
122
+ // reads as a "container label" rather than a sibling of the swimlane tab.
123
+ //
124
+ // The chiclet's top edge aligns with the group box's top edge and its left
125
+ // edge aligns with the box's left edge (no overhang). The group reserves
126
+ // `GROUP_TITLE_TAB_HEIGHT_PX + GROUP_TITLE_TAB_GUTTER_PX` of vertical top
127
+ // padding inside the box before the first child row begins, so the chiclet
128
+ // never overlaps content. Bumping the height without re-tuning
129
+ // `GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX` mis-centers the label.
130
+
131
+ /** Height (px) of the group-title chiclet anchored at the box's upper-left. */
132
+ export const GROUP_TITLE_TAB_HEIGHT_PX = 16;
133
+
134
+ /** Horizontal padding (px) on each side of the title text inside the chiclet. */
135
+ export const GROUP_TITLE_TAB_PAD_X_PX = 6;
136
+
137
+ /** Vertical gap (px) between the chiclet's bottom edge and the first inner row. */
138
+ export const GROUP_TITLE_TAB_GUTTER_PX = 4;
139
+
140
+ /** Baseline Y of the chiclet label relative to the chiclet's top edge.
141
+ * Tuned for a 9 pt bold glyph roughly vertically centered in a 16 px tab. */
142
+ export const GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX = 11;
143
+
144
+ /** Font size (px) of the group-title chiclet label. */
145
+ export const GROUP_TITLE_TAB_LABEL_FONT_SIZE_PX = 9;
146
+
147
+ /** Approximate per-character width factor used to size the chiclet to its
148
+ * title text. Mirrors the constant used in the renderer's pre-port code
149
+ * so the chiclet hugs short titles without clipping long ones. */
150
+ export const GROUP_TITLE_TAB_CHAR_WIDTH_PX = 5.5;
151
+
152
+ /** Padding (px) reserved on the inside-bottom of a styled group box before
153
+ * its lower stroke. Symmetric with the chiclet gutter so children breathe
154
+ * on both ends. */
155
+ export const GROUP_BOTTOM_PAD_PX = 4;
156
+
157
+ /**
158
+ * Vertical space (px) reserved ABOVE a bracket-style (no-fill) group's
159
+ * box to host its overhanging title label. Bracket groups paint their
160
+ * label at `box.y - 2` (baseline) in the renderer — the glyph extent
161
+ * lives entirely ABOVE box.y. Without an explicit reservation, that
162
+ * overhang collides with the previous sibling's bracket-foot when two
163
+ * bracket-titled groups stack in a parallel (the foot ends right where
164
+ * the next label's top would render). The group shifts its own box.y
165
+ * down by this amount so the label has clear space above it; the
166
+ * caller advances by `bracketLabelOverhang + box.height + interRowGap`.
167
+ *
168
+ * Filled-style ("chiclet") groups use `GROUP_TITLE_TAB_HEIGHT_PX +
169
+ * GROUP_TITLE_TAB_GUTTER_PX` instead — the chiclet sits INSIDE the box,
170
+ * so they need no above-box reservation.
171
+ */
172
+ export const GROUP_BRACKET_LABEL_OVERHANG_PX = 12;
173
+
174
+ /**
175
+ * Dashed-accent pattern (px on / px off) used for two nowline accents
176
+ * that read as "structural emphasis": the milestone vertical cut line
177
+ * and the include-region's dashed border. Coupling them in one constant
178
+ * keeps both surfaces visually in sync.
179
+ *
180
+ * Mirrors the spec samples (`stroke-dasharray="6 4"`).
181
+ */
182
+ export const ACCENT_DASH_PATTERN = '6 4';
183
+
184
+ // Now-line pill — the rounded "now" chiclet that sits above the
185
+ // timeline at the now-line's x. Layout reserves the pill row's height
186
+ // in the timeline header AND uses NOW_PILL_WIDTH_PX/2 to keep the
187
+ // canvas wide enough that the pill never clips at the right edge
188
+ // (centered on the line, the pill extends ±half-width past it). The
189
+ // renderer paints with the same constants so the layout's reservation
190
+ // matches the painted glyph 1:1.
191
+
192
+ /** Width (px) of the rounded "now" pill above the now-line. */
193
+ export const NOW_PILL_WIDTH_PX = 36;
194
+
195
+ /** Height (px) of the rounded "now" pill. Matches the pill row band
196
+ * reserved at the top of the timeline. */
197
+ export const NOW_PILL_HEIGHT_PX = 16;
198
+
199
+ /** Corner radius (px) of the pill — half of `NOW_PILL_HEIGHT_PX` so
200
+ * the rect renders as a fully-rounded chiclet. */
201
+ export const NOW_PILL_CORNER_RADIUS_PX = NOW_PILL_HEIGHT_PX / 2;
202
+
203
+ /** Font size (px) of the "now" label inside the pill. */
204
+ export const NOW_PILL_LABEL_FONT_SIZE_PX = 10;
205
+
206
+ /** Baseline offset (px) of the label text from the pill's top edge.
207
+ * Tuned to vertically center a 10 pt bold glyph in a 16 px pill. */
208
+ export const NOW_PILL_LABEL_BASELINE_OFFSET_PX = NOW_PILL_HEIGHT_PX - 4;
209
+
210
+ /**
211
+ * Horizontal inset (px) of the "now" label from the pill's squared
212
+ * edge in flag mode. In `flag-right` mode the label sits at
213
+ * `squaredEdgeX + INSET` (left-aligned past the line); in `flag-left`
214
+ * mode at `squaredEdgeX - INSET` (right-aligned before the line).
215
+ * Center mode ignores this — the label sits at `nowX` with
216
+ * `text-anchor=middle`.
217
+ */
218
+ export const NOW_PILL_LABEL_INSET_X_PX = 6;
219
+
220
+ /**
221
+ * Stroke width (px) of the now-line. Shared between the line itself
222
+ * and the flag-mode pill geometry so the squared edge of the pill
223
+ * lines up with the OUTER edge of the line stroke (SVG strokes are
224
+ * centered on their geometry, so without this offset the line
225
+ * stroke peeks past the pill's squared edge by half-stroke).
226
+ */
227
+ export const NOWLINE_STROKE_WIDTH_PX = 2.25;
228
+
229
+ // Visual inset applied on each side of an item bar. Two adjacent (logically
230
+ // chained) items therefore have a 2× ITEM_INSET_PX visible gutter between
231
+ // them, leaving room for vertical drop-lines (dependency arrows, anchor /
232
+ // milestone cuts, the now-line) to pass between bars without crossing them.
233
+ export const ITEM_INSET_PX = 6;
234
+
235
+ // Canonical content gutter — the rest-state spacing between adjacent
236
+ // pieces of chart content. Used for:
237
+ // - the gap between two adjacent items in a track (= 2 × ITEM_INSET_PX),
238
+ // - the gap between the header card and the chart's left edge (originX
239
+ // offset from chartLeftX),
240
+ // - the bottom margin around the attribution wordmark.
241
+ //
242
+ // Future interactive layers (e.g. drag-and-drop authoring) may locally
243
+ // inflate this gutter at the active drop site to reveal a target slot.
244
+ // That's a runtime concern — the layout engine emits a static positioned
245
+ // model and the interactive shell animates spacing on top. Keep this
246
+ // constant as the rest-state baseline.
247
+ export const GUTTER_PX = 2 * ITEM_INSET_PX;
248
+
249
+ // Default pixel-per-day when no explicit scale is set. Calibrated so a
250
+ // 26-week (180 day) roadmap fits a 1200 px content area.
251
+ export const DEFAULT_PIXELS_PER_DAY = 5;
252
+
253
+ // Header box defaults per position. The beside-mode card width is dynamic
254
+ // (sized to the title + author text, clamped to MIN..MAX with text wrap),
255
+ // so the layout uses these bounds instead of a single fixed width.
256
+ export const HEADER_BESIDE_MIN_WIDTH_PX = 120;
257
+ export const HEADER_BESIDE_MAX_WIDTH_PX = 240;
258
+ export const HEADER_ABOVE_HEIGHT_PX = 72;
259
+
260
+ // Footnote panel — fixed-position panel below the chart with a
261
+ // "Footnotes" header and one row per entry. Layout sizes the panel
262
+ // using these values; the renderer paints labels at offsets derived
263
+ // from the same constants so a padding bump cascades to both surfaces.
264
+ //
265
+ // ┌───────────────────────────────┐
266
+ // │ Footnotes ↑ │ header band (HEADER_HEIGHT)
267
+ // │ │ │
268
+ // ├─────────────────────────┘ │ gap = PANEL_PADDING
269
+ // │ N Title — description │ row N (ROW_HEIGHT each)
270
+ // │ N … │
271
+ // │ │
272
+ // └───────────────────────────────┘ bottom pad = PANEL_PADDING
273
+ //
274
+ // Total panel height (when entries.length > 0):
275
+ // HEADER_HEIGHT + entries × ROW_HEIGHT + PANEL_PADDING
276
+
277
+ /** Height (px) of the header band above the entry rows. */
278
+ export const FOOTNOTE_HEADER_HEIGHT_PX = 28;
279
+
280
+ /**
281
+ * Single padding value (px) reused as: left/right text inset, bottom
282
+ * padding under the last row, gap between the header band and the
283
+ * first entry, and gap between the number column and the title column.
284
+ * Bumping it changes every footnote panel inset consistently.
285
+ */
286
+ export const FOOTNOTE_PANEL_PADDING_PX = 16;
287
+
288
+ /** Baseline Y of the "Footnotes" header text relative to the panel top. */
289
+ export const FOOTNOTE_HEADER_BASELINE_OFFSET_PX = 22;
290
+
291
+ /** Baseline-to-baseline spacing between footnote entry rows. */
292
+ export const FOOTNOTE_ROW_HEIGHT = 18;
293
+
294
+ // Attribution mark — a clickable "Powered by nowline" link that sits in
295
+ // the canvas's bottom margin. Single source of truth for layout (which
296
+ // reserves a glyph-sized slot at canvas-bottom-right) and the renderer
297
+ // (which paints the glyph inside that slot at the same scale, so the
298
+ // reserved box hugs the painted glyph 1:1).
299
+ //
300
+ // "Powered by" is rendered at 80% of the wordmark's font size so it
301
+ // reads as a subdued tag rather than competing with the brand. Both
302
+ // share the same baseline (y = wordmarkFontSize) so the tag's x-height
303
+ // sits cleanly above the wordmark's baseline.
304
+ //
305
+ // Logical units (before `ATTRIBUTION_SCALE`):
306
+ // "Powered by" text ≈ 10 × 32 × 0.58 = 185.6 weight 400, x=0
307
+ // gap 10
308
+ // "now" text ≈ 3 × 40 × 0.58 = 69.6 weight 700, x=195.6
309
+ // bar (the "l") x=195.6+74=269.6, w=5
310
+ // "ine" text ≈ 3 × 40 × 0.58 = 69.6 weight 400, x=195.6+81=276.6
311
+ // Bar bottom is y = 12 + 40 = 52. The 74/81 internal offsets keep the
312
+ // red bar reading as a single "l" between "now" and "ine".
313
+ export const ATTRIBUTION_TEXT = 'Powered by';
314
+ export const ATTRIBUTION_LINK = 'https://nowline.io';
315
+ export const ATTRIBUTION_SCALE = 0.22;
316
+ export const ATTRIBUTION_WORDMARK_FONT_SIZE = 40;
317
+ export const ATTRIBUTION_PREFIX_FONT_SIZE = 32;
318
+
319
+ const ATTR_CHAR_FACTOR = 0.58;
320
+ export const ATTRIBUTION_PREFIX_LOGICAL_WIDTH =
321
+ ATTRIBUTION_TEXT.length * ATTRIBUTION_PREFIX_FONT_SIZE * ATTR_CHAR_FACTOR; // 185.6
322
+ export const ATTRIBUTION_PREFIX_TO_WORDMARK_GAP = 10;
323
+ export const ATTRIBUTION_NOW_LOGICAL_X =
324
+ ATTRIBUTION_PREFIX_LOGICAL_WIDTH + ATTRIBUTION_PREFIX_TO_WORDMARK_GAP; // 195.6
325
+ export const ATTRIBUTION_BAR_LOGICAL_X = ATTRIBUTION_NOW_LOGICAL_X + 74; // 269.6
326
+ export const ATTRIBUTION_BAR_LOGICAL_WIDTH = 5;
327
+ export const ATTRIBUTION_INE_LOGICAL_X = ATTRIBUTION_NOW_LOGICAL_X + 81; // 276.6
328
+ export const ATTRIBUTION_INE_LOGICAL_WIDTH = 3 * ATTRIBUTION_WORDMARK_FONT_SIZE * ATTR_CHAR_FACTOR; // 69.6
329
+ export const ATTRIBUTION_GLYPH_LOGICAL_WIDTH =
330
+ ATTRIBUTION_INE_LOGICAL_X + ATTRIBUTION_INE_LOGICAL_WIDTH; // 346.2
331
+ export const ATTRIBUTION_GLYPH_LOGICAL_HEIGHT = 12 + ATTRIBUTION_WORDMARK_FONT_SIZE; // 52
332
+ export const ATTRIBUTION_GLYPH_WIDTH = ATTRIBUTION_GLYPH_LOGICAL_WIDTH * ATTRIBUTION_SCALE;
333
+ export const ATTRIBUTION_GLYPH_HEIGHT = ATTRIBUTION_GLYPH_LOGICAL_HEIGHT * ATTRIBUTION_SCALE;
334
+
335
+ // Dependency-edge rounded-corner radius (for Manhattan routing).
336
+ export const EDGE_CORNER_RADIUS = 4;
337
+
338
+ // Shadow filter parameters per shadow kind.
339
+ export const SHADOW_PARAMS: Record<
340
+ 'none' | 'subtle' | 'soft' | 'hard',
341
+ {
342
+ dx: number;
343
+ dy: number;
344
+ stdDeviation: number;
345
+ opacity: number;
346
+ }
347
+ > = {
348
+ none: { dx: 0, dy: 0, stdDeviation: 0, opacity: 0 },
349
+ subtle: { dx: 0, dy: 1, stdDeviation: 1.5, opacity: 0.2 },
350
+ soft: { dx: 0, dy: 3, stdDeviation: 5, opacity: 0.3 },
351
+ hard: { dx: 2, dy: 2, stdDeviation: 0, opacity: 0.45 },
352
+ };
353
+
354
+ // Font stacks keyed by DSL `font:` value.
355
+ export const FONT_STACK: Record<'sans' | 'serif' | 'mono', string> = {
356
+ sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
357
+ serif: 'Georgia, "Times New Roman", serif',
358
+ mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
359
+ };
360
+
361
+ // Label thinning defaults per scale unit (from specs/rendering.md § Timeline).
362
+ // Values are label-every counts (i.e. label every Nth tick).
363
+ export const LABEL_THINNING: Record<'days' | 'weeks' | 'months' | 'quarters' | 'years', number> = {
364
+ days: 7,
365
+ weeks: 4,
366
+ months: 3,
367
+ quarters: 4,
368
+ years: 5,
369
+ };
@@ -0,0 +1,78 @@
1
+ // Date <-> pixel mapping. Replaces the hand-coded `xForDate` /
2
+ // `pixelsPerDay` arithmetic from `timeline.ts` with a wrapper that
3
+ // also exposes `invert(x) -> Date` for the m4 editor work and that
4
+ // composes with a `WorkingCalendar` for non-continuous time models.
5
+ //
6
+ // Forward direction matches the legacy arithmetic byte-for-byte
7
+ // (`originX + daysBetween(domain[0], date) * pixelsPerDay`) so the
8
+ // m2.5a refactor leaves the rendered output unchanged. Invert uses
9
+ // `d3-scale.scaleTime` so callers get a precise Date back from a
10
+ // pixel coordinate.
11
+
12
+ import { type ScaleTime, scaleTime } from 'd3-scale';
13
+ import { daysBetween } from './calendar.js';
14
+ import type { WorkingCalendar } from './working-calendar.js';
15
+
16
+ export interface TimeScaleOptions {
17
+ /** [start, end] in calendar dates (UTC midnight assumed). */
18
+ domain: [Date, Date];
19
+ /** [originX, originX + chartWidth]. */
20
+ range: [number, number];
21
+ /** Optional non-continuous calendar for future weekend-skip support. */
22
+ calendar?: WorkingCalendar;
23
+ }
24
+
25
+ export class TimeScale {
26
+ readonly domain: [Date, Date];
27
+ readonly range: [number, number];
28
+ readonly pixelsPerDay: number;
29
+ readonly calendar?: WorkingCalendar;
30
+ private readonly d3: ScaleTime<number, number>;
31
+
32
+ constructor(opts: TimeScaleOptions) {
33
+ this.domain = opts.domain;
34
+ this.range = opts.range;
35
+ this.calendar = opts.calendar;
36
+ const spanDays = Math.max(1, daysBetween(opts.domain[0], opts.domain[1]));
37
+ this.pixelsPerDay = (opts.range[1] - opts.range[0]) / spanDays;
38
+ this.d3 = scaleTime().domain(opts.domain).range(opts.range);
39
+ }
40
+
41
+ /**
42
+ * Project a date onto the x-axis. Always returns a number, even
43
+ * for dates outside the domain (callers that need clamping use
44
+ * `forwardWithinDomain`).
45
+ */
46
+ forward(date: Date): number {
47
+ const days = daysBetween(this.domain[0], date);
48
+ return this.range[0] + days * this.pixelsPerDay;
49
+ }
50
+
51
+ /**
52
+ * Project a date onto the x-axis, returning `null` when the date
53
+ * is outside [domain[0], domain[1]]. Replaces the legacy
54
+ * `xForDate(date, timeline)` helper.
55
+ */
56
+ forwardWithinDomain(date: Date): number | null {
57
+ if (date < this.domain[0] || date > this.domain[1]) return null;
58
+ return this.forward(date);
59
+ }
60
+
61
+ /**
62
+ * Inverse projection. Returns a Date for any x in the chart;
63
+ * callers that want day-resolution can floor the result.
64
+ */
65
+ invert(x: number): Date {
66
+ return this.d3.invert(x);
67
+ }
68
+
69
+ /** First pixel of the chart (start of range). */
70
+ get originX(): number {
71
+ return this.range[0];
72
+ }
73
+
74
+ /** Width of the chart band in pixels. */
75
+ get widthPx(): number {
76
+ return this.range[1] - this.range[0];
77
+ }
78
+ }