@nowline/layout 0.8.6 → 0.9.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 (338) hide show
  1. package/dist/axis-columns.d.ts +70 -0
  2. package/dist/axis-columns.d.ts.map +1 -0
  3. package/dist/axis-columns.js +104 -0
  4. package/dist/axis-columns.js.map +1 -0
  5. package/dist/calendar-resolver.d.ts +27 -0
  6. package/dist/calendar-resolver.d.ts.map +1 -0
  7. package/dist/calendar-resolver.js +132 -0
  8. package/dist/calendar-resolver.js.map +1 -0
  9. package/dist/calendar.d.ts +0 -19
  10. package/dist/calendar.d.ts.map +1 -1
  11. package/dist/calendar.js +4 -44
  12. package/dist/calendar.js.map +1 -1
  13. package/dist/canvas-width.d.ts +23 -0
  14. package/dist/canvas-width.d.ts.map +1 -0
  15. package/dist/canvas-width.js +26 -0
  16. package/dist/canvas-width.js.map +1 -0
  17. package/dist/capacity.d.ts +28 -0
  18. package/dist/capacity.d.ts.map +1 -1
  19. package/dist/capacity.js +64 -29
  20. package/dist/capacity.js.map +1 -1
  21. package/dist/chart-translation.d.ts +22 -0
  22. package/dist/chart-translation.d.ts.map +1 -0
  23. package/dist/chart-translation.js +83 -0
  24. package/dist/chart-translation.js.map +1 -0
  25. package/dist/container-header-geometry.d.ts +64 -0
  26. package/dist/container-header-geometry.d.ts.map +1 -0
  27. package/dist/container-header-geometry.js +89 -0
  28. package/dist/container-header-geometry.js.map +1 -0
  29. package/dist/dsl-utils.d.ts +3 -4
  30. package/dist/dsl-utils.d.ts.map +1 -1
  31. package/dist/dsl-utils.js +8 -25
  32. package/dist/dsl-utils.js.map +1 -1
  33. package/dist/edge-routing.d.ts +69 -78
  34. package/dist/edge-routing.d.ts.map +1 -1
  35. package/dist/edge-routing.js +271 -401
  36. package/dist/edge-routing.js.map +1 -1
  37. package/dist/frame-tab-geometry.d.ts +24 -14
  38. package/dist/frame-tab-geometry.d.ts.map +1 -1
  39. package/dist/frame-tab-geometry.js +69 -23
  40. package/dist/frame-tab-geometry.js.map +1 -1
  41. package/dist/header-card-geometry.d.ts +12 -0
  42. package/dist/header-card-geometry.d.ts.map +1 -1
  43. package/dist/header-card-geometry.js +14 -2
  44. package/dist/header-card-geometry.js.map +1 -1
  45. package/dist/header-logo.d.ts +28 -0
  46. package/dist/header-logo.d.ts.map +1 -0
  47. package/dist/header-logo.js +51 -0
  48. package/dist/header-logo.js.map +1 -0
  49. package/dist/i18n.d.ts +11 -0
  50. package/dist/i18n.d.ts.map +1 -1
  51. package/dist/i18n.js +11 -1
  52. package/dist/i18n.js.map +1 -1
  53. package/dist/index.d.ts +20 -8
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -7
  56. package/dist/index.js.map +1 -1
  57. package/dist/inline-date-pin-geometry.d.ts +19 -11
  58. package/dist/inline-date-pin-geometry.d.ts.map +1 -1
  59. package/dist/inline-date-pin-geometry.js +45 -46
  60. package/dist/inline-date-pin-geometry.js.map +1 -1
  61. package/dist/item-bar-geometry.d.ts +192 -4
  62. package/dist/item-bar-geometry.d.ts.map +1 -1
  63. package/dist/item-bar-geometry.js +258 -5
  64. package/dist/item-bar-geometry.js.map +1 -1
  65. package/dist/item-port-geometry.d.ts +13 -0
  66. package/dist/item-port-geometry.d.ts.map +1 -0
  67. package/dist/item-port-geometry.js +63 -0
  68. package/dist/item-port-geometry.js.map +1 -0
  69. package/dist/layout-context.d.ts +115 -51
  70. package/dist/layout-context.d.ts.map +1 -1
  71. package/dist/layout-context.js +96 -5
  72. package/dist/layout-context.js.map +1 -1
  73. package/dist/layout-insights.d.ts.map +1 -1
  74. package/dist/layout-insights.js +73 -1
  75. package/dist/layout-insights.js.map +1 -1
  76. package/dist/layout.d.ts +35 -0
  77. package/dist/layout.d.ts.map +1 -1
  78. package/dist/layout.js +18 -1315
  79. package/dist/layout.js.map +1 -1
  80. package/dist/nodes/anchor-node.d.ts.map +1 -1
  81. package/dist/nodes/anchor-node.js +11 -4
  82. package/dist/nodes/anchor-node.js.map +1 -1
  83. package/dist/nodes/content-extent.d.ts +24 -0
  84. package/dist/nodes/content-extent.d.ts.map +1 -0
  85. package/dist/nodes/content-extent.js +100 -0
  86. package/dist/nodes/content-extent.js.map +1 -0
  87. package/dist/nodes/dependency-edges.d.ts +6 -0
  88. package/dist/nodes/dependency-edges.d.ts.map +1 -0
  89. package/dist/nodes/dependency-edges.js +148 -0
  90. package/dist/nodes/dependency-edges.js.map +1 -0
  91. package/dist/nodes/footnote-node.d.ts +24 -3
  92. package/dist/nodes/footnote-node.d.ts.map +1 -1
  93. package/dist/nodes/footnote-node.js +129 -25
  94. package/dist/nodes/footnote-node.js.map +1 -1
  95. package/dist/nodes/group-node.d.ts +10 -21
  96. package/dist/nodes/group-node.d.ts.map +1 -1
  97. package/dist/nodes/group-node.js +79 -152
  98. package/dist/nodes/group-node.js.map +1 -1
  99. package/dist/nodes/header-node.d.ts +56 -0
  100. package/dist/nodes/header-node.d.ts.map +1 -0
  101. package/dist/nodes/header-node.js +163 -0
  102. package/dist/nodes/header-node.js.map +1 -0
  103. package/dist/nodes/include-node.d.ts +22 -12
  104. package/dist/nodes/include-node.d.ts.map +1 -1
  105. package/dist/nodes/include-node.js +95 -62
  106. package/dist/nodes/include-node.js.map +1 -1
  107. package/dist/nodes/item-measure.d.ts +135 -0
  108. package/dist/nodes/item-measure.d.ts.map +1 -0
  109. package/dist/nodes/item-measure.js +514 -0
  110. package/dist/nodes/item-measure.js.map +1 -0
  111. package/dist/nodes/item-node.d.ts +162 -9
  112. package/dist/nodes/item-node.d.ts.map +1 -1
  113. package/dist/nodes/item-node.js +256 -69
  114. package/dist/nodes/item-node.js.map +1 -1
  115. package/dist/nodes/marker-band.d.ts +118 -0
  116. package/dist/nodes/marker-band.d.ts.map +1 -0
  117. package/dist/nodes/marker-band.js +303 -0
  118. package/dist/nodes/marker-band.js.map +1 -0
  119. package/dist/nodes/marker-geometry.js +1 -1
  120. package/dist/nodes/marker-geometry.js.map +1 -1
  121. package/dist/nodes/milestone-node.d.ts +112 -15
  122. package/dist/nodes/milestone-node.d.ts.map +1 -1
  123. package/dist/nodes/milestone-node.js +246 -95
  124. package/dist/nodes/milestone-node.js.map +1 -1
  125. package/dist/nodes/nowline-node.d.ts +8 -0
  126. package/dist/nodes/nowline-node.d.ts.map +1 -0
  127. package/dist/nodes/nowline-node.js +74 -0
  128. package/dist/nodes/nowline-node.js.map +1 -0
  129. package/dist/nodes/parallel-node.d.ts +7 -12
  130. package/dist/nodes/parallel-node.d.ts.map +1 -1
  131. package/dist/nodes/parallel-node.js +66 -30
  132. package/dist/nodes/parallel-node.js.map +1 -1
  133. package/dist/nodes/region-markers.d.ts +41 -0
  134. package/dist/nodes/region-markers.d.ts.map +1 -0
  135. package/dist/nodes/region-markers.js +130 -0
  136. package/dist/nodes/region-markers.js.map +1 -0
  137. package/dist/nodes/roadmap-node.d.ts +9 -72
  138. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  139. package/dist/nodes/roadmap-node.js +38 -788
  140. package/dist/nodes/roadmap-node.js.map +1 -1
  141. package/dist/nodes/row-track.d.ts +33 -0
  142. package/dist/nodes/row-track.d.ts.map +1 -0
  143. package/dist/nodes/row-track.js +170 -0
  144. package/dist/nodes/row-track.js.map +1 -0
  145. package/dist/nodes/sequence-item.d.ts +25 -0
  146. package/dist/nodes/sequence-item.d.ts.map +1 -0
  147. package/dist/nodes/sequence-item.js +396 -0
  148. package/dist/nodes/sequence-item.js.map +1 -0
  149. package/dist/nodes/sequence-one.d.ts +5 -0
  150. package/dist/nodes/sequence-one.d.ts.map +1 -0
  151. package/dist/nodes/sequence-one.js +26 -0
  152. package/dist/nodes/sequence-one.js.map +1 -0
  153. package/dist/nodes/swimlane-node.d.ts +5 -17
  154. package/dist/nodes/swimlane-node.d.ts.map +1 -1
  155. package/dist/nodes/swimlane-node.js +62 -145
  156. package/dist/nodes/swimlane-node.js.map +1 -1
  157. package/dist/nodes/wave-node.d.ts +52 -0
  158. package/dist/nodes/wave-node.d.ts.map +1 -0
  159. package/dist/nodes/wave-node.js +483 -0
  160. package/dist/nodes/wave-node.js.map +1 -0
  161. package/dist/non-working-display.d.ts +14 -0
  162. package/dist/non-working-display.d.ts.map +1 -0
  163. package/dist/non-working-display.js +28 -0
  164. package/dist/non-working-display.js.map +1 -0
  165. package/dist/phases/date-window.d.ts +48 -0
  166. package/dist/phases/date-window.d.ts.map +1 -0
  167. package/dist/phases/date-window.js +130 -0
  168. package/dist/phases/date-window.js.map +1 -0
  169. package/dist/phases/finalize.d.ts +198 -0
  170. package/dist/phases/finalize.d.ts.map +1 -0
  171. package/dist/phases/finalize.js +1058 -0
  172. package/dist/phases/finalize.js.map +1 -0
  173. package/dist/phases/measure.d.ts +77 -0
  174. package/dist/phases/measure.d.ts.map +1 -0
  175. package/dist/phases/measure.js +222 -0
  176. package/dist/phases/measure.js.map +1 -0
  177. package/dist/phases/place.d.ts +28 -0
  178. package/dist/phases/place.d.ts.map +1 -0
  179. package/dist/phases/place.js +225 -0
  180. package/dist/phases/place.js.map +1 -0
  181. package/dist/renderable.d.ts.map +1 -1
  182. package/dist/renderable.js +23 -3
  183. package/dist/renderable.js.map +1 -1
  184. package/dist/resolve-today.d.ts +11 -1
  185. package/dist/resolve-today.d.ts.map +1 -1
  186. package/dist/resolve-today.js +26 -12
  187. package/dist/resolve-today.js.map +1 -1
  188. package/dist/row-packer.d.ts +42 -16
  189. package/dist/row-packer.d.ts.map +1 -1
  190. package/dist/row-packer.js +51 -78
  191. package/dist/row-packer.js.map +1 -1
  192. package/dist/schedule.d.ts +58 -1
  193. package/dist/schedule.d.ts.map +1 -1
  194. package/dist/schedule.js +280 -106
  195. package/dist/schedule.js.map +1 -1
  196. package/dist/start-rules.d.ts +27 -0
  197. package/dist/start-rules.d.ts.map +1 -0
  198. package/dist/start-rules.js +91 -0
  199. package/dist/start-rules.js.map +1 -0
  200. package/dist/start-terms.d.ts +23 -0
  201. package/dist/start-terms.d.ts.map +1 -0
  202. package/dist/start-terms.js +27 -0
  203. package/dist/start-terms.js.map +1 -0
  204. package/dist/status-dot.d.ts +46 -0
  205. package/dist/status-dot.d.ts.map +1 -0
  206. package/dist/status-dot.js +78 -0
  207. package/dist/status-dot.js.map +1 -0
  208. package/dist/style-resolution.d.ts.map +1 -1
  209. package/dist/style-resolution.js +23 -43
  210. package/dist/style-resolution.js.map +1 -1
  211. package/dist/text-measure.d.ts +28 -0
  212. package/dist/text-measure.d.ts.map +1 -0
  213. package/dist/text-measure.js +70 -0
  214. package/dist/text-measure.js.map +1 -0
  215. package/dist/themes/contrast.d.ts +4 -0
  216. package/dist/themes/contrast.d.ts.map +1 -0
  217. package/dist/themes/contrast.js +32 -0
  218. package/dist/themes/contrast.js.map +1 -0
  219. package/dist/themes/dark.d.ts.map +1 -1
  220. package/dist/themes/dark.js +28 -1
  221. package/dist/themes/dark.js.map +1 -1
  222. package/dist/themes/grayscale.d.ts.map +1 -1
  223. package/dist/themes/grayscale.js +28 -1
  224. package/dist/themes/grayscale.js.map +1 -1
  225. package/dist/themes/index.d.ts +4 -11
  226. package/dist/themes/index.d.ts.map +1 -1
  227. package/dist/themes/index.js +6 -4
  228. package/dist/themes/index.js.map +1 -1
  229. package/dist/themes/light.d.ts.map +1 -1
  230. package/dist/themes/light.js +28 -1
  231. package/dist/themes/light.js.map +1 -1
  232. package/dist/themes/shape.d.ts +31 -0
  233. package/dist/themes/shape.d.ts.map +1 -1
  234. package/dist/themes/shared.d.ts +44 -50
  235. package/dist/themes/shared.d.ts.map +1 -1
  236. package/dist/themes/shared.js +79 -83
  237. package/dist/themes/shared.js.map +1 -1
  238. package/dist/time-scale.d.ts +42 -6
  239. package/dist/time-scale.d.ts.map +1 -1
  240. package/dist/time-scale.js +109 -17
  241. package/dist/time-scale.js.map +1 -1
  242. package/dist/types.d.ts +528 -11
  243. package/dist/types.d.ts.map +1 -1
  244. package/dist/view-preset.d.ts +61 -6
  245. package/dist/view-preset.d.ts.map +1 -1
  246. package/dist/view-preset.js +439 -24
  247. package/dist/view-preset.js.map +1 -1
  248. package/dist/wave-barrier.d.ts +107 -0
  249. package/dist/wave-barrier.d.ts.map +1 -0
  250. package/dist/wave-barrier.js +174 -0
  251. package/dist/wave-barrier.js.map +1 -0
  252. package/dist/wave-hatch.d.ts +4 -0
  253. package/dist/wave-hatch.d.ts.map +1 -0
  254. package/dist/wave-hatch.js +21 -0
  255. package/dist/wave-hatch.js.map +1 -0
  256. package/dist/wave-layout.d.ts +99 -0
  257. package/dist/wave-layout.d.ts.map +1 -0
  258. package/dist/wave-layout.js +130 -0
  259. package/dist/wave-layout.js.map +1 -0
  260. package/dist/working-calendar.d.ts +128 -4
  261. package/dist/working-calendar.d.ts.map +1 -1
  262. package/dist/working-calendar.js +725 -51
  263. package/dist/working-calendar.js.map +1 -1
  264. package/dist/working-span.d.ts +11 -0
  265. package/dist/working-span.d.ts.map +1 -0
  266. package/dist/working-span.js +18 -0
  267. package/dist/working-span.js.map +1 -0
  268. package/package.json +4 -4
  269. package/src/axis-columns.ts +140 -0
  270. package/src/calendar-resolver.ts +161 -0
  271. package/src/calendar.ts +4 -45
  272. package/src/canvas-width.ts +36 -0
  273. package/src/capacity.ts +71 -30
  274. package/src/chart-translation.ts +88 -0
  275. package/src/container-header-geometry.ts +116 -0
  276. package/src/dsl-utils.ts +8 -26
  277. package/src/edge-routing.ts +347 -491
  278. package/src/frame-tab-geometry.ts +78 -25
  279. package/src/header-card-geometry.ts +17 -2
  280. package/src/header-logo.ts +67 -0
  281. package/src/i18n.ts +22 -1
  282. package/src/index.ts +97 -12
  283. package/src/inline-date-pin-geometry.ts +55 -58
  284. package/src/item-bar-geometry.ts +341 -5
  285. package/src/item-port-geometry.ts +69 -0
  286. package/src/layout-context.ts +210 -72
  287. package/src/layout-insights.ts +109 -2
  288. package/src/layout.ts +57 -1530
  289. package/src/nodes/anchor-node.ts +11 -4
  290. package/src/nodes/content-extent.ts +107 -0
  291. package/src/nodes/dependency-edges.ts +171 -0
  292. package/src/nodes/footnote-node.ts +171 -31
  293. package/src/nodes/group-node.ts +92 -214
  294. package/src/nodes/header-node.ts +255 -0
  295. package/src/nodes/include-node.ts +124 -110
  296. package/src/nodes/item-measure.ts +723 -0
  297. package/src/nodes/item-node.ts +358 -80
  298. package/src/nodes/marker-band.ts +406 -0
  299. package/src/nodes/marker-geometry.ts +1 -1
  300. package/src/nodes/milestone-node.ts +321 -136
  301. package/src/nodes/nowline-node.ts +87 -0
  302. package/src/nodes/parallel-node.ts +69 -38
  303. package/src/nodes/region-markers.ts +194 -0
  304. package/src/nodes/roadmap-node.ts +46 -950
  305. package/src/nodes/row-track.ts +217 -0
  306. package/src/nodes/sequence-item.ts +433 -0
  307. package/src/nodes/sequence-one.ts +40 -0
  308. package/src/nodes/swimlane-node.ts +66 -205
  309. package/src/nodes/wave-node.ts +609 -0
  310. package/src/non-working-display.ts +32 -0
  311. package/src/phases/date-window.ts +191 -0
  312. package/src/phases/finalize.ts +1303 -0
  313. package/src/phases/measure.ts +329 -0
  314. package/src/phases/place.ts +318 -0
  315. package/src/renderable.ts +23 -3
  316. package/src/resolve-today.ts +28 -25
  317. package/src/row-packer.ts +85 -84
  318. package/src/schedule.ts +362 -114
  319. package/src/start-rules.ts +113 -0
  320. package/src/start-terms.ts +38 -0
  321. package/src/status-dot.ts +81 -0
  322. package/src/style-resolution.ts +27 -57
  323. package/src/text-measure.ts +71 -0
  324. package/src/themes/contrast.ts +34 -0
  325. package/src/themes/dark.ts +28 -1
  326. package/src/themes/grayscale.ts +28 -1
  327. package/src/themes/index.ts +10 -7
  328. package/src/themes/light.ts +28 -1
  329. package/src/themes/shape.ts +38 -0
  330. package/src/themes/shared.ts +85 -93
  331. package/src/time-scale.ts +117 -19
  332. package/src/types.ts +529 -20
  333. package/src/view-preset.ts +520 -27
  334. package/src/wave-barrier.ts +242 -0
  335. package/src/wave-hatch.ts +25 -0
  336. package/src/wave-layout.ts +203 -0
  337. package/src/working-calendar.ts +875 -57
  338. package/src/working-span.ts +19 -0
@@ -5,17 +5,36 @@
5
5
  //
6
6
  // `resolveScale` parses the DSL `scale:` property (and any nested
7
7
  // `scale` block) into a `ViewPreset`. `buildHeaderTicks` produces
8
- // the `PositionedTick[]` byte-stable with the legacy generator: same
9
- // x positions, same labelX positions, same major/minor flags, same
10
- // label text.
8
+ // the `PositionedTick[]`. Day and week ticks keep the legacy fixed
9
+ // stride (byte-stable with the legacy generator); month, quarter, and
10
+ // year ticks sit on real calendar boundaries, because a fixed day
11
+ // count (30d, 22d, 65d, ...) drifts across month edges and mislabels
12
+ // columns once the span runs long enough. Under a calendar with
13
+ // non-working days (`hide`) every unit takes real boundaries: days,
14
+ // week starts, or unit starts on the working-day axis.
11
15
 
12
16
  import type { NowlineFile, ScaleBlock } from '@nowline/core';
13
- import { addDays } from './calendar.js';
17
+ import { findProp } from '@nowline/core/internal';
18
+ import { addDays, daysBetween } from './calendar.js';
14
19
  import { DEFAULT_LOCALE, localeStrings } from './i18n.js';
15
- import { DEFAULT_PIXELS_PER_DAY, LABEL_THINNING } from './themes/shared.js';
20
+ import { estimateTextWidth } from './text-measure.js';
21
+ import {
22
+ DEFAULT_PIXELS_PER_DAY,
23
+ LABEL_THINNING,
24
+ TIMELINE_TICK_LABEL_FONT_SIZE_PX,
25
+ } from './themes/shared.js';
16
26
  import type { TimeScale } from './time-scale.js';
17
- import type { PositionedTick } from './types.js';
18
- import type { WorkingCalendar } from './working-calendar.js';
27
+ import type {
28
+ PositionedNonWorkingRun,
29
+ PositionedTick,
30
+ PositionedWorkingException,
31
+ } from './types.js';
32
+ import {
33
+ continuousDaysPerUnit,
34
+ type NonWorkingRun,
35
+ type WorkingCalendar,
36
+ type WorkingExceptionRun,
37
+ } from './working-calendar.js';
19
38
 
20
39
  export type ScaleUnit = 'days' | 'weeks' | 'months' | 'quarters' | 'years';
21
40
 
@@ -26,6 +45,12 @@ export interface ViewPreset {
26
45
  labelEvery: number;
27
46
  /** Pixels per `1 unit` worth of working days. */
28
47
  pixelsPerUnit: number;
48
+ /**
49
+ * Set when `labelEvery` came from the default thinning
50
+ * (`LABEL_THINNING`) rather than a `scale:` literal or `label-every:`.
51
+ * Under `hide` the default days thinning labels week starts instead.
52
+ */
53
+ labelEveryDefault?: true;
29
54
  }
30
55
 
31
56
  // `ScaleConfig` is kept as an alias for source-compat with the few
@@ -33,12 +58,8 @@ export interface ViewPreset {
33
58
  // `ViewPreset`.
34
59
  export type ScaleConfig = ViewPreset;
35
60
 
36
- function stripColon(key: string): string {
37
- return key.endsWith(':') ? key.slice(0, -1) : key;
38
- }
39
-
40
61
  export function resolveScale(file: NowlineFile, scaleBlock: ScaleBlock | undefined): ViewPreset {
41
- const scaleProp = file.roadmapDecl?.properties.find((p) => stripColon(p.key) === 'scale');
62
+ const scaleProp = findProp(file.roadmapDecl?.properties ?? [], 'scale');
42
63
  // `scale:` accepts a unit name (`days`/`weeks`/`months`/`quarters`/`years`)
43
64
  // OR a duration literal (`1w`, `2w`, `1m`, `1q`, `1y`). The literal form
44
65
  // is the documented default in the DSL spec; it picks the unit and uses
@@ -85,25 +106,29 @@ export function resolveScale(file: NowlineFile, scaleBlock: ScaleBlock | undefin
85
106
  }
86
107
  }
87
108
  const defaultLabelEvery = labelEveryOverride ?? LABEL_THINNING[unit] ?? 4;
109
+ // Spread into the result only when the thinning is the default one.
110
+ const defaultFlag = (hasLabelEvery: boolean): { labelEveryDefault?: true } =>
111
+ labelEveryOverride === undefined && !hasLabelEvery ? { labelEveryDefault: true } : {};
88
112
 
89
113
  if (scaleBlock) {
90
- const unitProp = scaleBlock.properties.find((p) => stripColon(p.key) === 'unit');
91
- const resolvedUnit: ScaleUnit = (unitProp?.value as ScaleUnit) ?? unit;
92
- const labelProp = scaleBlock.properties.find((p) => stripColon(p.key) === 'label-every');
93
- const pxProp = scaleBlock.properties.find((p) => stripColon(p.key) === 'pixels-per-unit');
94
- const labelEvery = labelProp
95
- ? Math.max(1, parseInt(labelProp.value, 10) || defaultLabelEvery)
96
- : defaultLabelEvery;
97
- const pixelsPerUnit = pxProp
98
- ? Math.max(1, parseInt(pxProp.value, 10) || unitPx(resolvedUnit))
99
- : (pixelsPerUnitOverride ?? unitPx(resolvedUnit));
100
- return { unit: resolvedUnit, labelEvery, pixelsPerUnit };
114
+ // Unit and column width come from `scale:` alone; the block only sets display.
115
+ const labelProp = findProp(scaleBlock.properties, 'label-every');
116
+ const explicitLabelEvery = labelProp ? parseInt(labelProp.value, 10) || 0 : 0;
117
+ const labelEvery =
118
+ explicitLabelEvery !== 0 ? Math.max(1, explicitLabelEvery) : defaultLabelEvery;
119
+ return {
120
+ unit,
121
+ labelEvery,
122
+ pixelsPerUnit: pixelsPerUnitOverride ?? unitPx(unit),
123
+ ...defaultFlag(explicitLabelEvery !== 0),
124
+ };
101
125
  }
102
126
 
103
127
  return {
104
128
  unit,
105
129
  labelEvery: defaultLabelEvery,
106
130
  pixelsPerUnit: pixelsPerUnitOverride ?? unitPx(unit),
131
+ ...defaultFlag(false),
107
132
  };
108
133
  }
109
134
 
@@ -124,11 +149,120 @@ function unitPx(unit: ScaleUnit): number {
124
149
  }
125
150
  }
126
151
 
152
+ type CalendarAlignedUnit = 'months' | 'quarters' | 'years';
153
+
154
+ /** Half a pixel: a column this close to a full unit counts as full. */
155
+ const NARROW_COLUMN_TOLERANCE_PX = 0.5;
156
+ /** Two working-day boundaries closer than this share one grid line. */
157
+ const SAME_X_TOLERANCE_PX = 0.5;
158
+ const DAYS_PER_WEEK = 7;
159
+
160
+ // Months, quarters, and years vary in length (28-31d, 90-92d,
161
+ // 365-366d), so their ticks follow the real calendar instead of the
162
+ // `calendar:` preset's `days-per-*` count. That count is duration
163
+ // arithmetic for `1m` / `1q` / `1y` literals, not the length of a
164
+ // month on the date axis.
165
+ function isCalendarAligned(unit: ScaleUnit): unit is CalendarAlignedUnit {
166
+ return unit === 'months' || unit === 'quarters' || unit === 'years';
167
+ }
168
+
169
+ /** First month / quarter / year start strictly after `date` (UTC). */
170
+ function nextUnitStart(date: Date, unit: CalendarAlignedUnit): Date {
171
+ const year = date.getUTCFullYear();
172
+ const month = date.getUTCMonth();
173
+ switch (unit) {
174
+ case 'months':
175
+ return new Date(Date.UTC(year, month + 1, 1));
176
+ case 'quarters':
177
+ return new Date(Date.UTC(year, month - (month % 3) + 3, 1));
178
+ case 'years':
179
+ return new Date(Date.UTC(year + 1, 0, 1));
180
+ }
181
+ }
182
+
183
+ function isUnitStart(date: Date, unit: CalendarAlignedUnit): boolean {
184
+ if (date.getUTCDate() !== 1) return false;
185
+ const month = date.getUTCMonth();
186
+ switch (unit) {
187
+ case 'months':
188
+ return true;
189
+ case 'quarters':
190
+ return month % 3 === 0;
191
+ case 'years':
192
+ return month === 0;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Day offset from `start` of the first tick boundary at or after
198
+ * `start + days`. Used to pad the date window so the chart's right
199
+ * edge lands on a column boundary. Day and week units round up to a
200
+ * multiple of the fixed stride (`tickDays`); month, quarter, and year
201
+ * units round up to the next real month / quarter / year start.
202
+ *
203
+ * With a `calendar` that has non-working days, `days` and the result
204
+ * are working-day indices (specs/working-calendar.md §7.2): the content
205
+ * ends before the first date whose index is `ceil(days)`, and the
206
+ * boundary is the first day, week start or unit start at or after it.
207
+ * A zero or negative count pads to the start (index 0) for days and
208
+ * weeks, and to the first unit start at or after the start otherwise.
209
+ */
210
+ export function tickBoundaryAtOrAfter(
211
+ start: Date,
212
+ days: number,
213
+ unit: ScaleUnit,
214
+ tickDays: number,
215
+ calendar?: WorkingCalendar,
216
+ ): number {
217
+ // `days` can arrive from pixel arithmetic (`x / pixelsPerDay`), so
218
+ // absorb float noise before rounding up; otherwise content that ends
219
+ // exactly on a boundary would pad out a whole extra column.
220
+ const count = Math.ceil(days - 1e-6);
221
+ if (calendar?.hasNonWorkingDays) {
222
+ if (!isCalendarAligned(unit) && count <= 0) return 0;
223
+ const contentEnd =
224
+ count <= 0 ? start : addDays(calendar.dateAtWorkingIndex(start, count - 1), 1);
225
+ const boundary = isCalendarAligned(unit)
226
+ ? unitStartAtOrAfter(contentEnd, unit)
227
+ : unit === 'weeks'
228
+ ? weekStartAtOrAfter(contentEnd, start, calendar.weekStart)
229
+ : contentEnd;
230
+ return calendar.workingIndexOf(start, boundary);
231
+ }
232
+ if (!isCalendarAligned(unit)) return Math.ceil(days / tickDays) * tickDays;
233
+ const target = addDays(start, count);
234
+ return daysBetween(start, unitStartAtOrAfter(target, unit));
235
+ }
236
+
237
+ function unitStartAtOrAfter(date: Date, unit: CalendarAlignedUnit): Date {
238
+ return isUnitStart(date, unit) ? date : nextUnitStart(date, unit);
239
+ }
240
+
241
+ /**
242
+ * The first week start at or after `date`: the calendar's `weekStart`
243
+ * weekday, or, with none, a 7-day stride from the window start.
244
+ */
245
+ function weekStartAtOrAfter(date: Date, windowStart: Date, weekStart: number | undefined): Date {
246
+ const offset =
247
+ weekStart === undefined ? -daysBetween(windowStart, date) : weekStart - date.getUTCDay();
248
+ return addDays(date, ((offset % DAYS_PER_WEEK) + DAYS_PER_WEEK) % DAYS_PER_WEEK);
249
+ }
250
+
251
+ /** The first week start strictly after `date`; see `weekStartAtOrAfter`. */
252
+ function nextWeekStart(date: Date, windowStart: Date, weekStart: number | undefined): Date {
253
+ return weekStartAtOrAfter(addDays(date, 1), windowStart, weekStart);
254
+ }
255
+
127
256
  /**
128
- * Build header ticks for the chart. The ith tick sits at
129
- * `originX + i * stridePx`. The last tick is rendered (so the chart
130
- * has a closing edge) but its label is suppressed because there's no
131
- * following column.
257
+ * Build header ticks for the chart. The last tick is rendered (so the
258
+ * chart has a closing edge) but its label is suppressed because there's
259
+ * no following column.
260
+ *
261
+ * Day and week ticks use a fixed stride: the ith tick sits at
262
+ * `originX + i * stridePx`. Month, quarter, and year ticks sit on real
263
+ * calendar boundaries; see `buildCalendarAlignedTicks`. When the scale's
264
+ * calendar has non-working days the axis hides them and every unit
265
+ * takes the boundary path; see `buildHiddenDayTicks`.
132
266
  */
133
267
  export function buildHeaderTicks(
134
268
  scale: TimeScale,
@@ -136,6 +270,12 @@ export function buildHeaderTicks(
136
270
  calendar: WorkingCalendar,
137
271
  locale: string = DEFAULT_LOCALE,
138
272
  ): PositionedTick[] {
273
+ if (scale.calendar?.hasNonWorkingDays) {
274
+ return buildHiddenDayTicks(scale, preset, scale.calendar, locale);
275
+ }
276
+ if (isCalendarAligned(preset.unit)) {
277
+ return buildCalendarAlignedTicks(scale, preset, preset.unit, locale);
278
+ }
139
279
  const dayPerTick = calendar.daysPerUnit(preset.unit);
140
280
  const stridePx = dayPerTick * scale.pixelsPerDay;
141
281
  const totalDays = Math.max(1, Math.round(scale.widthPx / scale.pixelsPerDay));
@@ -159,6 +299,359 @@ export function buildHeaderTicks(
159
299
  return ticks;
160
300
  }
161
301
 
302
+ /**
303
+ * Ticks under `hide` (specs/working-calendar.md §7.2). Candidate
304
+ * boundaries are the window start plus every day (days), every week start
305
+ * (weeks), or every unit start (months and up), and the window end always
306
+ * closes the axis; x comes from the working-day scale, so a boundary on a
307
+ * hidden day shares the next working day's x. A column of zero width is
308
+ * dropped with its label.
309
+ *
310
+ * Thinning counts the kept columns. Under the default days thinning the
311
+ * majors are the week starts instead (the first tick only when it is
312
+ * one); under `hide` that is the first working day of each week, so a
313
+ * hidden Monday moves its week's label to Tuesday. The closing tick never
314
+ * has a label; its `major` flag follows the same rule, as on the
315
+ * fixed-stride path.
316
+ *
317
+ * At days and weeks a label is dropped only when it is wider than its
318
+ * column and the column is narrower than a full unit (a partial week at
319
+ * either edge); a full business week keeps a label wider than its 40 px.
320
+ * Months and up keep the #92 rule: an edge column drops a label it cannot
321
+ * hold. A dropped label keeps its `labelX`. Inside a run of two or more
322
+ * narrowed columns, a dropped label is restored when it fits its column
323
+ * plus half of each neighbour whose label is absent; see
324
+ * `restoreLabelsInNarrowRuns`.
325
+ *
326
+ * Under `show` the same boundaries apply on the calendar-day scale, so no
327
+ * column has zero width: week ticks still fall on week starts, a full unit
328
+ * counts calendar days, and at months and up a closing column with no
329
+ * working day merges into the one before it.
330
+ */
331
+ function buildHiddenDayTicks(
332
+ scale: TimeScale,
333
+ preset: ViewPreset,
334
+ calendar: WorkingCalendar,
335
+ locale: string,
336
+ ): PositionedTick[] {
337
+ const { unit } = preset;
338
+ const [start, end] = scale.domain;
339
+ const next = (date: Date): Date => {
340
+ if (isCalendarAligned(unit)) return nextUnitStart(date, unit);
341
+ if (unit === 'weeks') return nextWeekStart(date, start, calendar.weekStart);
342
+ return addDays(date, 1);
343
+ };
344
+ const candidates: Date[] = [start];
345
+ for (let d = next(start); d < end; d = next(d)) candidates.push(d);
346
+
347
+ // Keep the boundaries that open a column of positive width.
348
+ const endX = scale.forward(end);
349
+ const kept: Array<{ date: Date; x: number }> = [];
350
+ candidates.forEach((date, i) => {
351
+ const x = scale.forward(date);
352
+ const nextX = i + 1 < candidates.length ? scale.forward(candidates[i + 1]) : endX;
353
+ if (nextX > x && !isShownEmptyClosingColumn(scale, unit, candidates, i)) {
354
+ kept.push({ date, x });
355
+ }
356
+ });
357
+
358
+ const weekStartMajors =
359
+ unit === 'days' && preset.labelEveryDefault === true && calendar.weekStart !== undefined;
360
+ const isMajor = (date: Date, column: number): boolean =>
361
+ weekStartMajors
362
+ ? isFirstVisibleDayOfWeek(date, calendar, scale.showsNonWorking)
363
+ : column % preset.labelEvery === 0;
364
+ // A full unit is its working days under `hide` and its calendar days
365
+ // under `show`, where every day has width.
366
+ const fullUnitDays = scale.showsNonWorking
367
+ ? continuousDaysPerUnit(unit)
368
+ : calendar.daysPerUnit(unit);
369
+ const fullUnitPx = fullUnitDays * scale.pixelsPerDay;
370
+
371
+ const columns: TickColumn[] = kept.map(({ date, x }, column) => {
372
+ const columnPx = (column + 1 < kept.length ? kept[column + 1].x : endX) - x;
373
+ const major = isMajor(date, column);
374
+ const label = major ? formatTickLabel(unit, date, column, locale) : undefined;
375
+ const labelPx =
376
+ label === undefined ? 0 : estimateTextWidth(label, TIMELINE_TICK_LABEL_FONT_SIZE_PX);
377
+ const isNarrowed = isCalendarAligned(unit)
378
+ ? column === 0 || column === kept.length - 1
379
+ : columnPx < fullUnitPx - NARROW_COLUMN_TOLERANCE_PX;
380
+ const isShown = label !== undefined && !(labelPx > columnPx && isNarrowed);
381
+ return { x, columnPx, major, label, labelPx, isNarrowed, isShown };
382
+ });
383
+ if (!isCalendarAligned(unit)) restoreLabelsInNarrowRuns(columns);
384
+
385
+ const ticks: PositionedTick[] = columns.map((c) => ({
386
+ x: c.x,
387
+ labelX: c.x + c.columnPx / 2,
388
+ major: c.major,
389
+ label: c.isShown ? c.label : undefined,
390
+ }));
391
+ if (end > start) {
392
+ ticks.push({
393
+ x: endX,
394
+ labelX: undefined,
395
+ major: isMajor(end, kept.length),
396
+ label: undefined,
397
+ });
398
+ }
399
+ return ticks;
400
+ }
401
+
402
+ interface TickColumn {
403
+ x: number;
404
+ columnPx: number;
405
+ major: boolean;
406
+ label: string | undefined;
407
+ labelPx: number;
408
+ isNarrowed: boolean;
409
+ /** False when thinned or dropped by the narrow-column rule. */
410
+ isShown: boolean;
411
+ }
412
+
413
+ /**
414
+ * Days and weeks: inside a run of two or more consecutive narrowed
415
+ * columns (specs/working-calendar.md §7.2), a label the narrow-column rule
416
+ * dropped comes back when, centred on its column, it fits the column plus
417
+ * half of each neighbouring column whose label is absent (thinned or
418
+ * dropped). A neighbour with a label, or the chart edge, lends nothing.
419
+ *
420
+ * A bounded recurrence (Summer Fridays) narrows every week in its window,
421
+ * so dropping each label blanks the axis for the whole stretch. Read left
422
+ * to right, the run's labels alternate instead: the first one after a
423
+ * labelled full week stays dropped, the next borrows from it, and so on.
424
+ * A label that needs to borrow cannot sit next to a restored one, so the
425
+ * greedy pass never has to revisit a decision.
426
+ *
427
+ * A lone narrowed column (one holiday week) is not a run and keeps the
428
+ * plain rule. So does a run of two at `label-every` 1: each of its columns
429
+ * has a labelled full week on one side.
430
+ */
431
+ function restoreLabelsInNarrowRuns(columns: TickColumn[]): void {
432
+ const roomBeside = (j: number): number => {
433
+ const neighbour = columns[j];
434
+ if (neighbour === undefined || neighbour.isShown) return 0;
435
+ return neighbour.columnPx / 2;
436
+ };
437
+ columns.forEach((c, i) => {
438
+ if (c.label === undefined || c.isShown || !c.isNarrowed) return;
439
+ const isInRun = columns[i - 1]?.isNarrowed === true || columns[i + 1]?.isNarrowed === true;
440
+ if (!isInRun) return;
441
+ const halfLabel = c.labelPx / 2;
442
+ const halfColumn = c.columnPx / 2;
443
+ c.isShown =
444
+ halfLabel <= halfColumn + roomBeside(i - 1) &&
445
+ halfLabel <= halfColumn + roomBeside(i + 1);
446
+ });
447
+ }
448
+
449
+ /**
450
+ * The days scale's default majors: a week start (the recurring weekend's
451
+ * next day). Under `hide` a hidden week start has no column, so the week's
452
+ * first working day takes its label instead: a Monday holiday, or a
453
+ * recurrence that takes Mondays out for a stretch, would otherwise leave
454
+ * those weeks unlabelled.
455
+ */
456
+ function isFirstVisibleDayOfWeek(
457
+ date: Date,
458
+ calendar: WorkingCalendar,
459
+ showsNonWorking: boolean,
460
+ ): boolean {
461
+ const weekStart = calendar.weekStart as number;
462
+ if (showsNonWorking) return date.getUTCDay() === weekStart;
463
+ const sinceWeekStart = (date.getUTCDay() - weekStart + DAYS_PER_WEEK) % DAYS_PER_WEEK;
464
+ for (let back = 1; back <= sinceWeekStart; back++) {
465
+ if (calendar.isWorkingDay(addDays(date, -back))) return false;
466
+ }
467
+ return true;
468
+ }
469
+
470
+ /**
471
+ * True under `show` for the last candidate boundary at months and up when
472
+ * its closing column holds no working day (a Sunday Feb 1 before a window
473
+ * that ends Mon Feb 2). That column is merged into the one before it, as
474
+ * `hide` drops it for having zero width. A leading sliver keeps its tick.
475
+ */
476
+ function isShownEmptyClosingColumn(
477
+ scale: TimeScale,
478
+ unit: ScaleUnit,
479
+ candidates: ReadonlyArray<Date>,
480
+ i: number,
481
+ ): boolean {
482
+ const calendar = scale.calendar;
483
+ if (!scale.showsNonWorking || !calendar || !isCalendarAligned(unit)) return false;
484
+ if (i === 0 || i !== candidates.length - 1) return false;
485
+ const [start, end] = scale.domain;
486
+ return calendar.workingIndexOf(start, candidates[i]) >= calendar.workingIndexOf(start, end);
487
+ }
488
+
489
+ /**
490
+ * The window's non-working runs for the model (specs/working-calendar.md
491
+ * §7.5), or undefined when the window holds no non-working day (always
492
+ * the case on the identity path). Under `hide` a run has zero width at the
493
+ * x of the next working day. At the days scale a run is marked `seam` when
494
+ * it lies strictly inside the chart and no grid line falls at its x: no
495
+ * major tick there, and no tick at all when the minor grid is on. Under
496
+ * `show` a run spans its days instead; see `shownNonWorkingRun`.
497
+ */
498
+ export function buildNonWorkingRuns(
499
+ scale: TimeScale,
500
+ ticks: ReadonlyArray<PositionedTick>,
501
+ unit: ScaleUnit,
502
+ minorGrid: boolean,
503
+ ): PositionedNonWorkingRun[] | undefined {
504
+ const calendar = scale.calendar;
505
+ if (!calendar?.hasNonWorkingDays) return undefined;
506
+ const runs = calendar.nonWorkingRuns(scale.domain[0], scale.domain[1]);
507
+ if (runs.length === 0) return undefined;
508
+ const [left, right] = scale.range;
509
+ if (scale.showsNonWorking) return runs.map((run) => shownNonWorkingRun(scale, run, unit));
510
+ const hasGridLineAt = (x: number): boolean =>
511
+ ticks.some((t) => Math.abs(t.x - x) < SAME_X_TOLERANCE_PX && (t.major || minorGrid));
512
+ return runs.map((run) => {
513
+ const x = scale.forward(run.from);
514
+ const out: PositionedNonWorkingRun = {
515
+ x,
516
+ width: scale.forward(addDays(run.through, 1)) - x,
517
+ from: run.from,
518
+ through: run.through,
519
+ };
520
+ if (run.titles.length > 0) {
521
+ out.titles = run.titles;
522
+ out.label = nonWorkingRunLabel(run, calendar.workingWeekdays);
523
+ out.cut = true;
524
+ }
525
+ const isInside = x - left > SAME_X_TOLERANCE_PX && right - x > SAME_X_TOLERANCE_PX;
526
+ if (unit === 'days' && isInside && !hasGridLineAt(x)) out.seam = true;
527
+ return out;
528
+ });
529
+ }
530
+
531
+ /**
532
+ * The window's working exceptions for the model (specs/working-calendar.md
533
+ * §7.4), or undefined when there are none. Each sits at its first day's x,
534
+ * the same under either view since a working day always has width, clamped
535
+ * to the window for a run that starts before it.
536
+ */
537
+ export function buildWorkingExceptions(scale: TimeScale): PositionedWorkingException[] | undefined {
538
+ const runs = scale.calendar?.workingExceptionRuns(scale.domain[0], scale.domain[1]) ?? [];
539
+ if (runs.length === 0) return undefined;
540
+ const [left] = scale.range;
541
+ return runs.map((run) => ({
542
+ x: Math.max(left, scale.forward(run.from)),
543
+ from: run.from,
544
+ through: run.through,
545
+ titles: run.titles,
546
+ label: workingExceptionLabel(run),
547
+ }));
548
+ }
549
+
550
+ /**
551
+ * The marker-row label of a working exception: its titles joined with `, `,
552
+ * then ` · +Nd` for the N days it puts back, every day of the run.
553
+ */
554
+ export function workingExceptionLabel(
555
+ run: Pick<WorkingExceptionRun, 'from' | 'through' | 'titles'>,
556
+ ): string {
557
+ const days = daysBetween(run.from, run.through) + 1;
558
+ return `${run.titles.join(', ')}${RUN_LABEL_DAY_SEPARATOR}+${days}d`;
559
+ }
560
+
561
+ /** Separator between a run's titles and its day count: U+00B7 with a space each side. */
562
+ const RUN_LABEL_DAY_SEPARATOR = ' \u00b7 ';
563
+
564
+ /**
565
+ * The marker-row label of a titled run (specs/working-calendar.md §7.4):
566
+ * its titles joined with `, `, then ` · Nd`, where N counts the days of
567
+ * `[from, through]` the open-ended week would work, so the closure's cost
568
+ * reads in the DSL's working-day `d`. The suffix is dropped when N is 0.
569
+ */
570
+ export function nonWorkingRunLabel(
571
+ run: Pick<NonWorkingRun, 'from' | 'through' | 'titles'>,
572
+ workingWeekdays: ReadonlySet<number>,
573
+ ): string {
574
+ let workingDays = 0;
575
+ for (let day = run.from; day <= run.through; day = addDays(day, 1)) {
576
+ if (workingWeekdays.has(day.getUTCDay())) workingDays++;
577
+ }
578
+ const text = run.titles.join(', ');
579
+ return workingDays > 0 ? `${text}${RUN_LABEL_DAY_SEPARATOR}${workingDays}d` : text;
580
+ }
581
+
582
+ /**
583
+ * A run under `show` (specs/working-calendar.md §7.3): it spans its days,
584
+ * clamped to the window, and never has a seam. At the days and weeks
585
+ * scales, or when it has titles, it is flagged `band` for the renderer to
586
+ * shade; a plain weekend at months and up stays unpainted.
587
+ */
588
+ function shownNonWorkingRun(
589
+ scale: TimeScale,
590
+ run: NonWorkingRun,
591
+ unit: ScaleUnit,
592
+ ): PositionedNonWorkingRun {
593
+ const [left, right] = scale.range;
594
+ const x = Math.max(left, scale.forward(run.from));
595
+ const rightX = Math.min(right, scale.forward(addDays(run.through, 1)));
596
+ const out: PositionedNonWorkingRun = {
597
+ x,
598
+ width: rightX - x,
599
+ from: run.from,
600
+ through: run.through,
601
+ };
602
+ if (run.titles.length > 0) {
603
+ out.titles = run.titles;
604
+ if (scale.calendar) out.label = nonWorkingRunLabel(run, scale.calendar.workingWeekdays);
605
+ }
606
+ if (unit === 'days' || unit === 'weeks' || run.titles.length > 0) out.band = true;
607
+ return out;
608
+ }
609
+
610
+ /**
611
+ * Ticks at the chart's left edge, every real month / quarter / year
612
+ * start inside the window, and the chart's right edge. The first and
613
+ * last columns can be partial (a roadmap starting Jan 5 opens with a
614
+ * Jan 5 - Feb 1 column); each column is labelled from its own start
615
+ * date, so a partial column still names the month it sits in.
616
+ * Thinning (`labelEvery`) counts ticks from the left edge, as the
617
+ * fixed-stride generator does.
618
+ */
619
+ function buildCalendarAlignedTicks(
620
+ scale: TimeScale,
621
+ preset: ViewPreset,
622
+ unit: CalendarAlignedUnit,
623
+ locale: string,
624
+ ): PositionedTick[] {
625
+ const [start, end] = scale.domain;
626
+ const dates: Date[] = [start];
627
+ for (let d = nextUnitStart(start, unit); d < end; d = nextUnitStart(d, unit)) {
628
+ dates.push(d);
629
+ }
630
+ if (end > start) dates.push(end);
631
+ const xs = dates.map((d) => scale.forward(d));
632
+ const lastIndex = dates.length - 1;
633
+ return dates.map((date, i) => {
634
+ const x = xs[i];
635
+ const isMajor = i % preset.labelEvery === 0;
636
+ if (i === lastIndex) return { x, labelX: undefined, major: isMajor, label: undefined };
637
+ const columnPx = xs[i + 1] - x;
638
+ let label = isMajor ? formatTickLabel(unit, date, i, locale) : undefined;
639
+ // A partial edge column can be a sliver (a roadmap starting Mar
640
+ // 29, or `length:` ending just past a month start). Its centered
641
+ // label would spill past the chart edge, so drop the label when
642
+ // it doesn't fit; the tick and grid line stay.
643
+ const isEdgeColumn = i === 0 || i === lastIndex - 1;
644
+ if (
645
+ label !== undefined &&
646
+ isEdgeColumn &&
647
+ estimateTextWidth(label, TIMELINE_TICK_LABEL_FONT_SIZE_PX) > columnPx
648
+ ) {
649
+ label = undefined;
650
+ }
651
+ return { x, labelX: x + columnPx / 2, major: isMajor, label };
652
+ });
653
+ }
654
+
162
655
  function formatTickLabel(unit: ScaleUnit, date: Date, _index: number, locale: string): string {
163
656
  switch (unit) {
164
657
  case 'days':