@svgrid/enterprise 3.0.2 → 3.1.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 (364) hide show
  1. package/LICENSE +94 -24
  2. package/README.md +36 -4
  3. package/dist/SvAdvancedFilter.svelte +1 -1
  4. package/dist/SvAlertsManager.svelte +2 -1
  5. package/dist/SvBoard.svelte +2 -1
  6. package/dist/SvExportMenu.svelte +4 -1
  7. package/dist/SvExportMenu.svelte.d.ts +0 -13
  8. package/dist/SvExpressionEditor.svelte +3 -3
  9. package/dist/SvFormulaBar.svelte +94 -20
  10. package/dist/SvFormulaBar.svelte.d.ts +7 -0
  11. package/dist/SvGridAlerts.svelte +49 -9
  12. package/dist/SvGridBoard.svelte +13 -8
  13. package/dist/SvGridBulkEditDrawer.svelte +26 -2
  14. package/dist/SvGridEditPanel.svelte +3 -0
  15. package/dist/SvGridScheduler.svelte +69 -40
  16. package/dist/SvGridSelectionBar.svelte +10 -3
  17. package/dist/SvPivotDesigner.svelte +198 -40
  18. package/dist/SvPivotDesigner.svelte.d.ts +27 -20
  19. package/dist/SvSheet.svelte +4043 -243
  20. package/dist/SvSheet.svelte.d.ts +76 -3
  21. package/dist/SvSheetChartSetup.svelte +151 -0
  22. package/dist/SvSheetChartSetup.svelte.d.ts +18 -0
  23. package/dist/SvSheetComment.svelte +216 -31
  24. package/dist/SvSheetComment.svelte.d.ts +11 -11
  25. package/dist/SvSheetConditionalFormat.svelte +28 -31
  26. package/dist/SvSheetDataValidation.svelte +66 -42
  27. package/dist/SvSheetEditRanges.svelte +217 -0
  28. package/dist/SvSheetEditRanges.svelte.d.ts +15 -0
  29. package/dist/SvSheetErrors.svelte +125 -0
  30. package/dist/SvSheetErrors.svelte.d.ts +20 -0
  31. package/dist/SvSheetEvaluate.svelte +133 -0
  32. package/dist/SvSheetEvaluate.svelte.d.ts +15 -0
  33. package/dist/SvSheetFilterMenu.svelte +137 -34
  34. package/dist/SvSheetFilterMenu.svelte.d.ts +5 -0
  35. package/dist/SvSheetFindReplace.svelte +19 -19
  36. package/dist/SvSheetFormatCells.svelte +125 -81
  37. package/dist/SvSheetGoalSeek.svelte +17 -16
  38. package/dist/SvSheetInsertFunction.svelte +15 -13
  39. package/dist/SvSheetIteration.svelte +81 -0
  40. package/dist/SvSheetIteration.svelte.d.ts +10 -0
  41. package/dist/SvSheetLink.svelte +99 -0
  42. package/dist/SvSheetLink.svelte.d.ts +17 -0
  43. package/dist/SvSheetListPicker.svelte +4 -2
  44. package/dist/SvSheetListPicker.svelte.d.ts +0 -6
  45. package/dist/SvSheetManageRules.svelte +20 -18
  46. package/dist/SvSheetNameManager.svelte +17 -15
  47. package/dist/SvSheetPageSetup.svelte +159 -0
  48. package/dist/SvSheetPageSetup.svelte.d.ts +13 -0
  49. package/dist/SvSheetPasteSpecial.svelte +22 -20
  50. package/dist/SvSheetPivot.svelte +220 -0
  51. package/dist/SvSheetPivot.svelte.d.ts +21 -0
  52. package/dist/SvSheetProtectSheet.svelte +81 -0
  53. package/dist/SvSheetProtectSheet.svelte.d.ts +11 -0
  54. package/dist/SvSheetRemoveDuplicates.svelte +13 -10
  55. package/dist/SvSheetRibbon.svelte +104 -51
  56. package/dist/SvSheetSizeDialog.svelte +4 -2
  57. package/dist/SvSheetSort.svelte +218 -0
  58. package/dist/SvSheetSort.svelte.d.ts +28 -0
  59. package/dist/SvSheetSparklines.svelte +135 -0
  60. package/dist/SvSheetSparklines.svelte.d.ts +14 -0
  61. package/dist/SvSheetTable.svelte +160 -0
  62. package/dist/SvSheetTable.svelte.d.ts +24 -0
  63. package/dist/SvSheetTabs.svelte +161 -31
  64. package/dist/SvSheetTabs.svelte.d.ts +22 -8
  65. package/dist/SvSheetTextToColumns.svelte +20 -20
  66. package/dist/SvSheetValidationAlert.svelte +8 -6
  67. package/dist/advanced-filter-enable.js +1 -1
  68. package/dist/alerts/alert-engine.d.ts +6 -0
  69. package/dist/alerts/alert-engine.js +21 -4
  70. package/dist/alerts/alert-formats.js +2 -0
  71. package/dist/alerts/alert-types.d.ts +1 -1
  72. package/dist/alerts.d.ts +1 -1
  73. package/dist/alerts.js +2 -2
  74. package/dist/board.js +2 -2
  75. package/dist/bulk-edit.d.ts +3 -1
  76. package/dist/bulk-edit.js +1 -1
  77. package/dist/cdn/svgrid-enterprise.svelte-external.js +39827 -16429
  78. package/dist/designer/assets/GridMenus-8gTNssgV.js +8 -0
  79. package/dist/designer/assets/{SvChartMenu-D9hEeHar.js → SvChartMenu-BDoeqGz6.js} +1 -1
  80. package/dist/designer/assets/{SvDateTimePicker-DARvKUL2.js → SvDateTimePicker-DCPg7WGN.js} +1 -1
  81. package/dist/designer/assets/SvGridCellEditor-DMoWJjEI.js +4 -0
  82. package/dist/designer/assets/{SvGridChart-B8j1SSy3.js → SvGridChart-Cy1ATrWh.js} +2 -2
  83. package/dist/designer/assets/SvGridChartBuilder-eQ3mn0L9.js +1 -0
  84. package/dist/designer/assets/{SvGridChartPanel-pT6ITc2P.js → SvGridChartPanel-CATdVc9X.js} +3 -3
  85. package/dist/designer/assets/{SvGridChartView-bg281UA1.js → SvGridChartView-CmsZtCED.js} +1 -1
  86. package/dist/designer/assets/SvGridDropdown-Cjz0H-fI.js +1 -0
  87. package/dist/designer/assets/{SvTabs-UFCMTHXS.js → SvTabs-2mHC6oaz.js} +1 -1
  88. package/dist/designer/assets/cell-formatting-BbubJTVz.js +1 -0
  89. package/dist/designer/assets/{chart-cIPUsbM5.js → chart-Cn7MnNqA.js} +1 -1
  90. package/dist/designer/assets/{chart-panel-messages-DtTxpBUU.js → chart-panel-messages-Dz7PTIqT.js} +1 -1
  91. package/dist/designer/assets/{chart-summary-Czu_5epb.js → chart-summary-BD1qYB2R.js} +1 -1
  92. package/dist/designer/assets/{chart-trend-D5I5VEe8.js → chart-trend-B8xr5LuD.js} +1 -1
  93. package/dist/designer/assets/{dismissable-BO547MH9.js → dismissable-ChWRPkoa.js} +1 -1
  94. package/dist/designer/assets/{export-format-D-eP4b2l.js → export-format-DGckmgKr.js} +1 -1
  95. package/dist/designer/assets/{focus-trap-CAreAhlU.js → focus-trap-V2-6Fy9I.js} +1 -1
  96. package/dist/designer/assets/{index-DwU7CAMm.js → index-BSjv6Qec.js} +317 -315
  97. package/dist/designer/assets/index-_JVFxt02.css +1 -0
  98. package/dist/designer/assets/row-resize-BInFWCpr.js +1 -0
  99. package/dist/designer/assets/server-block-cache-CXJKkJpy.js +1 -0
  100. package/dist/designer/assets/src-DUQ4xE3x.js +58 -0
  101. package/dist/designer/assets/validate-C3B8QKsM.js +1 -0
  102. package/dist/designer/index.html +17 -16
  103. package/dist/export-pdf.d.ts +75 -0
  104. package/dist/export-pdf.js +216 -15
  105. package/dist/export.js +10 -2
  106. package/dist/gantt/SvGridGantt.svelte +2890 -0
  107. package/dist/gantt/SvGridGantt.svelte.d.ts +30 -0
  108. package/dist/gantt/gantt-config.d.ts +73 -0
  109. package/dist/gantt/gantt-config.js +1 -0
  110. package/dist/gantt/gantt-critical-path.d.ts +67 -0
  111. package/dist/gantt/gantt-critical-path.js +207 -0
  112. package/dist/gantt/gantt-model.d.ts +257 -0
  113. package/dist/gantt/gantt-model.js +651 -0
  114. package/dist/gantt/gantt-resources.d.ts +59 -0
  115. package/dist/gantt/gantt-resources.js +71 -0
  116. package/dist/gantt/gantt.d.ts +7 -0
  117. package/dist/gantt/gantt.js +36 -0
  118. package/dist/gantt/index.d.ts +15 -0
  119. package/dist/gantt/index.js +22 -0
  120. package/dist/gantt/timeline-arrows.d.ts +102 -0
  121. package/dist/gantt/timeline-arrows.js +181 -0
  122. package/dist/import.d.ts +2 -2
  123. package/dist/import.js +1 -1
  124. package/dist/index.d.ts +34 -7
  125. package/dist/index.js +44 -7
  126. package/dist/install.js +8 -1
  127. package/dist/license-core.d.ts +17 -1
  128. package/dist/license-core.js +16 -5
  129. package/dist/license.d.ts +18 -2
  130. package/dist/license.js +71 -6
  131. package/dist/node/studio.js +140 -8
  132. package/dist/pivot-enable.js +2 -2
  133. package/dist/scheduler-dependencies.d.ts +35 -0
  134. package/dist/scheduler-dependencies.js +36 -1
  135. package/dist/scheduler.js +2 -2
  136. package/dist/selection-bar.js +2 -2
  137. package/dist/server/SvGroupCell.svelte +213 -0
  138. package/dist/server/SvGroupCell.svelte.d.ts +62 -0
  139. package/dist/server/SvRowGroupPanel.svelte +322 -0
  140. package/dist/server/SvRowGroupPanel.svelte.d.ts +37 -0
  141. package/dist/server/enable.d.ts +17 -0
  142. package/dist/server/enable.js +49 -0
  143. package/dist/server/index.d.ts +22 -0
  144. package/dist/server/index.js +24 -0
  145. package/dist/server/messages.d.ts +45 -0
  146. package/dist/server/messages.js +35 -0
  147. package/dist/server/server-group-model.d.ts +104 -0
  148. package/dist/server/server-group-model.js +272 -0
  149. package/dist/server/server-pivot.d.ts +46 -0
  150. package/dist/server/server-pivot.js +57 -0
  151. package/dist/server/server-row-model.d.ts +547 -0
  152. package/dist/server/server-row-model.js +1551 -0
  153. package/dist/server/server-selection.d.ts +99 -0
  154. package/dist/server/server-selection.js +215 -0
  155. package/dist/server/svgrid-adapter.d.ts +155 -0
  156. package/dist/server/svgrid-adapter.js +202 -0
  157. package/dist/sheet/ast.d.ts +45 -1
  158. package/dist/sheet/ast.js +14 -0
  159. package/dist/sheet/auto-filter.d.ts +58 -4
  160. package/dist/sheet/auto-filter.js +159 -11
  161. package/dist/sheet/autocomplete.d.ts +9 -0
  162. package/dist/sheet/autocomplete.js +90 -4
  163. package/dist/sheet/cell-images.d.ts +39 -0
  164. package/dist/sheet/cell-images.js +90 -0
  165. package/dist/sheet/cell-types.d.ts +58 -0
  166. package/dist/sheet/cell-types.js +96 -0
  167. package/dist/sheet/coerce.d.ts +40 -0
  168. package/dist/sheet/coerce.js +170 -3
  169. package/dist/sheet/comments.d.ts +43 -9
  170. package/dist/sheet/comments.js +85 -27
  171. package/dist/sheet/conditional-formats.d.ts +29 -6
  172. package/dist/sheet/conditional-formats.js +37 -12
  173. package/dist/sheet/csv.d.ts +32 -0
  174. package/dist/sheet/csv.js +158 -0
  175. package/dist/sheet/culture.d.ts +87 -0
  176. package/dist/sheet/culture.js +247 -0
  177. package/dist/sheet/delta.d.ts +102 -0
  178. package/dist/sheet/delta.js +203 -0
  179. package/dist/sheet/deps.d.ts +45 -1
  180. package/dist/sheet/deps.js +77 -2
  181. package/dist/sheet/document.d.ts +106 -4
  182. package/dist/sheet/document.js +204 -3
  183. package/dist/sheet/engine.d.ts +67 -0
  184. package/dist/sheet/engine.js +19 -0
  185. package/dist/sheet/entry.d.ts +8 -4
  186. package/dist/sheet/entry.js +171 -21
  187. package/dist/sheet/error-check.d.ts +49 -0
  188. package/dist/sheet/error-check.js +83 -0
  189. package/dist/sheet/evaluate-steps.d.ts +48 -0
  190. package/dist/sheet/evaluate-steps.js +196 -0
  191. package/dist/sheet/evaluate.d.ts +67 -0
  192. package/dist/sheet/evaluate.js +725 -13
  193. package/dist/sheet/filter-files.d.ts +37 -0
  194. package/dist/sheet/filter-files.js +465 -0
  195. package/dist/sheet/format-store.d.ts +15 -0
  196. package/dist/sheet/format-store.js +11 -1
  197. package/dist/sheet/from-rows.d.ts +14 -0
  198. package/dist/sheet/from-rows.js +50 -0
  199. package/dist/sheet/function-catalog.d.ts +1 -1
  200. package/dist/sheet/function-catalog.js +296 -5
  201. package/dist/sheet/functions.js +363 -91
  202. package/dist/sheet/index.d.ts +35 -8
  203. package/dist/sheet/index.js +35 -8
  204. package/dist/sheet/links.d.ts +57 -0
  205. package/dist/sheet/links.js +174 -0
  206. package/dist/sheet/messages.d.ts +778 -0
  207. package/dist/sheet/messages.js +806 -0
  208. package/dist/sheet/number-format.d.ts +52 -5
  209. package/dist/sheet/number-format.js +465 -68
  210. package/dist/sheet/objects.d.ts +106 -0
  211. package/dist/sheet/objects.js +149 -0
  212. package/dist/sheet/ods-document.d.ts +44 -0
  213. package/dist/sheet/ods-document.js +1275 -0
  214. package/dist/sheet/open-file.d.ts +26 -0
  215. package/dist/sheet/open-file.js +56 -0
  216. package/dist/sheet/outline.d.ts +82 -0
  217. package/dist/sheet/outline.js +228 -0
  218. package/dist/sheet/packs/array.d.ts +16 -0
  219. package/dist/sheet/packs/array.js +172 -0
  220. package/dist/sheet/packs/database.d.ts +42 -0
  221. package/dist/sheet/packs/database.js +142 -0
  222. package/dist/sheet/packs/engineering.d.ts +2 -0
  223. package/dist/sheet/packs/engineering.js +296 -0
  224. package/dist/sheet/packs/financial.d.ts +5 -0
  225. package/dist/sheet/packs/financial.js +220 -0
  226. package/dist/sheet/packs/math-stats.d.ts +2 -0
  227. package/dist/sheet/packs/math-stats.js +372 -0
  228. package/dist/sheet/packs/special.d.ts +44 -0
  229. package/dist/sheet/packs/special.js +187 -0
  230. package/dist/sheet/packs/stats-dist.d.ts +2 -0
  231. package/dist/sheet/packs/stats-dist.js +785 -0
  232. package/dist/sheet/packs/text-date.d.ts +2 -0
  233. package/dist/sheet/packs/text-date.js +332 -0
  234. package/dist/sheet/packs/trig.d.ts +2 -0
  235. package/dist/sheet/packs/trig.js +73 -0
  236. package/dist/sheet/page-setup.d.ts +73 -0
  237. package/dist/sheet/page-setup.js +73 -0
  238. package/dist/sheet/parse.js +42 -10
  239. package/dist/sheet/paste-special.d.ts +22 -0
  240. package/dist/sheet/paste-special.js +14 -5
  241. package/dist/sheet/pivot-range.d.ts +160 -0
  242. package/dist/sheet/pivot-range.js +333 -0
  243. package/dist/sheet/presence.d.ts +73 -0
  244. package/dist/sheet/presence.js +79 -0
  245. package/dist/sheet/print.d.ts +104 -0
  246. package/dist/sheet/print.js +169 -0
  247. package/dist/sheet/protection.d.ts +55 -1
  248. package/dist/sheet/protection.js +55 -2
  249. package/dist/sheet/rects.d.ts +1 -1
  250. package/dist/sheet/refs.d.ts +48 -0
  251. package/dist/sheet/refs.js +155 -8
  252. package/dist/sheet/ribbon-icons.d.ts +1 -1
  253. package/dist/sheet/ribbon-icons.js +186 -0
  254. package/dist/sheet/ribbon.d.ts +1 -1
  255. package/dist/sheet/ribbon.js +324 -23
  256. package/dist/sheet/sheet-kinds.d.ts +74 -0
  257. package/dist/sheet/sheet-kinds.js +91 -0
  258. package/dist/sheet/shortcuts.d.ts +13 -3
  259. package/dist/sheet/shortcuts.js +63 -12
  260. package/dist/sheet/sort.d.ts +51 -0
  261. package/dist/sheet/sort.js +107 -0
  262. package/dist/sheet/sparklines.d.ts +111 -0
  263. package/dist/sheet/sparklines.js +204 -0
  264. package/dist/sheet/table-styles.d.ts +74 -0
  265. package/dist/sheet/table-styles.js +152 -0
  266. package/dist/sheet/tables.d.ts +19 -0
  267. package/dist/sheet/tables.js +31 -0
  268. package/dist/sheet/tokenize.d.ts +18 -1
  269. package/dist/sheet/tokenize.js +60 -9
  270. package/dist/sheet/validation.d.ts +16 -0
  271. package/dist/sheet/validation.js +37 -0
  272. package/dist/sheet/workbook.d.ts +148 -3
  273. package/dist/sheet/workbook.js +742 -31
  274. package/dist/sheet/xls-cfb.d.ts +32 -0
  275. package/dist/sheet/xls-cfb.js +290 -0
  276. package/dist/sheet/xls-document.d.ts +10 -0
  277. package/dist/sheet/xls-document.js +1243 -0
  278. package/dist/sheet/xls-formula.d.ts +40 -0
  279. package/dist/sheet/xls-formula.js +569 -0
  280. package/dist/sheet/xlsx-document.d.ts +108 -0
  281. package/dist/sheet/xlsx-document.js +1849 -0
  282. package/dist/sheet/xlsx-drawing.d.ts +99 -0
  283. package/dist/sheet/xlsx-drawing.js +420 -0
  284. package/dist/sheet-enable.js +6 -7
  285. package/dist/sheet-text.d.ts +7 -0
  286. package/dist/sheet-text.js +29 -0
  287. package/dist/sources/auth-supabase.js +2 -2
  288. package/dist/sources/realtime-supabase.js +2 -2
  289. package/dist/sources/rest.d.ts +6 -2
  290. package/dist/sources/rest.js +42 -7
  291. package/dist/sources/supabase.d.ts +10 -1
  292. package/dist/sources/supabase.js +73 -40
  293. package/dist/studio/emit-project.js +14 -1
  294. package/dist/studio/index.js +1 -1
  295. package/dist/studio/project.d.ts +11 -2
  296. package/dist/studio/project.js +5 -2
  297. package/dist/studio/ui-components.generated.js +107 -2
  298. package/dist/sveltekit/in-memory.d.ts +2 -2
  299. package/dist/sveltekit/in-memory.js +125 -4
  300. package/dist/sveltekit/index.d.ts +1 -1
  301. package/dist/sveltekit/index.js +1 -1
  302. package/dist/sveltekit/query-plan.d.ts +50 -4
  303. package/dist/sveltekit/query-plan.js +25 -3
  304. package/dist/sveltekit/sql-source.d.ts +6 -0
  305. package/dist/sveltekit/sql-source.js +34 -6
  306. package/dist/sveltekit/sql.d.ts +31 -0
  307. package/dist/sveltekit/sql.js +135 -25
  308. package/dist/sveltekit/transport.d.ts +10 -2
  309. package/dist/sveltekit/transport.js +30 -6
  310. package/dist/upgrade-prompt.d.ts +1 -1
  311. package/dist/version.d.ts +1 -1
  312. package/dist/version.js +1 -1
  313. package/dist/watermark.js +35 -2
  314. package/dist/wc/GridMenus-By9vPuq0.js +1088 -0
  315. package/dist/wc/SvChartMenu-CQNrQGT2.js +308 -0
  316. package/dist/wc/SvDateRangeInput-DR6m7bSM.js +199 -0
  317. package/dist/wc/SvDateTimePicker-D-vRnaMo.js +771 -0
  318. package/dist/wc/SvField-B_kurx_P.js +149 -0
  319. package/dist/wc/SvGrid.helpers-BdVDtjUm.js +135 -0
  320. package/dist/wc/SvGridCellEditor-Bnf1Kr5C.js +560 -0
  321. package/dist/wc/SvGridChart-BqUJh2pG.js +3572 -0
  322. package/dist/wc/SvGridChartBuilder-CGycC5cn.js +988 -0
  323. package/dist/wc/SvGridChartPanel-CRnizsHD.js +1746 -0
  324. package/dist/wc/SvGridChartView-D1pa_xMy.js +47 -0
  325. package/dist/wc/SvGridDropdown-45WsObLL.js +259 -0
  326. package/dist/wc/SvModal-DrfOYYyX.js +189 -0
  327. package/dist/wc/cell-formatting-CBfgahv1.js +86 -0
  328. package/dist/wc/chart-6OXlybVT.js +4341 -0
  329. package/dist/wc/chart-export-CeSADGfi.js +135 -0
  330. package/dist/wc/chart-export-pdf-BGko7MER.js +104 -0
  331. package/dist/wc/chart-summary-2m3ia1-n.js +137 -0
  332. package/dist/wc/chart-trend-CQOQtTMl.js +50 -0
  333. package/dist/wc/chart-validate-CD6ptiwg.js +180 -0
  334. package/dist/wc/client-DQIzjDBG.js +3064 -0
  335. package/dist/wc/column-resize-DUPgynvc.js +101 -0
  336. package/dist/wc/date-format-DS-z_h9v.js +1130 -0
  337. package/dist/wc/dismissable-CQ6XFXsk.js +45 -0
  338. package/dist/wc/editor-contract-peBDxir_.js +22 -0
  339. package/dist/wc/editor-registry-DxGKqcvx.js +24 -0
  340. package/dist/wc/focus-trap-Dbb2tJxb.js +65 -0
  341. package/dist/wc/list-option-DrRyivWp.js +82 -0
  342. package/dist/wc/popover-WsGlvtQv.js +69 -0
  343. package/dist/wc/positioning-BiKLfu1E.js +112 -0
  344. package/dist/wc/react.d.ts +41 -0
  345. package/dist/wc/react.js +90 -0
  346. package/dist/wc/rolldown-runtime-Dy4uBu1J.js +11 -0
  347. package/dist/wc/row-drag-touch-CycRR65n.js +93 -0
  348. package/dist/wc/row-resize-limG_N1z.js +111 -0
  349. package/dist/wc/sv-sheet-element.d.ts +97 -0
  350. package/dist/wc/sv-sheet-element.js +44213 -0
  351. package/dist/wc/svelte-virtualizer.svelte-CXtQy2TQ.js +530 -0
  352. package/dist/wc/validate-BqDthkNr.js +88 -0
  353. package/dist/wc/vue.d.ts +31 -0
  354. package/dist/wc/vue.js +75 -0
  355. package/package.json +30 -5
  356. package/dist/designer/assets/GridMenus-BlsvL_aU.js +0 -8
  357. package/dist/designer/assets/SvGridCellEditor-C7uQXYds.js +0 -4
  358. package/dist/designer/assets/SvGridChartBuilder-kvyIU_Rl.js +0 -1
  359. package/dist/designer/assets/SvGridDropdown-Bb3HH2Aj.js +0 -1
  360. package/dist/designer/assets/cell-formatting-wr5wm7US.js +0 -1
  361. package/dist/designer/assets/index-CMFgAoVV.css +0 -1
  362. package/dist/designer/assets/row-resize-DLktcqUK.js +0 -1
  363. package/dist/designer/assets/src-EdI3Xqds.js +0 -58
  364. package/dist/designer/assets/validate-h-aslRV9.js +0 -1
@@ -0,0 +1,2890 @@
1
+ <script
2
+ lang="ts"
3
+ generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData"
4
+ >
5
+ // Gantt renderer for SvGrid. Rendered in place of the table when the `gantt`
6
+ // prop is set. Like the Kanban board (SvGridBoard) and the calendar
7
+ // (SvGridScheduler) it is a pure *view of the grid*:
8
+ // - reads the grid's already filtered + sorted rows
9
+ // - resolves them into bars, nested into a work-breakdown tree
10
+ // - draws a task table beside a time chart, with dependency arrows
11
+ // - writes back only through callbacks, never mutating the rows
12
+ // The layout math lives in ./gantt-model (pure + unit-tested); the arrow
13
+ // geometry in ./timeline-arrows, shared with the scheduler's timeline.
14
+ //
15
+ // Scrolling is ONE container for both axes: the task pane sticks to the left
16
+ // and the axis header to the top. Two scrollers kept in sync is the usual
17
+ // shape for this layout and the usual source of a header that drifts a pixel
18
+ // behind its bars on a trackpad.
19
+ import type {
20
+ ColumnDef,
21
+ GanttConfig,
22
+ GanttDependency,
23
+ RowData,
24
+ TableFeatures,
25
+ } from "@svgrid/grid";
26
+ import {
27
+ portalToBody,
28
+ popIn,
29
+ createDismissableLayer,
30
+ onScrollOutside,
31
+ SvDrawer,
32
+ SvForm,
33
+ SvMenuList,
34
+ type FormField,
35
+ type FormFieldType,
36
+ type MenuItem,
37
+ } from "@svgrid/grid";
38
+ import {
39
+ cascade,
40
+ hasCycle,
41
+ violations,
42
+ type CascadeBound,
43
+ type SchedulerDependency,
44
+ } from "../scheduler-dependencies";
45
+ import { criticalPath, slackDays } from "./gantt-critical-path";
46
+ import { arrowHeadPath, dependencyArrows, nearestArrow, type BarRect } from "./timeline-arrows";
47
+ import {
48
+ ganttAxis,
49
+ ganttScale,
50
+ ganttTickWidth,
51
+ ganttTree,
52
+ isWorkingDay,
53
+ makeCalendar,
54
+ nodeIndex,
55
+ projectRange,
56
+ parseDay,
57
+ resolveTasks,
58
+ snapToWorkingDay,
59
+ startOfDay,
60
+ addDays,
61
+ visibleAnchor,
62
+ workingDays,
63
+ moveWorkingSpan,
64
+ startForWorkingDays,
65
+ type GanttNode,
66
+ type GanttTaskSpec,
67
+ type ResolvedTask,
68
+ } from "./gantt-model";
69
+ import {
70
+ overallocations,
71
+ resourceLoad,
72
+ type ResourceAssignment,
73
+ } from "./gantt-resources";
74
+ import type { GanttProConfig } from "./gantt-config";
75
+
76
+ let {
77
+ data,
78
+ columns,
79
+ gantt,
80
+ getRowId,
81
+ }: {
82
+ data: ReadonlyArray<TData>;
83
+ columns: Array<ColumnDef<TFeatures, TData>>;
84
+ gantt: GanttConfig<TFeatures, TData>;
85
+ getRowId?: (row: TData, index: number) => string;
86
+ } = $props();
87
+
88
+ const MS_DAY = 86_400_000;
89
+ /** Height of the two-row axis header, shared with the CSS. */
90
+ const axisHeadH = 48;
91
+ const ZOOMS = ["day", "week", "month", "quarter", "year"] as const;
92
+ type Zoom = (typeof ZOOMS)[number];
93
+
94
+ // The Pro fields are read through a cast: the renderer is registered untyped,
95
+ // so the free grid's GanttConfig stays unchanged. Mirrors the scheduler's
96
+ // `pcfg`. Only the fields Phase 1 honours are read here.
97
+ const pcfg = $derived(gantt as unknown as GanttProConfig<TFeatures, TData>);
98
+
99
+ // --- config with defaults ---
100
+ const rowH = $derived(Math.max(18, gantt.rowHeight ?? 32));
101
+ const barH = $derived(Math.max(8, rowH - 10));
102
+ const zoomLadder = $derived<ReadonlyArray<Zoom>>(
103
+ (gantt.zoomLevels?.length ? gantt.zoomLevels : ZOOMS) as ReadonlyArray<Zoom>,
104
+ );
105
+ let zoomOverride = $state<Zoom | null>(null);
106
+ const zoom = $derived<Zoom>(zoomOverride ?? (gantt.zoom as Zoom) ?? "week");
107
+ const weekStartsOn = $derived(gantt.weekStartsOn ?? 0);
108
+ const cal = $derived(makeCalendar(gantt.nonWorkingDays ?? [0, 6], gantt.holidays ?? []));
109
+ const summaryBars = $derived(gantt.summaryBars !== false);
110
+ const labelPosition = $derived(gantt.labelPosition ?? "inside");
111
+ const showNonWorking = $derived(gantt.showNonWorking !== false);
112
+ const showTodayLine = $derived(gantt.todayLine !== false);
113
+ const editable = $derived(gantt.editable === true);
114
+ const respectWorking = $derived(gantt.respectWorkingTime !== false);
115
+ const historyEnabled = $derived(gantt.history === true);
116
+
117
+ // The clock, read once a minute rather than per render, so the today line
118
+ // and the "today" tick stay right through a long session without making
119
+ // every derived value depend on Date.now().
120
+ let today = $state(startOfToday());
121
+ function startOfToday(): Date {
122
+ const n = new Date();
123
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
124
+ }
125
+ $effect(() => {
126
+ const id = setInterval(() => {
127
+ const next = startOfToday();
128
+ if (next.getTime() !== today.getTime()) today = next;
129
+ }, 60_000);
130
+ return () => clearInterval(id);
131
+ });
132
+
133
+ // --- stable per-row key (getRowId, else a synthetic id pinned to the row
134
+ // object) so any future drag overlay survives data adds / removes + re-sorts.
135
+ const synthetic = new WeakMap<object, string>();
136
+ let nextSynthetic = 0;
137
+ function key(row: TData): string {
138
+ if (getRowId) return getRowId(row, data.indexOf(row));
139
+ const obj = row as unknown as object;
140
+ let id = synthetic.get(obj);
141
+ if (!id) {
142
+ id = `gantt-${nextSynthetic++}`;
143
+ synthetic.set(obj, id);
144
+ }
145
+ return id;
146
+ }
147
+
148
+ // --- the edit overlay: never mutates the consumer's rows. Phase 1 is
149
+ // read-only, so it stays empty; the spec already reads through it so editing
150
+ // only has to write here.
151
+ let startOf = $state<Record<string, Date>>({});
152
+ let endOf = $state<Record<string, Date>>({});
153
+ let progressOf = $state<Record<string, number>>({});
154
+ let edits = $state<Record<string, Record<string, unknown>>>({});
155
+
156
+ function fieldValue(row: TData, field: string): unknown {
157
+ const e = edits[key(row)];
158
+ if (e && field in e) return e[field];
159
+ return (row as Record<string, unknown>)[field];
160
+ }
161
+
162
+ // --- columns lookup + default title field ---
163
+ const fieldColumns = $derived(columns.filter((c) => typeof c.field === "string"));
164
+ const titleField = $derived(
165
+ gantt.titleField ?? (fieldColumns[0]?.field as string | undefined),
166
+ );
167
+
168
+ // --- resolve rows into tasks, then into a tree ---
169
+ const spec = $derived.by<GanttTaskSpec<TData>>(() => ({
170
+ getKey: (r) => key(r),
171
+ getStart: (r) => startOf[key(r)] ?? (fieldValue(r, gantt.startField) as never),
172
+ getEnd: (r) =>
173
+ endOf[key(r)] ??
174
+ (gantt.endField ? (fieldValue(r, gantt.endField) as never) : undefined),
175
+ getDuration: (r) =>
176
+ gantt.durationField ? (fieldValue(r, gantt.durationField) as never) : undefined,
177
+ getTitle: (r) => (titleField ? String(fieldValue(r, titleField) ?? "") : ""),
178
+ getProgress: (r) =>
179
+ progressOf[key(r)] ??
180
+ (gantt.progressField ? (fieldValue(r, gantt.progressField) as never) : undefined),
181
+ getParent: (r) =>
182
+ gantt.parentField
183
+ ? (fieldValue(r, gantt.parentField) as string | null | undefined)
184
+ : undefined,
185
+ getMilestone: (r) =>
186
+ gantt.milestoneField ? !!fieldValue(r, gantt.milestoneField) : false,
187
+ getColor: (r) =>
188
+ gantt.colorField
189
+ ? ((fieldValue(r, gantt.colorField) as string | undefined) ?? gantt.color)
190
+ : gantt.color,
191
+ }));
192
+
193
+ const tasks = $derived(resolveTasks(data, spec, cal));
194
+
195
+ // Collapse state: the config owns it when `collapsed` is set, else we do.
196
+ let collapsedLocal = $state<Set<string>>(new Set());
197
+ const collapsed = $derived(
198
+ gantt.collapsed ? new Set(gantt.collapsed) : collapsedLocal,
199
+ );
200
+ function setCollapsed(next: Set<string>) {
201
+ if (!gantt.collapsed) collapsedLocal = next;
202
+ gantt.onCollapseChange?.([...next]);
203
+ }
204
+ function toggleCollapse(k: string) {
205
+ const next = new Set(collapsed);
206
+ if (next.has(k)) next.delete(k);
207
+ else next.add(k);
208
+ setCollapsed(next);
209
+ }
210
+ const parentKeys = $derived(
211
+ new Set(tasks.filter((t) => t.parentKey != null).map((t) => t.parentKey as string)),
212
+ );
213
+ const anyCollapsible = $derived(parentKeys.size > 0);
214
+ const allCollapsed = $derived(
215
+ anyCollapsible && [...parentKeys].every((k) => collapsed.has(k)),
216
+ );
217
+
218
+ // The tree is built from EVERY task, not the visible rows, so a collapsed
219
+ // phase still rolls its children up into its summary bar.
220
+ const nodes = $derived(ganttTree(tasks, collapsed));
221
+ /** The same tree with nothing folded: what the planning passes read, so a
222
+ * collapsed phase's tasks stay in the critical path and keep their slack. */
223
+ const NOTHING_COLLAPSED = new Set<string>();
224
+ const allNodes = $derived(collapsed.size ? ganttTree(tasks, NOTHING_COLLAPSED) : nodes);
225
+ const rowIndexOf = $derived(nodeIndex(nodes));
226
+ const anchorOf = $derived(visibleAnchor(nodes, tasks));
227
+ /** key -> its descendants: moving a phase moves everything under it, and a
228
+ * phase's slack and critical ring come from what is under it. */
229
+ const descendantsOf = $derived.by(() => {
230
+ const kids = new Map<string, string[]>();
231
+ for (const t of tasks) {
232
+ if (t.parentKey == null) continue;
233
+ (kids.get(t.parentKey) ?? kids.set(t.parentKey, []).get(t.parentKey)!).push(t.key);
234
+ }
235
+ const out = new Map<string, string[]>();
236
+ const walk = (k: string): string[] => {
237
+ const hit = out.get(k);
238
+ if (hit) return hit;
239
+ const acc: string[] = [];
240
+ out.set(k, acc); // guard a cycle: the partial list is already in place
241
+ for (const c of kids.get(k) ?? []) {
242
+ acc.push(c, ...walk(c));
243
+ }
244
+ return acc;
245
+ };
246
+ for (const t of tasks) walk(t.key);
247
+ return out;
248
+ });
249
+
250
+ // --- the axis, and the ONE function that turns a date into an x ---
251
+ const range = $derived(
252
+ projectRange(tasks, {
253
+ paddingDays: gantt.rangePaddingDays ?? 7,
254
+ today,
255
+ minDate: gantt.minDate ?? null,
256
+ maxDate: gantt.maxDate ?? null,
257
+ }),
258
+ );
259
+ const axis = $derived(
260
+ ganttAxis(range.start, range.end, zoom, { weekStartsOn, today: showTodayLine ? today : null }),
261
+ );
262
+ /** The axis at its preset's tick width - the width it scrolls at. */
263
+ const naturalPx = $derived(
264
+ Math.max(240, Math.round(axis.ticks.length * ganttTickWidth[zoom])),
265
+ );
266
+ /** Fold whole non-working days out of the axis (Pro). Only a day-granular
267
+ * zoom has weekend columns to fold, so coarser presets ignore it rather
268
+ * than silently shrinking a week tick by part of itself. */
269
+ const collapseOff = $derived(
270
+ pcfg.collapseWeekends === true && (zoom === "day" || zoom === "week"),
271
+ );
272
+ /** Width of the scroll container, for the fill rule below. */
273
+ let viewportW = $state(0);
274
+ /** The room beside the task pane (its 1px border included, so the chart can
275
+ * never come out a pixel wider than the pane and grow a scrollbar for it). */
276
+ const paneW = $derived.by(() => Math.floor(viewportW - (showTable ? tableW + 1 : 0)));
277
+ /** The chart's date <-> pixel mapping, and the only thing that owns it.
278
+ *
279
+ * A short plan at a coarse zoom is narrower than the pane, and a chart that
280
+ * stops two thirds of the way across leaves the rest blank, so when the
281
+ * natural width falls short the ticks stretch to fill the pane. Folded
282
+ * days keep their fixed gap width, which is why the folded case rebuilds
283
+ * from the working-day share rather than scaling the whole thing. */
284
+ const scaleOpts = $derived({
285
+ collapsed: collapseOff ? (d: Date) => !isWorkingDay(d, cal) : null,
286
+ gapPx: pcfg.collapsedGapPx ?? 12,
287
+ });
288
+ /** The width the scale is asked for: the natural one, or the pane's when
289
+ * the plan is narrower than that. */
290
+ const requestPx = $derived.by(() => {
291
+ const natural = ganttScale(axis.start, axis.end, naturalPx, scaleOpts);
292
+ if (paneW <= natural.totalPx) return naturalPx;
293
+ const gaps = natural.segments.filter((sg) => sg.collapsed).length;
294
+ const gapsPx = gaps * Math.max(0, scaleOpts.gapPx);
295
+ const workingPx = natural.totalPx - gapsPx;
296
+ if (workingPx <= 0) return naturalPx;
297
+ return Math.floor(((paneW - gapsPx) * naturalPx) / workingPx);
298
+ });
299
+ /**
300
+ * Pixels per millisecond, held for the length of a drag. The window is read
301
+ * through the drag overlay, so a bar dragged past the last finish grows it
302
+ * as it goes; with the pane-filling stretch above that would change the
303
+ * mapping under the pointer, the bar would jump further, the window grow
304
+ * again - a 12-day drag ran away to 40. Frozen, the axis still extends, at
305
+ * the same scale.
306
+ */
307
+ let frozenPxPerMs = $state<number | null>(null);
308
+ const scale = $derived.by(() => {
309
+ const totalMs = Math.max(1, axis.end.getTime() - axis.start.getTime());
310
+ const px = frozenPxPerMs != null ? Math.max(240, Math.round(frozenPxPerMs * totalMs)) : requestPx;
311
+ return ganttScale(axis.start, axis.end, px, scaleOpts);
312
+ });
313
+ function freezeScale() {
314
+ frozenPxPerMs = requestPx / Math.max(1, axis.end.getTime() - axis.start.getTime());
315
+ }
316
+ const axisPx = $derived(scale.totalPx);
317
+ /** Date -> x offset (px) in the chart body. Every bar, gridline, band, arrow
318
+ * anchor and the today line goes through this; nothing else maps a date. */
319
+ const xOf = $derived((d: Date) => scale.xOf(d));
320
+ /** A header cell's px span, from the percentages the axis reports. */
321
+ const pctDate = $derived(
322
+ (pct: number) => new Date(axis.start.getTime() + (pct / 100) * axis.totalMs),
323
+ );
324
+ const bodyH = $derived(Math.max(rowH, nodes.length * rowH));
325
+
326
+ // Non-working columns, as day bands. Under a day-granular axis these are
327
+ // whole days; a tick coarser than a day (week / month) is never wholly
328
+ // non-working, so the shading is simply skipped there rather than lying.
329
+ const shadeBands = $derived.by(() => {
330
+ if (zoom !== "day" && zoom !== "week") return [];
331
+ // Folded away, a run of non-working days is drawn as ONE narrow gap
332
+ // marker rather than a wide shaded column - that is the whole point of
333
+ // folding it, and a per-day band would just stripe the marker.
334
+ if (collapseOff) {
335
+ return scale.segments
336
+ .filter((sg) => sg.collapsed && sg.px > 0)
337
+ .map((sg) => ({ left: sg.x, width: sg.px, gap: true }));
338
+ }
339
+ if (!showNonWorking) return [];
340
+ const out: Array<{ left: number; width: number; gap: boolean }> = [];
341
+ for (const t of axis.ticks) {
342
+ if (isWorkingDay(t.start, cal)) continue;
343
+ const left = xOf(t.start);
344
+ out.push({ left, width: Math.max(1, xOf(t.end) - left), gap: false });
345
+ }
346
+ return out;
347
+ });
348
+ const todayX = $derived(
349
+ showTodayLine &&
350
+ today.getTime() >= axis.start.getTime() &&
351
+ today.getTime() < axis.end.getTime()
352
+ ? xOf(today)
353
+ : null,
354
+ );
355
+
356
+ // --- bars -----------------------------------------------------------------
357
+ type Bar = {
358
+ key: string;
359
+ node: GanttNode<TData>;
360
+ row: TData;
361
+ title: string;
362
+ kind: "task" | "summary" | "milestone";
363
+ left: number;
364
+ width: number;
365
+ progress: number;
366
+ color: string | undefined;
367
+ index: number;
368
+ start: Date;
369
+ end: Date;
370
+ };
371
+
372
+ /** The dates a row DRAWS at: a parent shows its rollup, a leaf its own. */
373
+ function drawnSpan(n: GanttNode<TData>): { start: Date; end: Date; progress: number } {
374
+ if (n.hasChildren && summaryBars && n.summary) return n.summary;
375
+ return { start: n.task.start, end: n.task.end, progress: n.task.progress };
376
+ }
377
+
378
+ const bars = $derived.by<Bar[]>(() =>
379
+ nodes.map((n, i) => {
380
+ const span = drawnSpan(n);
381
+ const left = xOf(span.start);
382
+ const isMilestone = n.task.milestone && !n.hasChildren;
383
+ const kind: Bar["kind"] = isMilestone
384
+ ? "milestone"
385
+ : n.hasChildren && summaryBars
386
+ ? "summary"
387
+ : "task";
388
+ return {
389
+ key: n.task.key,
390
+ node: n,
391
+ row: n.task.row,
392
+ title: n.task.title,
393
+ kind,
394
+ left,
395
+ width: Math.max(2, xOf(span.end) - left),
396
+ progress: span.progress,
397
+ color: n.task.color,
398
+ index: i,
399
+ start: span.start,
400
+ end: span.end,
401
+ };
402
+ }),
403
+ );
404
+
405
+ // --- dependency arrows ----------------------------------------------------
406
+ /** The links, from the flat list and / or a per-row field. `lag` is days on
407
+ * the Gantt and minutes in the shared cascade helpers, so it converts here. */
408
+ const depList = $derived.by<SchedulerDependency[]>(() => {
409
+ const out: SchedulerDependency[] = [];
410
+ const push = (d: GanttDependency) => {
411
+ if (!d || !d.from || !d.to || d.from === d.to) return;
412
+ out.push({
413
+ id: d.id,
414
+ from: String(d.from),
415
+ to: String(d.to),
416
+ type: d.type,
417
+ lag: (d.lag ?? 0) * 1440,
418
+ });
419
+ };
420
+ for (const d of gantt.dependencies ?? []) push(d);
421
+ const f = gantt.dependencyField;
422
+ if (f) {
423
+ for (const row of data) {
424
+ const raw = fieldValue(row, f);
425
+ if (!Array.isArray(raw)) continue;
426
+ const from = key(row);
427
+ for (const entry of raw) {
428
+ if (typeof entry === "string") {
429
+ push({ id: `${from}->${entry}`, from, to: entry });
430
+ } else if (entry && typeof entry === "object") {
431
+ const d = entry as GanttDependency;
432
+ push({ ...d, from: d.from ?? from, id: d.id ?? `${from}->${d.to}` });
433
+ }
434
+ }
435
+ }
436
+ }
437
+ return out;
438
+ });
439
+ const hasDeps = $derived(depList.length > 0);
440
+ const depTimes = $derived(
441
+ new Map(tasks.map((t) => [t.key, { start: t.start, end: t.end }])),
442
+ );
443
+ const depBad = $derived(
444
+ hasDeps ? new Set(violations(depTimes, depList).map((d) => d.id)) : new Set<string>(),
445
+ );
446
+ /** Half the width of a milestone's diamond: a square of side barH on its corner. */
447
+ const milestoneR = $derived(barH * Math.SQRT1_2);
448
+ const barRects = $derived.by(() => {
449
+ const m = new Map<string, BarRect>();
450
+ for (const b of bars) {
451
+ const midY = b.index * rowH + rowH / 2;
452
+ if (b.kind === "milestone") {
453
+ // The diamond is centred on its date, so an arrow lands on its
454
+ // corner, not at the centre where it would pierce the shape.
455
+ m.set(b.key, { left: b.left - milestoneR, right: b.left + milestoneR, midY, kind: "milestone" });
456
+ } else {
457
+ m.set(b.key, {
458
+ left: b.left,
459
+ right: b.left + b.width,
460
+ midY,
461
+ // Only a task bar is entered from above or below; a summary's
462
+ // spine is too thin to point a head at.
463
+ halfH: b.kind === "task" ? barH / 2 : undefined,
464
+ });
465
+ }
466
+ }
467
+ return m;
468
+ });
469
+ /** Arrows, with each end re-pointed at the visible row that stands for it -
470
+ * a link into a collapsed phase draws to that phase's summary bar. */
471
+ const arrows = $derived.by(() => {
472
+ if (!hasDeps) return [];
473
+ const resolved = depList
474
+ .map((d) => ({
475
+ id: d.id,
476
+ from: anchorOf.get(d.from) ?? d.from,
477
+ to: anchorOf.get(d.to) ?? d.to,
478
+ type: d.type,
479
+ }))
480
+ // A link between two tasks of the same collapsed phase re-anchors both
481
+ // ends on that phase's summary bar; drawn, it is a hook from the bar
482
+ // back to itself.
483
+ .filter((d) => d.from !== d.to);
484
+ return dependencyArrows(barRects, resolved, depBad, rowH);
485
+ });
486
+
487
+ // --- Gantt Pro: critical path, baselines, constraints ----------------------
488
+ // Read through `pcfg` (the untyped Pro cast) so the free grid's GanttConfig
489
+ // stays unchanged. Each is inert unless its field is configured.
490
+
491
+ /**
492
+ * The span every row DRAWS, keyed for the planning passes. A parent carries
493
+ * its rollup, so a link naming a phase means "after the whole phase" without
494
+ * the critical path needing a special case for it.
495
+ */
496
+ const drawnTimes = $derived(
497
+ new Map(allNodes.map((n) => [n.task.key, (({ start, end }) => ({ start, end }))(drawnSpan(n))])),
498
+ );
499
+
500
+ const criticalOn = $derived(pcfg.criticalPath === true && hasDeps);
501
+ const cpm = $derived(criticalOn ? criticalPath(drawnTimes, depList, respectWorking ? cal : undefined) : null);
502
+ const criticalKeys = $derived(cpm?.critical ?? new Set<string>());
503
+ /** The bars that draw the ring: the critical tasks, and every phase over
504
+ * one - folded, the summary bar is all that stands for the chain. */
505
+ const criticalBars = $derived.by(() => {
506
+ if (!criticalKeys.size) return criticalKeys;
507
+ const out = new Set(criticalKeys);
508
+ for (const [key, kids] of descendantsOf) {
509
+ if (kids.some((k) => criticalKeys.has(k))) out.add(key);
510
+ }
511
+ return out;
512
+ });
513
+ /** An arrow is critical when both of its ends are. */
514
+ const criticalArrows = $derived.by(() => {
515
+ if (!cpm) return new Set<string>();
516
+ const out = new Set<string>();
517
+ for (const d of depList) {
518
+ if (criticalKeys.has(d.from) && criticalKeys.has(d.to)) out.add(d.id);
519
+ }
520
+ return out;
521
+ });
522
+ $effect(() => {
523
+ if (!cpm) return;
524
+ pcfg.onCriticalPathChange?.([...cpm.critical]);
525
+ });
526
+
527
+ // --- baselines -------------------------------------------------------------
528
+ const baselineOn = $derived(!!pcfg.baselineStartField && !!pcfg.baselineEndField);
529
+ type Baseline = { left: number; width: number; varianceDays: number };
530
+ /** The originally agreed span for a row, as a ghost bar under its task. */
531
+ function baselineFor(n: GanttNode<TData>): Baseline | null {
532
+ if (!baselineOn) return null;
533
+ const bs = parseDay(fieldValue(n.task.row, pcfg.baselineStartField as string) as never);
534
+ const beRaw = fieldValue(n.task.row, pcfg.baselineEndField as string);
535
+ const be = parseDay(beRaw as never);
536
+ if (!bs || !be) return null;
537
+ // Same inclusive-date-only rule the task spans use.
538
+ const end =
539
+ typeof beRaw === "string" && /^\d{4}-\d{2}-\d{2}$/.test(beRaw)
540
+ ? addDays(startOfDay(be), 1)
541
+ : be;
542
+ const left = xOf(bs);
543
+ const span = drawnSpan(n);
544
+ return {
545
+ left,
546
+ width: Math.max(2, xOf(end) - left),
547
+ // Positive means late against the baseline, which is the direction that
548
+ // matters; the tooltip says which way.
549
+ varianceDays: Math.round((span.end.getTime() - end.getTime()) / MS_DAY),
550
+ };
551
+ }
552
+
553
+ // --- constraints -----------------------------------------------------------
554
+ const constraintOn = $derived(!!pcfg.constraintField && !!pcfg.constraintDateField);
555
+ type Constraint = { kind: string; date: Date };
556
+ function constraintOf(row: TData): Constraint | null {
557
+ if (!constraintOn) return null;
558
+ const kind = String(fieldValue(row, pcfg.constraintField as string) ?? "").toUpperCase();
559
+ if (!kind || kind === "ASAP") return null;
560
+ const date = parseDay(fieldValue(row, pcfg.constraintDateField as string) as never);
561
+ return date ? { kind, date: startOfDay(date) } : null;
562
+ }
563
+ /**
564
+ * The floor / ceiling each constraint puts on the cascade. `MSO` and `SNET`
565
+ * raise the floor; `MFO` and `FNLT` cap the finish; `SNLT` caps the start,
566
+ * which is the finish minus the duration.
567
+ */
568
+ const cascadeBounds = $derived.by<Map<string, CascadeBound> | undefined>(() => {
569
+ if (!constraintOn) return undefined;
570
+ const out = new Map<string, CascadeBound>();
571
+ for (const t of tasks) {
572
+ const c = constraintOf(t.row);
573
+ if (!c) continue;
574
+ const dur = t.end.getTime() - t.start.getTime();
575
+ switch (c.kind) {
576
+ case "MSO":
577
+ case "SNET":
578
+ out.set(t.key, { minStart: c.date });
579
+ break;
580
+ case "MFO":
581
+ case "FNLT":
582
+ out.set(t.key, { maxEnd: c.date });
583
+ break;
584
+ case "SNLT":
585
+ out.set(t.key, { maxEnd: new Date(c.date.getTime() + dur) });
586
+ break;
587
+ default:
588
+ break; // FNET / ALAP: nothing the forward cascade can enforce.
589
+ }
590
+ }
591
+ return out.size ? out : undefined;
592
+ });
593
+ /** Rows whose constraint their current dates already break. */
594
+ const constraintBroken = $derived.by(() => {
595
+ const out = new Set<string>();
596
+ if (!constraintOn) return out;
597
+ for (const t of tasks) {
598
+ const c = constraintOf(t.row);
599
+ if (!c) continue;
600
+ const late = t.end.getTime() > c.date.getTime();
601
+ const early = t.start.getTime() < c.date.getTime();
602
+ if (
603
+ ((c.kind === "MFO" || c.kind === "FNLT") && late) ||
604
+ (c.kind === "SNLT" && t.start.getTime() > c.date.getTime()) ||
605
+ ((c.kind === "MSO" || c.kind === "SNET") && early) ||
606
+ (c.kind === "FNET" && t.end.getTime() < c.date.getTime())
607
+ ) out.add(t.key);
608
+ }
609
+ return out;
610
+ });
611
+ const CONSTRAINT_LABEL: Record<string, string> = {
612
+ MSO: "Must start on",
613
+ MFO: "Must finish on",
614
+ SNET: "Start no earlier than",
615
+ SNLT: "Start no later than",
616
+ FNET: "Finish no earlier than",
617
+ FNLT: "Finish no later than",
618
+ ALAP: "As late as possible",
619
+ };
620
+ /** Whether a constraint names a FINISH, so its pin sits at the end of that day. */
621
+ const constraintPinsFinish = (kind: string) =>
622
+ kind === "MFO" || kind === "FNET" || kind === "FNLT";
623
+
624
+ // --- resource load (Pro) ---------------------------------------------------
625
+ // Who is booked on what, summed per axis column. Leaves only: a phase is its
626
+ // children, so counting it too would book its owner twice for the same work.
627
+ const resourceField = $derived(pcfg.resourceField ?? null);
628
+ const histoCfg = $derived(
629
+ pcfg.resourceHistogram === true ? {} : (pcfg.resourceHistogram || null),
630
+ );
631
+ const histoOn = $derived(histoCfg != null && resourceField != null);
632
+ function resourceOf(row: TData): string | null {
633
+ if (!resourceField) return null;
634
+ const v = (row as Record<string, unknown>)[resourceField];
635
+ return v == null || v === "" ? null : String(v);
636
+ }
637
+ const assignments = $derived.by<ResourceAssignment[]>(() => {
638
+ if (!histoOn) return [];
639
+ const out: ResourceAssignment[] = [];
640
+ for (const n of nodes) {
641
+ if (n.hasChildren) continue;
642
+ const r = resourceOf(n.task.row);
643
+ if (!r) continue;
644
+ const span = drawnSpan(n);
645
+ out.push({ key: n.task.key, resource: r, start: span.start, end: span.end });
646
+ }
647
+ return out;
648
+ });
649
+ const loadRows = $derived.by(() => {
650
+ if (!histoOn) return [];
651
+ const capField = histoCfg?.capacityField ?? null;
652
+ const capOf = (id: string): number => {
653
+ if (!capField) return 1;
654
+ const r = pcfg.resources?.find((x) => String(x.id) === id) as
655
+ | Record<string, unknown>
656
+ | undefined;
657
+ const v = r?.[capField];
658
+ return typeof v === "number" && v > 0 ? v : 1;
659
+ };
660
+ return resourceLoad(
661
+ assignments,
662
+ axis.ticks.map((t) => ({ start: t.start, end: t.end })),
663
+ { resources: pcfg.resources ?? null, capacityOf: capOf },
664
+ );
665
+ });
666
+ const overloaded = $derived(new Set(overallocations(loadRows)));
667
+ /** Height of one resource's strip, from the total the config asks for. */
668
+ const histoRowH = $derived(
669
+ Math.max(16, Math.round((histoCfg?.height ?? 88) / Math.max(1, loadRows.length))),
670
+ );
671
+ /** Column geometry, shared by every strip: one cell per axis tick. */
672
+ const histoCols = $derived(
673
+ axis.ticks.map((t) => {
674
+ const left = xOf(t.start);
675
+ return { left, width: Math.max(1, xOf(t.end) - left) };
676
+ }),
677
+ );
678
+
679
+ // --- editing --------------------------------------------------------------
680
+ // One drag state for every gesture: moving a bar, dragging either edge,
681
+ // sliding the progress grip, and drawing a link. They share a threshold, a
682
+ // commit and a history entry, so a fix to any of that lands in all of them.
683
+ // Mirrors SvGridScheduler's timeline drag, which is the same shape.
684
+ type DragMode = "move" | "resize-start" | "resize-end" | "progress" | "link";
685
+ type BarDrag = {
686
+ key: string;
687
+ bar: Bar;
688
+ mode: DragMode;
689
+ startX: number;
690
+ startY: number;
691
+ moved: boolean;
692
+ /** Where in the bar the pointer grabbed it, in ms. */
693
+ grabOffsetMs: number;
694
+ durationMs: number;
695
+ origStart: Date;
696
+ origEnd: Date;
697
+ origProgress: number;
698
+ /** For a parent: every descendant's original span, shifted with it. */
699
+ subtree: Array<{ key: string; start: Date; end: Date }>;
700
+ /** `link` mode only. */
701
+ fromEdge: "start" | "end";
702
+ pointer: { x: number; y: number };
703
+ /** Pixels the edge scroll could not scroll yet (pinned at an end of the
704
+ * axis); the bar is laid out as if the pointer had gone on that far. */
705
+ edgePx: number;
706
+ targetKey?: string;
707
+ targetEdge?: "start" | "end";
708
+ };
709
+ let drag = $state<BarDrag | null>(null);
710
+ /** Set on release so the click that ends a drag does not also open the drawer. */
711
+ let suppressClick = false;
712
+ /** Keys flashed red because an edit was refused (a cycle, a duplicate link). */
713
+ let refused = $state<Set<string>>(new Set());
714
+ let refusedTimer: ReturnType<typeof setTimeout> | undefined;
715
+ function refuse(keys: string[]) {
716
+ clearTimeout(refusedTimer);
717
+ refused = new Set(keys);
718
+ refusedTimer = setTimeout(() => (refused = new Set()), 320);
719
+ }
720
+
721
+ const DRAG_THRESHOLD = 3;
722
+
723
+ const taskByKey = $derived(new Map(tasks.map((t) => [t.key, t])));
724
+
725
+ /** The day a drag snaps to. Ends land on the NEXT midnight, so a bar covers
726
+ * whole days either way. */
727
+ const snapStart = (d: Date) => startOfDay(d);
728
+ const snapEnd = (d: Date) => {
729
+ const floor = startOfDay(d);
730
+ return floor.getTime() === d.getTime() ? floor : addDays(floor, 1);
731
+ };
732
+ /** Clamp a span to `minDate` / `maxDate` without changing its length. */
733
+ function clampSpan(start: Date, end: Date): { start: Date; end: Date } {
734
+ const min = gantt.minDate ? startOfDay(new Date(gantt.minDate as never)) : null;
735
+ const max = gantt.maxDate ? startOfDay(new Date(gantt.maxDate as never)) : null;
736
+ const len = end.getTime() - start.getTime();
737
+ let s = start;
738
+ if (min && s.getTime() < min.getTime()) s = min;
739
+ if (max && s.getTime() + len > max.getTime()) {
740
+ s = new Date(Math.max(min?.getTime() ?? -Infinity, max.getTime() - len));
741
+ }
742
+ return { start: s, end: new Date(s.getTime() + len) };
743
+ }
744
+
745
+ function beginDrag(e: PointerEvent, bar: Bar, mode: DragMode, fromEdge: "start" | "end" = "end") {
746
+ if (e.button !== 0 || !editable) return;
747
+ // A summary bar is a rollup: it moves its subtree, but it has no edges of
748
+ // its own to resize and no progress of its own to set.
749
+ if (bar.kind === "summary" && mode !== "move" && mode !== "link") return;
750
+ if (bar.kind === "milestone" && (mode === "resize-start" || mode === "resize-end" || mode === "progress")) return;
751
+ e.preventDefault();
752
+ e.stopPropagation();
753
+ suppressClick = false;
754
+ const sub =
755
+ mode === "move"
756
+ ? (descendantsOf.get(bar.key) ?? []).flatMap((k) => {
757
+ const t = taskByKey.get(k);
758
+ return t ? [{ key: k, start: t.start, end: t.end }] : [];
759
+ })
760
+ : [];
761
+ drag = {
762
+ key: bar.key,
763
+ bar,
764
+ mode,
765
+ startX: e.clientX,
766
+ startY: e.clientY,
767
+ moved: false,
768
+ grabOffsetMs: Math.max(0, (dateAtClientX(e.clientX)?.getTime() ?? bar.start.getTime()) - bar.start.getTime()),
769
+ durationMs: bar.end.getTime() - bar.start.getTime(),
770
+ origStart: bar.start,
771
+ origEnd: bar.end,
772
+ origProgress: bar.progress,
773
+ subtree: sub,
774
+ fromEdge,
775
+ pointer: { x: e.clientX, y: e.clientY },
776
+ edgePx: 0,
777
+ };
778
+ freezeScale();
779
+ // The hover card describes the bar as it was grabbed; after a move it
780
+ // would go on showing those dates until the pointer left the bar.
781
+ clearTimeout(tipTimer);
782
+ tipBar = null;
783
+ window.addEventListener("pointermove", onDragMove);
784
+ window.addEventListener("pointerup", onDragEnd, { once: true });
785
+ window.addEventListener("pointercancel", onDragCancel, { once: true });
786
+ window.addEventListener("keydown", onDragKey);
787
+ }
788
+ /** The browser took the pointer (a touch turned into a scroll): put
789
+ * everything back rather than leave a half-moved bar. */
790
+ function onDragCancel() {
791
+ cancelDrag();
792
+ }
793
+ /** Escape while the pointer is down: put everything back. */
794
+ function onDragKey(e: KeyboardEvent) {
795
+ if (e.key !== "Escape" || !drag) return;
796
+ e.preventDefault();
797
+ cancelDrag();
798
+ }
799
+
800
+ function onDragMove(e: PointerEvent) {
801
+ const d = drag;
802
+ if (!d) return;
803
+ if (!d.moved) {
804
+ if (
805
+ Math.abs(e.clientX - d.startX) < DRAG_THRESHOLD &&
806
+ Math.abs(e.clientY - d.startY) < DRAG_THRESHOLD
807
+ ) return;
808
+ d.moved = true;
809
+ }
810
+ d.pointer = { x: e.clientX, y: e.clientY };
811
+ applyPointer(d);
812
+ edgeScroll();
813
+ }
814
+
815
+ /** Lay the drag out for where the pointer is, plus the scroll debt the
816
+ * edge scroll still owes it (see `edgeScroll`). */
817
+ function applyPointer(d: BarDrag) {
818
+ const clientX = d.pointer.x + d.edgePx;
819
+
820
+ if (d.mode === "link") {
821
+ const hit = barAtPoint(d.pointer.x, d.pointer.y);
822
+ d.targetKey = hit?.key;
823
+ d.targetEdge = hit?.edge;
824
+ return;
825
+ }
826
+
827
+ const at = dateAtClientX(clientX);
828
+ if (!at) return;
829
+
830
+ if (d.mode === "progress") {
831
+ const el = barEl(d.key);
832
+ if (!el) return;
833
+ const r = el.getBoundingClientRect();
834
+ const frac = r.width > 0 ? (clientX - r.left) / r.width : 0;
835
+ // 5% steps: fine enough to mean something, coarse enough to hit.
836
+ d.origProgress = d.origProgress; // keep the undo value
837
+ progressOf[d.key] = Math.max(0, Math.min(100, Math.round((frac * 100) / 5) * 5));
838
+ return;
839
+ }
840
+
841
+ if (d.mode === "move") {
842
+ let ns = snapStart(new Date(at.getTime() - d.grabOffsetMs));
843
+ if (respectWorking) ns = nearestWorkingDay(ns);
844
+ const moved = movedSpan(ns, { start: d.origStart, end: d.origEnd });
845
+ const span = clampSpan(moved.start, moved.end);
846
+ applyMove(d, span.start.getTime() - d.origStart.getTime());
847
+ return;
848
+ }
849
+
850
+ if (d.mode === "resize-end") {
851
+ const ne = snapEnd(at);
852
+ // Never shorter than a day - a zero-width bar cannot be grabbed again.
853
+ endOf[d.key] = new Date(Math.max(ne.getTime(), d.origStart.getTime() + MS_DAY));
854
+ startOf[d.key] = d.origStart;
855
+ } else {
856
+ const ns = snapStart(at);
857
+ startOf[d.key] = new Date(Math.min(ns.getTime(), d.origEnd.getTime() - MS_DAY));
858
+ endOf[d.key] = d.origEnd;
859
+ }
860
+ }
861
+
862
+ /** The working day nearest a day-floored date: Saturday goes back to
863
+ * Friday, Sunday on to Monday. Snapping forward alone made a bar leap two
864
+ * days ahead of the pointer the moment it entered a weekend going right,
865
+ * and refuse to move until the pointer had cleared it going left. */
866
+ function nearestWorkingDay(d: Date): Date {
867
+ const fwd = snapToWorkingDay(d, 1, cal);
868
+ const back = snapToWorkingDay(d, -1, cal);
869
+ return d.getTime() - back.getTime() <= fwd.getTime() - d.getTime() ? back : fwd;
870
+ }
871
+
872
+ // --- scrolling the pane while a drag sits at its edge ----------------------
873
+ /** How close to the pane's edge the pointer has to be, in px. */
874
+ const EDGE_PX = 32;
875
+ let edgeRaf = 0;
876
+ /**
877
+ * A drag that reaches the edge of the pane keeps going: the pane scrolls
878
+ * under the pointer a little each frame until the pointer comes back in,
879
+ * and a link drag scrolls the rows as well. Pinned at an end of the axis,
880
+ * the pixels it could not scroll are kept as `edgePx` and the bar is laid
881
+ * out as if the pointer had gone on that far; the window grows to meet it
882
+ * and the debt is paid back in scroll as soon as there is room. Without
883
+ * this a link could only be drawn between two bars already on screen, and
884
+ * a bar could not be dragged past the pane's edge at all.
885
+ */
886
+ function edgeScroll() {
887
+ edgeRaf = 0;
888
+ const d = drag;
889
+ const el = scrollEl;
890
+ if (!d || !el || !d.moved) return;
891
+ const rect = el.getBoundingClientRect();
892
+ const paneLeft = rect.left + (showTable ? tableW : 0);
893
+ const { x, y } = d.pointer;
894
+ let dx = 0;
895
+ let dy = 0;
896
+ // A few px a frame, more the deeper into the zone: slow enough to stop
897
+ // on the day wanted, and it goes on for as long as the pointer stays.
898
+ const speed = (depth: number) => Math.min(8, 1 + depth / 4);
899
+ if (x > rect.right - EDGE_PX) dx = speed(x - (rect.right - EDGE_PX));
900
+ else if (x < paneLeft + EDGE_PX) dx = -speed(paneLeft + EDGE_PX - x);
901
+ if (d.mode === "link") {
902
+ if (y > rect.bottom - EDGE_PX) dy = speed(y - (rect.bottom - EDGE_PX));
903
+ else if (y < rect.top + axisHeadH + EDGE_PX) dy = -speed(rect.top + axisHeadH + EDGE_PX - y);
904
+ }
905
+ if (!dx && !dy && !d.edgePx) return;
906
+ const wantX = dx + d.edgePx;
907
+ const sl = el.scrollLeft;
908
+ const st = el.scrollTop;
909
+ if (wantX) el.scrollLeft = Math.max(0, sl + wantX);
910
+ if (dy) el.scrollTop = Math.max(0, st + dy);
911
+ const gotX = el.scrollLeft - sl;
912
+ // A link drag has nothing to grow the window with; a bar drag keeps the
913
+ // unscrolled remainder as debt.
914
+ d.edgePx = d.mode === "link" ? 0 : wantX - gotX;
915
+ const was = startOf[d.key]?.getTime();
916
+ applyPointer(d);
917
+ const stuck = gotX === 0 && el.scrollTop === st && startOf[d.key]?.getTime() === was;
918
+ // Nothing moved and nothing can (a maxDate, an empty axis): stop rather
919
+ // than spin until the pointer moves.
920
+ if (stuck && ++edgeStall > 12) {
921
+ edgeStall = 0;
922
+ return;
923
+ }
924
+ if (!stuck) edgeStall = 0;
925
+ edgeRaf = requestAnimationFrame(edgeScroll);
926
+ }
927
+ let edgeStall = 0;
928
+ function stopEdgeScroll() {
929
+ if (edgeRaf) cancelAnimationFrame(edgeRaf);
930
+ edgeRaf = 0;
931
+ edgeStall = 0;
932
+ }
933
+
934
+ /**
935
+ * The span a task keeps when it lands on `start`. With working time on it
936
+ * is the task's WORKING days laid out from there, so a Monday-to-Friday
937
+ * task dragged to Wednesday ends the Tuesday after rather than shrinking to
938
+ * three working days over the weekend; off, it is the calendar length.
939
+ */
940
+ function movedSpan(start: Date, orig: { start: Date; end: Date }): { start: Date; end: Date } {
941
+ if (respectWorking) return moveWorkingSpan(start, orig, cal);
942
+ return { start, end: new Date(start.getTime() + (orig.end.getTime() - orig.start.getTime())) };
943
+ }
944
+ /** Write a move of `deltaMs` into the overlay, for the bar AND its subtree. */
945
+ function applyMove(d: BarDrag, deltaMs: number) {
946
+ const own = movedSpan(new Date(d.origStart.getTime() + deltaMs), { start: d.origStart, end: d.origEnd });
947
+ startOf[d.key] = own.start;
948
+ endOf[d.key] = own.end;
949
+ for (const k of d.subtree) {
950
+ // Each task under a moved phase lands on a working day of its own and
951
+ // keeps its own working length; the phase's bar is their rollup.
952
+ let ks = new Date(k.start.getTime() + deltaMs);
953
+ if (respectWorking) ks = snapToWorkingDay(ks, 1, cal);
954
+ const span = movedSpan(ks, k);
955
+ startOf[k.key] = span.start;
956
+ endOf[k.key] = span.end;
957
+ }
958
+ }
959
+
960
+ function onDragEnd() {
961
+ window.removeEventListener("pointermove", onDragMove);
962
+ window.removeEventListener("pointercancel", onDragCancel);
963
+ window.removeEventListener("keydown", onDragKey);
964
+ stopEdgeScroll();
965
+ frozenPxPerMs = null;
966
+ const d = drag;
967
+ drag = null;
968
+ if (!d) return;
969
+ if (!d.moved) {
970
+ // A click, not a drag: leave the overlay alone so nothing moved.
971
+ if (d.mode === "progress") delete progressOf[d.key];
972
+ return;
973
+ }
974
+ suppressClick = true;
975
+
976
+ if (d.mode === "link") {
977
+ commitLink(d);
978
+ return;
979
+ }
980
+ if (d.mode === "progress") {
981
+ const next = progressOf[d.key] ?? d.origProgress;
982
+ gantt.onProgressChange?.({ row: d.bar.row, progress: next });
983
+ pushHistory({ kind: "progress", key: d.key, row: d.bar.row, before: { progress: d.origProgress }, after: { progress: next } });
984
+ return;
985
+ }
986
+
987
+ const ns = startOf[d.key] ?? d.origStart;
988
+ const ne = endOf[d.key] ?? d.origEnd;
989
+ const subtreeAfter = d.subtree.map((k) => ({
990
+ key: k.key,
991
+ start: startOf[k.key] ?? k.start,
992
+ end: endOf[k.key] ?? k.end,
993
+ }));
994
+
995
+ if (d.mode === "move") {
996
+ gantt.onTaskMove?.({
997
+ row: d.bar.row,
998
+ start: ns,
999
+ end: ne,
1000
+ subtree: subtreeAfter.length
1001
+ ? subtreeAfter.flatMap((k) => {
1002
+ const t = taskByKey.get(k.key);
1003
+ return t ? [{ row: t.row, start: k.start, end: k.end }] : [];
1004
+ })
1005
+ : undefined,
1006
+ });
1007
+ } else {
1008
+ gantt.onTaskResize?.({
1009
+ row: d.bar.row,
1010
+ start: ns,
1011
+ end: ne,
1012
+ edge: d.mode === "resize-start" ? "start" : "end",
1013
+ });
1014
+ }
1015
+
1016
+ const moves = cascadeFrom(d.key);
1017
+ pushHistory({
1018
+ kind: d.mode === "move" ? "move" : "resize",
1019
+ key: d.key,
1020
+ row: d.bar.row,
1021
+ edge: d.mode === "resize-start" ? "start" : d.mode === "resize-end" ? "end" : undefined,
1022
+ before: { start: d.origStart, end: d.origEnd, subtree: d.subtree },
1023
+ after: { start: ns, end: ne, subtree: subtreeAfter },
1024
+ cascaded: moves,
1025
+ });
1026
+ }
1027
+
1028
+ /** Cancel an active drag, restoring whatever it had written. */
1029
+ function cancelDrag() {
1030
+ const d = drag;
1031
+ if (!d) return;
1032
+ window.removeEventListener("pointermove", onDragMove);
1033
+ window.removeEventListener("pointercancel", onDragCancel);
1034
+ window.removeEventListener("keydown", onDragKey);
1035
+ stopEdgeScroll();
1036
+ frozenPxPerMs = null;
1037
+ drag = null;
1038
+ if (d.mode === "progress") {
1039
+ if (d.origProgress != null) progressOf[d.key] = d.origProgress;
1040
+ return;
1041
+ }
1042
+ startOf[d.key] = d.origStart;
1043
+ endOf[d.key] = d.origEnd;
1044
+ for (const k of d.subtree) {
1045
+ startOf[k.key] = k.start;
1046
+ endOf[k.key] = k.end;
1047
+ }
1048
+ }
1049
+
1050
+ /** The bar element for a key, for geometry the derived state does not carry.
1051
+ * Scans rather than building a selector: a row id is the consumer's, and may
1052
+ * hold quotes, spaces or anything else a selector would choke on. */
1053
+ function barEl(k: string): HTMLElement | null {
1054
+ if (!rootEl) return null;
1055
+ for (const el of rootEl.querySelectorAll<HTMLElement>(".sv-gantt-bar")) {
1056
+ if (el.dataset.key === k) return el;
1057
+ }
1058
+ return null;
1059
+ }
1060
+ /** Which bar (and which half of it) is under the pointer, for link drawing. */
1061
+ function barAtPoint(x: number, y: number): { key: string; edge: "start" | "end" } | undefined {
1062
+ const el = document.elementFromPoint(x, y)?.closest?.(".sv-gantt-bar") as HTMLElement | null;
1063
+ const k = el?.dataset.key;
1064
+ if (!k) return undefined;
1065
+ const r = el!.getBoundingClientRect();
1066
+ // A milestone is a date, not a span: a link into it is to that date.
1067
+ if (el!.classList.contains("sv-gantt-bar-milestone")) return { key: k, edge: "start" };
1068
+ return { key: k, edge: x < r.left + r.width / 2 ? "start" : "end" };
1069
+ }
1070
+
1071
+ // --- auto-reschedule -------------------------------------------------------
1072
+ const autoReschedule = $derived(gantt.autoReschedule ?? hasDeps);
1073
+ /**
1074
+ * Push successors forward so every link stays legal, writing the shifts into
1075
+ * the overlay and reporting them. Returns what moved, for the undo entry.
1076
+ */
1077
+ function cascadeFrom(key: string): Array<{ key: string; start: Date; end: Date; before: { start: Date; end: Date } }> {
1078
+ if (!autoReschedule || !hasDeps) return [];
1079
+ const times = new Map<string, { start: Date; end: Date }>();
1080
+ for (const t of tasks) times.set(t.key, { start: t.start, end: t.end });
1081
+ const shifts = cascade(times, depList, {
1082
+ // Only the successors of what moved: the moved task (and, for a phase,
1083
+ // its subtree) stays where it was put, even too early for a link -
1084
+ // that link is then drawn as violated, which is the feedback.
1085
+ from: [key, ...(descendantsOf.get(key) ?? [])],
1086
+ snapForward: respectWorking ? (d) => snapToWorkingDay(d, 1, cal) : undefined,
1087
+ bounds: cascadeBounds,
1088
+ // A pushed task keeps its working days, not its calendar span.
1089
+ endFor: respectWorking ? (start, orig) => moveWorkingSpan(start, orig, cal).end : undefined,
1090
+ startFor: respectWorking
1091
+ ? (end, orig) => {
1092
+ const days = workingDays(orig.start, orig.end, cal);
1093
+ return days > 0 ? startForWorkingDays(end, days, cal) : new Date(end.getTime() - (orig.end.getTime() - orig.start.getTime()));
1094
+ }
1095
+ : undefined,
1096
+ });
1097
+ if (!shifts.size) return [];
1098
+ const out: Array<{ key: string; start: Date; end: Date; before: { start: Date; end: Date } }> = [];
1099
+ for (const [k, t] of shifts) {
1100
+ const was = times.get(k);
1101
+ startOf[k] = t.start;
1102
+ endOf[k] = t.end;
1103
+ if (was) out.push({ key: k, start: t.start, end: t.end, before: was });
1104
+ }
1105
+ gantt.onDependenciesChange?.(out.map((m) => ({ id: m.key, start: m.start, end: m.end })));
1106
+ return out;
1107
+ }
1108
+
1109
+ // --- drawing a link --------------------------------------------------------
1110
+ function commitLink(d: BarDrag) {
1111
+ const to = d.targetKey;
1112
+ if (!to || to === d.key) return;
1113
+ const type =
1114
+ d.fromEdge === "end"
1115
+ ? d.targetEdge === "end" ? "FF" : "FS"
1116
+ : d.targetEdge === "end" ? "SF" : "SS";
1117
+ // A duplicate says nothing new; a cycle has no legal schedule at all, and
1118
+ // the cascade would have to ignore it anyway. Both flash rather than throw.
1119
+ const already = depList.some((x) => x.from === d.key && x.to === to);
1120
+ const candidate = { id: `dep-${d.key}-${to}`, from: d.key, to, type } as SchedulerDependency;
1121
+ if (already || hasCycle([...depList, candidate])) {
1122
+ refuse([d.key, to]);
1123
+ return;
1124
+ }
1125
+ gantt.onDependencyAdd?.({
1126
+ id: `dep-${d.key}-${to}-${Date.now()}`,
1127
+ from: d.key,
1128
+ to,
1129
+ type,
1130
+ });
1131
+ }
1132
+
1133
+ // --- undo / redo -----------------------------------------------------------
1134
+ type SpanState = { start?: Date; end?: Date; progress?: number; subtree?: Array<{ key: string; start: Date; end: Date }> };
1135
+ type HistCmd = {
1136
+ kind: "move" | "resize" | "progress";
1137
+ key: string;
1138
+ row: TData;
1139
+ edge?: "start" | "end";
1140
+ before: SpanState;
1141
+ after: SpanState;
1142
+ cascaded?: Array<{ key: string; start: Date; end: Date; before: { start: Date; end: Date } }>;
1143
+ };
1144
+ let undoStack: HistCmd[] = [];
1145
+ let redoStack: HistCmd[] = [];
1146
+ function pushHistory(cmd: HistCmd) {
1147
+ if (!historyEnabled) return;
1148
+ undoStack.push(cmd);
1149
+ if (undoStack.length > 100) undoStack.shift();
1150
+ redoStack = [];
1151
+ }
1152
+ /** Apply one side of a command, re-firing the callbacks so the data follows. */
1153
+ function applyState(cmd: HistCmd, s: SpanState, cascadeSide: "before" | "after") {
1154
+ if (cmd.kind === "progress") {
1155
+ if (s.progress != null) progressOf[cmd.key] = s.progress;
1156
+ gantt.onProgressChange?.({ row: cmd.row, progress: s.progress ?? 0 });
1157
+ return;
1158
+ }
1159
+ if (s.start) startOf[cmd.key] = s.start;
1160
+ if (s.end) endOf[cmd.key] = s.end;
1161
+ for (const k of s.subtree ?? []) {
1162
+ startOf[k.key] = k.start;
1163
+ endOf[k.key] = k.end;
1164
+ }
1165
+ // The cascade travelled with the edit, so it has to travel back with it.
1166
+ const moves: Array<{ id: string; start: Date; end: Date }> = [];
1167
+ for (const m of cmd.cascaded ?? []) {
1168
+ const t = cascadeSide === "after" ? { start: m.start, end: m.end } : m.before;
1169
+ startOf[m.key] = t.start;
1170
+ endOf[m.key] = t.end;
1171
+ moves.push({ id: m.key, start: t.start, end: t.end });
1172
+ }
1173
+ if (cmd.kind === "move") {
1174
+ gantt.onTaskMove?.({
1175
+ row: cmd.row,
1176
+ start: s.start!,
1177
+ end: s.end!,
1178
+ subtree: s.subtree?.flatMap((k) => {
1179
+ const t = taskByKey.get(k.key);
1180
+ return t ? [{ row: t.row, start: k.start, end: k.end }] : [];
1181
+ }),
1182
+ });
1183
+ } else {
1184
+ gantt.onTaskResize?.({ row: cmd.row, start: s.start!, end: s.end!, edge: cmd.edge ?? "end" });
1185
+ }
1186
+ if (moves.length) gantt.onDependenciesChange?.(moves);
1187
+ }
1188
+ function undo() {
1189
+ const cmd = undoStack.pop();
1190
+ if (!cmd) return;
1191
+ applyState(cmd, cmd.before, "before");
1192
+ redoStack.push(cmd);
1193
+ }
1194
+ function redo() {
1195
+ const cmd = redoStack.pop();
1196
+ if (!cmd) return;
1197
+ applyState(cmd, cmd.after, "after");
1198
+ undoStack.push(cmd);
1199
+ }
1200
+ $effect(() => {
1201
+ if (!historyEnabled) return;
1202
+ const onKey = (e: KeyboardEvent) => {
1203
+ if (!(e.ctrlKey || e.metaKey)) return;
1204
+ // Read the FOCUS rather than the event target: a key dispatched on the
1205
+ // window has a target that is not a Node, and `contains` throws on one.
1206
+ const active = document.activeElement as HTMLElement | null;
1207
+ // Never steal the shortcut from a field the user is typing in.
1208
+ if (
1209
+ active &&
1210
+ (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)
1211
+ ) return;
1212
+ // Only ours when the focus is inside this Gantt, or nowhere in particular.
1213
+ const mine = !active || active === document.body || !!rootEl?.contains(active);
1214
+ if (!mine) return;
1215
+ const k = e.key.toLowerCase();
1216
+ if (k === "z" && !e.shiftKey) {
1217
+ e.preventDefault();
1218
+ undo();
1219
+ } else if ((k === "z" && e.shiftKey) || k === "y") {
1220
+ e.preventDefault();
1221
+ redo();
1222
+ }
1223
+ };
1224
+ window.addEventListener("keydown", onKey);
1225
+ return () => window.removeEventListener("keydown", onKey);
1226
+ });
1227
+
1228
+ // --- keyboard on a focused bar ---------------------------------------------
1229
+ function onBarKey(e: KeyboardEvent, bar: Bar) {
1230
+ if (e.key === "Escape" && drag) {
1231
+ e.preventDefault();
1232
+ cancelDrag();
1233
+ return;
1234
+ }
1235
+ if (e.key === "Enter" || e.key === " ") {
1236
+ e.preventDefault();
1237
+ openDrawer(bar);
1238
+ return;
1239
+ }
1240
+ if (!editable) return;
1241
+ if (e.key === "Delete" || e.key === "Backspace") {
1242
+ if (!gantt.onTaskDelete) return;
1243
+ e.preventDefault();
1244
+ gantt.onTaskDelete(bar.row);
1245
+ return;
1246
+ }
1247
+ if (e.key === "+" || e.key === "-") {
1248
+ if (bar.kind !== "task") return;
1249
+ e.preventDefault();
1250
+ const next = Math.max(0, Math.min(100, Math.round(bar.progress) + (e.key === "+" ? 5 : -5)));
1251
+ progressOf[bar.key] = next;
1252
+ gantt.onProgressChange?.({ row: bar.row, progress: next });
1253
+ pushHistory({ kind: "progress", key: bar.key, row: bar.row, before: { progress: bar.progress }, after: { progress: next } });
1254
+ return;
1255
+ }
1256
+ const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
1257
+ if (!dir) return;
1258
+ e.preventDefault();
1259
+ const days = (e.shiftKey ? 7 : 1) * dir;
1260
+ const sub = (descendantsOf.get(bar.key) ?? []).flatMap((k) => {
1261
+ const t = taskByKey.get(k);
1262
+ return t ? [{ key: k, start: t.start, end: t.end }] : [];
1263
+ });
1264
+ if (e.altKey) {
1265
+ // Alt: stretch the finish instead of moving the whole bar.
1266
+ if (bar.kind !== "task") return;
1267
+ const ne = new Date(Math.max(addDays(bar.end, days).getTime(), bar.start.getTime() + MS_DAY));
1268
+ startOf[bar.key] = bar.start;
1269
+ endOf[bar.key] = ne;
1270
+ gantt.onTaskResize?.({ row: bar.row, start: bar.start, end: ne, edge: "end" });
1271
+ const moves = cascadeFrom(bar.key);
1272
+ pushHistory({ kind: "resize", key: bar.key, row: bar.row, edge: "end", before: { start: bar.start, end: bar.end }, after: { start: bar.start, end: ne }, cascaded: moves });
1273
+ return;
1274
+ }
1275
+ // Step a day (a week with Shift): a WORKING day when the plan respects
1276
+ // working time, so Right from Friday lands on Monday, not Saturday.
1277
+ let ns = addDays(bar.start, days);
1278
+ if (respectWorking) ns = snapToWorkingDay(ns, dir, cal);
1279
+ const moved = movedSpan(ns, { start: bar.start, end: bar.end });
1280
+ const span = clampSpan(moved.start, moved.end);
1281
+ const delta = span.start.getTime() - bar.start.getTime();
1282
+ if (delta === 0) return;
1283
+ const fake: BarDrag = {
1284
+ key: bar.key, bar, mode: "move", startX: 0, startY: 0, moved: true,
1285
+ grabOffsetMs: 0, durationMs: bar.end.getTime() - bar.start.getTime(),
1286
+ origStart: bar.start, origEnd: bar.end, origProgress: bar.progress,
1287
+ subtree: sub, fromEdge: "end", pointer: { x: 0, y: 0 }, edgePx: 0,
1288
+ };
1289
+ applyMove(fake, delta);
1290
+ const subAfter = sub.map((k) => ({ key: k.key, start: startOf[k.key] ?? k.start, end: endOf[k.key] ?? k.end }));
1291
+ gantt.onTaskMove?.({
1292
+ row: bar.row,
1293
+ start: startOf[bar.key] ?? span.start,
1294
+ end: endOf[bar.key] ?? span.end,
1295
+ subtree: subAfter.length
1296
+ ? subAfter.flatMap((k) => {
1297
+ const t = taskByKey.get(k.key);
1298
+ return t ? [{ row: t.row, start: k.start, end: k.end }] : [];
1299
+ })
1300
+ : undefined,
1301
+ });
1302
+ const moves = cascadeFrom(bar.key);
1303
+ pushHistory({
1304
+ kind: "move", key: bar.key, row: bar.row,
1305
+ before: { start: bar.start, end: bar.end, subtree: sub },
1306
+ after: { start: startOf[bar.key] ?? span.start, end: endOf[bar.key] ?? span.end, subtree: subAfter },
1307
+ cascaded: moves,
1308
+ });
1309
+ }
1310
+
1311
+ /** Double-clicking empty chart space creates a task on that day. */
1312
+ function onBodyDblClick(e: MouseEvent) {
1313
+ if (!editable || !gantt.onTaskAdd) return;
1314
+ if ((e.target as HTMLElement).closest(".sv-gantt-bar")) return;
1315
+ const at = dateAtClientX(e.clientX);
1316
+ if (!at) return;
1317
+ let start = startOfDay(at);
1318
+ if (respectWorking) start = snapToWorkingDay(start, 1, cal);
1319
+ // Under the pointer's row, so the new task lands in that phase.
1320
+ const body = (e.currentTarget as HTMLElement).getBoundingClientRect();
1321
+ const i = Math.floor((e.clientY - body.top) / rowH);
1322
+ const parent = nodes[i]?.task.parentKey ?? undefined;
1323
+ gantt.onTaskAdd(start, addDays(start, 1), parent ?? undefined);
1324
+ }
1325
+
1326
+ // --- the task table -------------------------------------------------------
1327
+ type TableCol = {
1328
+ id: string;
1329
+ label: string;
1330
+ width: number;
1331
+ kind: "field" | "duration" | "progress" | "slack";
1332
+ field?: string;
1333
+ numeric?: boolean;
1334
+ };
1335
+ /** Two columns a consumer can name in `tableColumns` without defining them. */
1336
+ const BUILT_IN: Record<string, TableCol> = {
1337
+ __duration: { id: "__duration", label: "Days", width: 70, kind: "duration", numeric: true },
1338
+ __progress: { id: "__progress", label: "Progress", width: 110, kind: "progress" },
1339
+ __slack: { id: "__slack", label: "Slack", width: 70, kind: "slack", numeric: true },
1340
+ };
1341
+ function colId(c: ColumnDef<TFeatures, TData>): string {
1342
+ return c.id ?? (c.field as string) ?? "";
1343
+ }
1344
+ const tableCols = $derived.by<TableCol[]>(() => {
1345
+ const fromDef = (c: ColumnDef<TFeatures, TData>): TableCol => ({
1346
+ id: colId(c),
1347
+ label: typeof c.header === "string" ? c.header : ((c.field as string) ?? ""),
1348
+ width: c.width ?? 140,
1349
+ kind: "field",
1350
+ field: c.field as string | undefined,
1351
+ });
1352
+ const want = gantt.tableColumns;
1353
+ if (!want) return fieldColumns.map(fromDef);
1354
+ const byId = new Map(fieldColumns.map((c) => [colId(c), c]));
1355
+ const out: TableCol[] = [];
1356
+ for (const id of want) {
1357
+ if (BUILT_IN[id]) out.push(BUILT_IN[id]!);
1358
+ else {
1359
+ const c = byId.get(id);
1360
+ if (c) out.push(fromDef(c));
1361
+ }
1362
+ }
1363
+ return out;
1364
+ });
1365
+ const tableColsWidth = $derived(tableCols.reduce((n, c) => n + c.width, 0))
1366
+ /**
1367
+ * The first column FLEXES and the rest keep their width, so the columns
1368
+ * always add up to the pane exactly. A `tableWidth` narrower than the columns
1369
+ * would otherwise spill them over the chart, and one wider would leave a gap
1370
+ * at the end of every row.
1371
+ */
1372
+ function colStyle(col: TableCol, i: number): string {
1373
+ return i === 0
1374
+ ? `flex:1 1 ${col.width}px; min-width:0`
1375
+ : `flex:none; width:${col.width}px`
1376
+ };
1377
+ let tableW = $state(0);
1378
+ let tableWSeeded = false;
1379
+ $effect(() => {
1380
+ // Seed once from the config (or the columns' own widths), then leave the
1381
+ // splitter in charge - re-seeding would undo every drag.
1382
+ if (tableWSeeded) return;
1383
+ tableWSeeded = true;
1384
+ tableW = gantt.tableWidth ?? Math.min(520, Math.max(180, tableColsWidth));
1385
+ });
1386
+ /** Whether the splitter has been dragged: after that the width is the user's. */
1387
+ let tableWTouched = false;
1388
+ let tableWFitted = false;
1389
+ $effect(() => {
1390
+ // Without a configured width the pane also gives way to the container:
1391
+ // capped at 40% of it once the container has a width (the first pass has
1392
+ // none). The chart is the point, and on a laptop a 520px table beside a
1393
+ // 660px container left it a sliver. The cap never squeezes the name
1394
+ // column - the one that flexes - under 120px: the trailing columns keep
1395
+ // their widths, so it is the name that would pay.
1396
+ if (tableWFitted || tableWTouched || gantt.tableWidth != null || viewportW <= 0) return;
1397
+ tableWFitted = true;
1398
+ const trailing = tableColsWidth - (tableCols[0]?.width ?? 0);
1399
+ tableW = Math.max(180, trailing + 120, Math.min(tableW, Math.floor(viewportW * 0.4)));
1400
+ });
1401
+ const showTable = $derived(tableCols.length > 0 && tableW > 0);
1402
+
1403
+ /** The board's display rule: dates read locally, everything else as text. */
1404
+ function fmt(value: unknown): string {
1405
+ if (value == null) return "";
1406
+ if (value instanceof Date) return value.toLocaleDateString();
1407
+ return String(value);
1408
+ }
1409
+ function cellText(col: TableCol, n: GanttNode<TData>): string {
1410
+ if (col.kind === "duration") {
1411
+ // A parent carries no dates of its own, so read the span it DRAWS -
1412
+ // otherwise every phase reports zero days.
1413
+ const span = drawnSpan(n);
1414
+ return String(workingDays(span.start, span.end, cal));
1415
+ }
1416
+ if (col.kind === "slack") {
1417
+ // Only meaningful with the critical path on; blank rather than a
1418
+ // confident zero when it is off.
1419
+ if (!cpm) return "";
1420
+ // A phase is in no link of its own, so the pass gives it the room
1421
+ // between its rollup and the project finish - 28 days over children
1422
+ // with none. It can slip only as far as its tightest task, so report
1423
+ // the least slack under it.
1424
+ if (n.hasChildren) {
1425
+ const kids = descendantsOf.get(n.task.key) ?? [];
1426
+ if (kids.length) return String(Math.min(...kids.map((k) => slackDays(cpm, k))));
1427
+ }
1428
+ return String(slackDays(cpm, n.task.key));
1429
+ }
1430
+ if (col.kind === "field" && col.field) return fmt(fieldValue(n.task.row, col.field));
1431
+ return "";
1432
+ }
1433
+
1434
+ // --- splitter -------------------------------------------------------------
1435
+ let splitDrag: { startX: number; startW: number } | null = null;
1436
+ function startSplit(e: PointerEvent) {
1437
+ if (e.button !== 0) return;
1438
+ e.preventDefault();
1439
+ splitDrag = { startX: e.clientX, startW: tableW };
1440
+ tableWTouched = true;
1441
+ window.addEventListener("pointermove", onSplitMove);
1442
+ window.addEventListener("pointerup", endSplit, { once: true });
1443
+ }
1444
+ function onSplitMove(e: PointerEvent) {
1445
+ if (!splitDrag) return;
1446
+ tableW = Math.max(0, Math.min(900, splitDrag.startW + (e.clientX - splitDrag.startX)));
1447
+ }
1448
+ function endSplit() {
1449
+ window.removeEventListener("pointermove", onSplitMove);
1450
+ splitDrag = null;
1451
+ }
1452
+
1453
+ // --- row windowing --------------------------------------------------------
1454
+ // Above the threshold only the rows near the viewport render. Rows are
1455
+ // absolutely positioned by index, so nothing else in the layout changes.
1456
+ const WINDOW_FROM = 300;
1457
+ const OVERSCAN = 20;
1458
+ let rootEl = $state<HTMLElement | null>(null);
1459
+ let scrollEl = $state<HTMLElement | null>(null);
1460
+ let scrollTop = $state(0);
1461
+ let viewportH = $state(0);
1462
+ function onScroll() {
1463
+ if (scrollEl) scrollTop = scrollEl.scrollTop;
1464
+ }
1465
+ const visibleBars = $derived.by(() => {
1466
+ if (bars.length <= WINDOW_FROM || viewportH <= 0) return bars;
1467
+ const first = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN);
1468
+ const last = Math.min(bars.length, Math.ceil((scrollTop + viewportH) / rowH) + OVERSCAN);
1469
+ return bars.slice(first, last);
1470
+ });
1471
+ const visibleRows = $derived.by(() => {
1472
+ if (nodes.length <= WINDOW_FROM || viewportH <= 0) {
1473
+ return nodes.map((n, i) => ({ node: n, index: i }));
1474
+ }
1475
+ const first = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN);
1476
+ const last = Math.min(nodes.length, Math.ceil((scrollTop + viewportH) / rowH) + OVERSCAN);
1477
+ return nodes.slice(first, last).map((n, i) => ({ node: n, index: first + i }));
1478
+ });
1479
+
1480
+ // --- zoom -----------------------------------------------------------------
1481
+ const zoomIndex = $derived(Math.max(0, zoomLadder.indexOf(zoom)));
1482
+ function setZoom(next: Zoom) {
1483
+ if (next === zoom) return;
1484
+ zoomOverride = next;
1485
+ gantt.onZoomChange?.(next);
1486
+ }
1487
+ function stepZoom(dir: -1 | 1) {
1488
+ // -1 zooms IN (a finer preset sits earlier in the ladder).
1489
+ const i = Math.max(0, Math.min(zoomLadder.length - 1, zoomIndex + dir));
1490
+ const next = zoomLadder[i];
1491
+ if (next) setZoom(next);
1492
+ }
1493
+ function onWheel(e: WheelEvent) {
1494
+ if (!(e.ctrlKey || e.metaKey)) return;
1495
+ e.preventDefault();
1496
+ // Keep the date under the pointer put: note it, change preset, re-scroll.
1497
+ const el = scrollEl;
1498
+ const before = el ? dateAtClientX(e.clientX) : null;
1499
+ stepZoom(e.deltaY > 0 ? 1 : -1);
1500
+ if (!el || !before) return;
1501
+ const offset = e.clientX - el.getBoundingClientRect().left;
1502
+ queueMicrotask(() => {
1503
+ el.scrollLeft = xOf(before) + (showTable ? tableW : 0) - offset;
1504
+ });
1505
+ }
1506
+ /**
1507
+ * The axis start the drag was last laid out against. A task dragged before
1508
+ * the plan's start grows the window on the left, which moves every x by the
1509
+ * days added; the scroll position moves by the same amount so nothing
1510
+ * changes under the pointer. Without it the origin moved, the pointer
1511
+ * mapped to an earlier day, the origin moved again: a three-day drag on
1512
+ * the first task ran off by forty-five days.
1513
+ */
1514
+ let dragAxisStart: Date | null = null;
1515
+ $effect(() => {
1516
+ const start = axis.start;
1517
+ const el = scrollEl;
1518
+ if (!drag) {
1519
+ dragAxisStart = null;
1520
+ return;
1521
+ }
1522
+ if (dragAxisStart && el && start.getTime() !== dragAxisStart.getTime()) {
1523
+ el.scrollLeft += xOf(dragAxisStart);
1524
+ }
1525
+ dragAxisStart = start;
1526
+ });
1527
+ function dateAtClientX(clientX: number): Date | null {
1528
+ if (!scrollEl) return null;
1529
+ const rect = scrollEl.getBoundingClientRect();
1530
+ const x = clientX - rect.left + scrollEl.scrollLeft - (showTable ? tableW : 0);
1531
+ return scale.dateAt(x);
1532
+ }
1533
+
1534
+ /** Open on today (or the first task) when the chart first has a width: a
1535
+ * third of the way into the chart pane, so most of what is on screen is
1536
+ * what comes next. The pane starts where the sticky task table ends, which
1537
+ * is why this measures from `paneW` and not the scroll container - centred
1538
+ * on the container, today landed at the chart's left edge. */
1539
+ let scrolledIn = false;
1540
+ $effect(() => {
1541
+ const el = scrollEl;
1542
+ if (!el || scrolledIn || !axisPx || paneW <= 0) return;
1543
+ const target = todayX ?? (bars.length ? bars[0]!.left : null);
1544
+ if (target == null) return;
1545
+ scrolledIn = true;
1546
+ let left = target - paneW / 3;
1547
+ // A plan that started a week or two ago would open with its first bars
1548
+ // hidden under the task table, and rows with no bar read as an empty
1549
+ // chart. When the first bar can share the pane with today, start there.
1550
+ const first = bars.length ? Math.min(...bars.map((b) => b.left)) : null;
1551
+ if (first != null && first < left && target - first < paneW * 0.8) left = first - 12;
1552
+ el.scrollLeft = Math.max(0, left);
1553
+ });
1554
+
1555
+ /** The window the chart spans, as its first and last header groups. */
1556
+ const rangeLabel = $derived.by(() => {
1557
+ // At the day preset the majors are weeks, labelled "7 - 13 Sep", and
1558
+ // joining two of those with another dash reads as four dates. Name the
1559
+ // window's own first and last day instead.
1560
+ if (zoom === "day") {
1561
+ const last = new Date(axis.end.getTime() - 1);
1562
+ const f = (d: Date) => d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
1563
+ return `${f(axis.start)} - ${f(last)}`;
1564
+ }
1565
+ const first = axis.majors[0]?.label
1566
+ const last = axis.majors[axis.majors.length - 1]?.label
1567
+ if (!first) return ""
1568
+ return !last || last === first ? first : `${first} - ${last}`
1569
+ })
1570
+
1571
+ // --- tooltip --------------------------------------------------------------
1572
+ const tooltipCfg = $derived(gantt.tooltip);
1573
+ const hasTooltip = $derived(!!tooltipCfg);
1574
+ const tooltipSnippet = $derived(typeof tooltipCfg === "function" ? tooltipCfg : undefined);
1575
+ let tipBar = $state<Bar | null>(null);
1576
+ let tipPos = $state({ x: 0, y: 0 });
1577
+ let tipTimer: ReturnType<typeof setTimeout> | undefined;
1578
+ function onBarEnter(e: MouseEvent, bar: Bar) {
1579
+ if (!hasTooltip) return;
1580
+ clearTimeout(tipTimer);
1581
+ const target = e.currentTarget as HTMLElement;
1582
+ tipTimer = setTimeout(() => {
1583
+ const r = target.getBoundingClientRect();
1584
+ tipPos = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top) };
1585
+ tipBar = bar;
1586
+ }, gantt.tooltipDelay ?? 400);
1587
+ }
1588
+ function onBarLeave() {
1589
+ clearTimeout(tipTimer);
1590
+ tipBar = null;
1591
+ }
1592
+ $effect(() => () => clearTimeout(tipTimer));
1593
+
1594
+ // --- the detail drawer ------------------------------------------------------
1595
+ // Built from the grid's own columns, like the scheduler's: the header becomes
1596
+ // the label and `editorType` the control, so a column set already describes
1597
+ // its edit form. Start / Finish / Progress are pinned first because they are
1598
+ // what a plan is about.
1599
+ const drawerCfg = $derived(
1600
+ typeof gantt.drawer === "object" ? gantt.drawer : undefined,
1601
+ );
1602
+ const hasDrawer = $derived(!!gantt.drawer);
1603
+ let drawerOpen = $state(false);
1604
+ let drawerBar = $state<Bar | null>(null);
1605
+ let drawerValues = $state<Record<string, unknown>>({});
1606
+ /** What the form opens with. `drawerValues` follows every keystroke; this
1607
+ * is the snapshot the form is seeded from, so it is not re-read mid-edit. */
1608
+ let drawerInitial = $state<Record<string, unknown>>({});
1609
+
1610
+ type DrawerCol = ColumnDef<TFeatures, TData> & { field: string };
1611
+ const drawerFieldCols = $derived.by<DrawerCol[]>(() => {
1612
+ const wanted = drawerCfg?.fields as ReadonlyArray<string> | undefined;
1613
+ const base = wanted?.length
1614
+ ? wanted
1615
+ .map((f) => fieldColumns.find((c) => c.field === f))
1616
+ .filter((c): c is DrawerCol => !!c)
1617
+ : (fieldColumns as DrawerCol[]);
1618
+ // The pinned date + progress rows already cover these.
1619
+ const pinned = new Set(
1620
+ [gantt.startField, gantt.endField, gantt.progressField].filter(Boolean) as string[],
1621
+ );
1622
+ return base.filter((c) => !pinned.has(c.field));
1623
+ });
1624
+
1625
+ function formType(t: string | undefined): FormFieldType {
1626
+ switch (t) {
1627
+ case "number": return "number";
1628
+ case "checkbox": return "checkbox";
1629
+ case "date":
1630
+ case "date-native":
1631
+ case "datetime":
1632
+ case "datetime-native": return "date";
1633
+ case "textarea": return "textarea";
1634
+ case "color": return "color";
1635
+ case "list":
1636
+ case "select": return "select";
1637
+ default: return "text";
1638
+ }
1639
+ }
1640
+ function drawerOptions(col: DrawerCol, row: TData) {
1641
+ const raw =
1642
+ typeof col.editorOptions === "function" ? col.editorOptions(row) : col.editorOptions;
1643
+ if (!raw || !Array.isArray(raw)) return undefined;
1644
+ const opts: ReadonlyArray<string | number | { value: string | number; label?: string }> = raw;
1645
+ return opts.map((o) =>
1646
+ typeof o === "object"
1647
+ ? { value: o.value, label: String(o.label ?? o.value) }
1648
+ : { value: o, label: String(o) },
1649
+ );
1650
+ }
1651
+
1652
+ const drawerFields = $derived.by<FormField[]>(() => {
1653
+ const bar = drawerBar;
1654
+ if (!bar) return [];
1655
+ const out: FormField[] = [
1656
+ { name: "__start", label: "Start", type: "date" },
1657
+ { name: "__end", label: "Finish", type: "date" },
1658
+ ];
1659
+ if (gantt.progressField) out.push({ name: "__progress", label: "Progress %", type: "number" });
1660
+ for (const col of drawerFieldCols) {
1661
+ out.push({
1662
+ name: col.field,
1663
+ label: typeof col.header === "string" ? col.header : col.field,
1664
+ type: formType(col.editorType as string | undefined),
1665
+ options: drawerOptions(col, bar.row),
1666
+ } satisfies FormField);
1667
+ }
1668
+ return out;
1669
+ });
1670
+ const drawerTitle = $derived(drawerBar?.title || "Task");
1671
+
1672
+ /** `yyyy-mm-dd` for a date input, in LOCAL time (never toISOString, which
1673
+ * shifts the day for anyone east or west of UTC). */
1674
+ function dayInput(d: Date): string {
1675
+ const p2 = (n: number) => String(n).padStart(2, "0");
1676
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
1677
+ }
1678
+
1679
+ function openDrawer(bar: Bar) {
1680
+ if (suppressClick) {
1681
+ suppressClick = false;
1682
+ return;
1683
+ }
1684
+ if (!hasDrawer) return;
1685
+ const next: Record<string, unknown> = {
1686
+ __start: dayInput(bar.start),
1687
+ // The form shows the INCLUSIVE finish, which is the date on the plan.
1688
+ __end: dayInput(lastDay(bar)),
1689
+ __progress: Math.round(bar.progress),
1690
+ };
1691
+ for (const col of drawerFieldCols) next[col.field] = fieldValue(bar.row, col.field);
1692
+ drawerInitial = next;
1693
+ drawerValues = { ...next };
1694
+ drawerBar = bar;
1695
+ drawerOpen = true;
1696
+ }
1697
+
1698
+ function saveDrawer(values: Record<string, unknown>) {
1699
+ const bar = drawerBar;
1700
+ if (!bar) return;
1701
+ drawerOpen = false;
1702
+ const patch: Record<string, unknown> = {};
1703
+ for (const col of drawerFieldCols) {
1704
+ if (values[col.field] !== undefined) patch[col.field] = values[col.field];
1705
+ }
1706
+
1707
+ const ns = parseDay(values.__start as never) ?? bar.start;
1708
+ const inclusiveEnd = parseDay(values.__end as never);
1709
+ // Back from the inclusive finish the form shows to the exclusive end the
1710
+ // model uses.
1711
+ const ne = inclusiveEnd ? addDays(startOfDay(inclusiveEnd), 1) : bar.end;
1712
+ const movedDates =
1713
+ ns.getTime() !== bar.start.getTime() || ne.getTime() !== bar.end.getTime();
1714
+
1715
+ if (movedDates && ne.getTime() > ns.getTime()) {
1716
+ startOf[bar.key] = ns;
1717
+ endOf[bar.key] = ne;
1718
+ if (gantt.startField) patch[gantt.startField] = ns;
1719
+ if (gantt.endField) patch[gantt.endField] = ne;
1720
+ }
1721
+ if (gantt.progressField && values.__progress != null) {
1722
+ const pc = Math.max(0, Math.min(100, Number(values.__progress) || 0));
1723
+ progressOf[bar.key] = pc;
1724
+ patch[gantt.progressField] = pc;
1725
+ }
1726
+ edits[bar.key] = { ...(edits[bar.key] ?? {}), ...patch };
1727
+ gantt.onTaskCommit?.({ row: bar.row, values: patch as Partial<TData> });
1728
+ if (movedDates) cascadeFrom(bar.key);
1729
+ }
1730
+
1731
+ function deleteFromDrawer() {
1732
+ const bar = drawerBar;
1733
+ if (!bar) return;
1734
+ drawerOpen = false;
1735
+ gantt.onTaskDelete?.(bar.row);
1736
+ }
1737
+
1738
+ // --- context menu (the board's dismissable-layer pattern) -------------------
1739
+ let menuOpen = $state(false);
1740
+ let menuItems = $state<MenuItem[]>([]);
1741
+ let menuPos = $state({ x: 0, y: 0 });
1742
+ let menuPanel = $state<HTMLElement | null>(null);
1743
+ function openMenu(e: MouseEvent, bar: Bar) {
1744
+ const items: MenuItem[] = [];
1745
+ if (hasDrawer) items.push({ label: "Edit", onSelect: () => openDrawer(bar) });
1746
+ if (editable && gantt.onTaskAdd) {
1747
+ items.push({
1748
+ label: "Add subtask",
1749
+ onSelect: () => gantt.onTaskAdd?.(bar.start, addDays(bar.start, 1), bar.key),
1750
+ });
1751
+ }
1752
+ if (editable && gantt.onTaskDelete) {
1753
+ items.push({ label: "Delete", onSelect: () => gantt.onTaskDelete?.(bar.row) });
1754
+ }
1755
+ const custom = gantt.taskMenu?.(bar.row);
1756
+ if (custom?.length) items.push(...custom);
1757
+ if (!items.length) return;
1758
+ e.preventDefault();
1759
+ menuItems = items;
1760
+ menuPos = { x: e.clientX, y: e.clientY };
1761
+ menuOpen = true;
1762
+ }
1763
+ /** How close to an arrow a right-click has to land to mean that link. */
1764
+ const ARROW_HIT_PX = 6;
1765
+ let bodyEl = $state<HTMLElement | null>(null);
1766
+ /** Right-click on an arrow: offer to remove the link it draws. A bar
1767
+ * handles its own menu and stops here; anything else on the body is
1768
+ * hit-tested against the arrow geometry, since the arrows layer takes no
1769
+ * pointer events of its own. */
1770
+ function onBodyContextMenu(e: MouseEvent) {
1771
+ if (!editable || !gantt.onDependencyRemove || !bodyEl || !arrows.length) return;
1772
+ if ((e.target as HTMLElement | null)?.closest?.(".sv-gantt-bar")) return;
1773
+ const r = bodyEl.getBoundingClientRect();
1774
+ const hit = nearestArrow(arrows, e.clientX - r.left, e.clientY - r.top, ARROW_HIT_PX);
1775
+ if (!hit) return;
1776
+ const dep = depList.find((d) => d.id === hit.id);
1777
+ if (!dep) return;
1778
+ e.preventDefault();
1779
+ const nameOf = (k: string) => tasks.find((t) => t.key === k)?.title ?? k;
1780
+ menuItems = [
1781
+ {
1782
+ label: `Remove link: ${nameOf(dep.from)} \u2192 ${nameOf(dep.to)}`,
1783
+ onSelect: () => gantt.onDependencyRemove?.(dep.id),
1784
+ },
1785
+ ];
1786
+ menuPos = { x: e.clientX, y: e.clientY };
1787
+ menuOpen = true;
1788
+ }
1789
+ $effect(() => {
1790
+ if (!menuOpen) return;
1791
+ const layer = createDismissableLayer({
1792
+ element: () => menuPanel,
1793
+ onDismiss: () => (menuOpen = false),
1794
+ });
1795
+ layer.activate();
1796
+ // A menu pinned to a point has to go when the ground moves under it.
1797
+ const offScroll = onScrollOutside(
1798
+ () => menuPanel,
1799
+ () => (menuOpen = false),
1800
+ );
1801
+ return () => {
1802
+ layer.release();
1803
+ offScroll();
1804
+ };
1805
+ });
1806
+
1807
+ // --- labels ---------------------------------------------------------------
1808
+ const fmtDay = (d: Date) =>
1809
+ d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
1810
+ /** The INCLUSIVE last day of a bar, which is what a person reads off a plan.
1811
+ * `end` is exclusive, so a task ending Wed 00:00 finishes on Tuesday. */
1812
+ function lastDay(b: { start: Date; end: Date }): Date {
1813
+ if (b.end.getTime() <= b.start.getTime()) return b.start;
1814
+ return new Date(b.end.getTime() - MS_DAY);
1815
+ }
1816
+ function barRange(b: { start: Date; end: Date }): string {
1817
+ const from = fmtDay(b.start);
1818
+ const to = fmtDay(lastDay(b));
1819
+ return from === to ? from : `${from} - ${to}`;
1820
+ }
1821
+ function barAria(b: Bar): string {
1822
+ const days = workingDays(b.start, b.end, cal);
1823
+ if (b.kind === "milestone") return `${b.title || "Milestone"}, ${fmtDay(b.start)}`;
1824
+ return `${b.title}, ${barRange(b)}, ${days} ${days === 1 ? "day" : "days"}, ${Math.round(b.progress)}%`;
1825
+ }
1826
+ /** Rough px a label needs, to decide whether it fits inside its bar. */
1827
+ const labelFits = (b: Bar) => b.width >= (b.title.length + 2) * 6.6;
1828
+ function labelInside(b: Bar): boolean {
1829
+ if (labelPosition === "none" || b.kind === "milestone") return false;
1830
+ if (labelPosition === "right") return false;
1831
+ return b.kind !== "summary" && labelFits(b);
1832
+ }
1833
+ function labelOutside(b: Bar): boolean {
1834
+ return labelPosition !== "none" && !labelInside(b) && !!b.title;
1835
+ }
1836
+ </script>
1837
+
1838
+ <div
1839
+ class="sv-gantt"
1840
+ class:sv-gantt-editable={editable}
1841
+ bind:this={rootEl}
1842
+ style={`--gantt-row-h:${rowH}px; --gantt-bar-h:${barH}px; --gantt-table-w:${tableW}px`}
1843
+ >
1844
+ <div class="sv-gantt-toolbar">
1845
+ <span class="sv-gantt-title">{rangeLabel}</span>
1846
+ <div class="sv-gantt-tools">
1847
+ {#if anyCollapsible}
1848
+ <button
1849
+ type="button"
1850
+ class="sv-gantt-btn"
1851
+ onclick={() => setCollapsed(allCollapsed ? new Set() : new Set(parentKeys))}
1852
+ >{allCollapsed ? "Expand all" : "Collapse all"}</button>
1853
+ {/if}
1854
+ {#if zoomLadder.length > 1}
1855
+ <div class="sv-gantt-zoom">
1856
+ <button
1857
+ type="button"
1858
+ class="sv-gantt-btn"
1859
+ aria-label="Zoom out"
1860
+ disabled={zoomIndex >= zoomLadder.length - 1}
1861
+ onclick={() => stepZoom(1)}
1862
+ >-</button>
1863
+ <span class="sv-gantt-zoom-label">{zoom}</span>
1864
+ <button
1865
+ type="button"
1866
+ class="sv-gantt-btn"
1867
+ aria-label="Zoom in"
1868
+ disabled={zoomIndex <= 0}
1869
+ onclick={() => stepZoom(-1)}
1870
+ >+</button>
1871
+ </div>
1872
+ {/if}
1873
+ </div>
1874
+ </div>
1875
+
1876
+ <!-- One scroll container for BOTH axes; the task pane sticks left and the
1877
+ axis header sticks top, so neither can drift out of step with the bars. -->
1878
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1879
+ <div
1880
+ class="sv-gantt-scroll"
1881
+ bind:this={scrollEl}
1882
+ bind:clientHeight={viewportH}
1883
+ bind:clientWidth={viewportW}
1884
+ onscroll={onScroll}
1885
+ onwheel={onWheel}
1886
+ >
1887
+ <div class="sv-gantt-panes" style={`min-width:${(showTable ? tableW : 0) + axisPx}px`}>
1888
+ {#if showTable}
1889
+ <div class="sv-gantt-table" style={`width:${tableW}px`}>
1890
+ <div class="sv-gantt-table-head">
1891
+ {#each tableCols as col, ci (col.id)}
1892
+ <div
1893
+ class="sv-gantt-th"
1894
+ class:sv-gantt-num={col.numeric}
1895
+ style={colStyle(col, ci)}
1896
+ >{col.label}</div>
1897
+ {/each}
1898
+ </div>
1899
+ <div class="sv-gantt-table-body" style={`height:${bodyH}px`}>
1900
+ {#each visibleRows as { node, index } (node.task.key)}
1901
+ <div
1902
+ class="sv-gantt-tr"
1903
+ class:sv-gantt-parent={node.hasChildren}
1904
+ style={`top:${index * rowH}px`}
1905
+ >
1906
+ {#each tableCols as col, ci (col.id)}
1907
+ <div
1908
+ class="sv-gantt-td"
1909
+ class:sv-gantt-num={col.numeric}
1910
+ style={`${colStyle(col, ci)}${ci === 0 ? `; padding-left:${8 + node.depth * 14}px` : ""}`}
1911
+ >
1912
+ {#if ci === 0 && node.hasChildren}
1913
+ <button
1914
+ type="button"
1915
+ class="sv-gantt-chevron"
1916
+ class:sv-gantt-chevron-shut={node.collapsed}
1917
+ aria-expanded={!node.collapsed}
1918
+ aria-label={`${node.collapsed ? "Expand" : "Collapse"} ${node.task.title}`}
1919
+ onclick={() => toggleCollapse(node.task.key)}
1920
+ >&#9662;</button>
1921
+ {:else if ci === 0}
1922
+ <span class="sv-gantt-chevron-gap"></span>
1923
+ {/if}
1924
+ {#if col.kind === "progress"}
1925
+ <span class="sv-gantt-meter" aria-hidden="true">
1926
+ <span
1927
+ class="sv-gantt-meter-fill"
1928
+ style={`width:${Math.round(drawnSpan(node).progress)}%`}
1929
+ ></span>
1930
+ </span>
1931
+ <span class="sv-gantt-meter-pct">{Math.round(drawnSpan(node).progress)}%</span>
1932
+ {:else}
1933
+ <span class="sv-gantt-cell-text">{cellText(col, node)}</span>
1934
+ {/if}
1935
+ </div>
1936
+ {/each}
1937
+ </div>
1938
+ {/each}
1939
+ </div>
1940
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1941
+ <div
1942
+ class="sv-gantt-splitter"
1943
+ role="separator"
1944
+ aria-orientation="vertical"
1945
+ aria-label="Resize the task table"
1946
+ onpointerdown={startSplit}
1947
+ ></div>
1948
+ </div>
1949
+ {/if}
1950
+
1951
+ <div class="sv-gantt-chart" style={`width:${axisPx}px`}>
1952
+ <div class="sv-gantt-axis" style={`width:${axisPx}px`}>
1953
+ <div class="sv-gantt-majors">
1954
+ {#each axis.majors as m (m.leftPct)}
1955
+ <div
1956
+ class="sv-gantt-major"
1957
+ style={`left:${xOf(pctDate(m.leftPct))}px; width:${xOf(pctDate(m.leftPct + m.widthPct)) - xOf(pctDate(m.leftPct))}px`}
1958
+ ><span>{m.label}</span></div>
1959
+ {/each}
1960
+ </div>
1961
+ <div class="sv-gantt-ticks">
1962
+ {#each axis.ticks as t (t.start.getTime())}
1963
+ <div
1964
+ class="sv-gantt-tick"
1965
+ class:sv-gantt-tick-today={t.today}
1966
+ class:sv-gantt-tick-folded={collapseOff && !isWorkingDay(t.start, cal)}
1967
+ style={`left:${xOf(t.start)}px; width:${Math.max(1, xOf(t.end) - xOf(t.start))}px`}
1968
+ ><span>{t.label}</span></div>
1969
+ {/each}
1970
+ </div>
1971
+ </div>
1972
+
1973
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1974
+ <div
1975
+ class="sv-gantt-body"
1976
+ bind:this={bodyEl}
1977
+ style={`width:${axisPx}px; height:${bodyH}px`}
1978
+ ondblclick={onBodyDblClick}
1979
+ oncontextmenu={onBodyContextMenu}
1980
+ >
1981
+ {#each shadeBands as band, i (i)}
1982
+ <div
1983
+ class="sv-gantt-shade"
1984
+ class:sv-gantt-gap={band.gap}
1985
+ style={`left:${band.left}px; width:${band.width}px`}
1986
+ aria-hidden="true"
1987
+ ></div>
1988
+ {/each}
1989
+ {#each axis.ticks as t (t.start.getTime())}
1990
+ <div
1991
+ class="sv-gantt-gridline"
1992
+ style={`left:${xOf(t.start)}px`}
1993
+ aria-hidden="true"
1994
+ ></div>
1995
+ {/each}
1996
+
1997
+ {#if arrows.length}
1998
+ <!-- Dependency arrows over the bars. Pointer-transparent. -->
1999
+ <svg
2000
+ class="sv-gantt-deps"
2001
+ width={axisPx}
2002
+ height={bodyH}
2003
+ aria-hidden="true"
2004
+ >
2005
+ {#each arrows as a (a.id)}
2006
+ <path
2007
+ class="sv-gantt-dep-line"
2008
+ class:sv-gantt-dep-bad={a.bad}
2009
+ class:sv-gantt-dep-critical={criticalArrows.has(a.id)}
2010
+ d={a.d}
2011
+ />
2012
+ <path
2013
+ class="sv-gantt-dep-arrow"
2014
+ class:sv-gantt-dep-bad={a.bad}
2015
+ class:sv-gantt-dep-critical={criticalArrows.has(a.id)}
2016
+ d={arrowHeadPath(a.hx, a.hy, a.dir)}
2017
+ />
2018
+ {/each}
2019
+ </svg>
2020
+ {/if}
2021
+
2022
+ {#each visibleBars as b (b.key)}
2023
+ <div class="sv-gantt-row" style={`top:${b.index * rowH}px`}>
2024
+ {#if baselineOn}
2025
+ {@const bl = baselineFor(b.node)}
2026
+ {#if bl}
2027
+ <span
2028
+ class="sv-gantt-baseline"
2029
+ class:sv-gantt-baseline-late={bl.varianceDays > 0}
2030
+ style={`left:${bl.left}px; width:${bl.width}px`}
2031
+ title={bl.varianceDays === 0
2032
+ ? "On the baseline"
2033
+ : `${Math.abs(bl.varianceDays)} day${Math.abs(bl.varianceDays) === 1 ? "" : "s"} ${bl.varianceDays > 0 ? "later than" : "ahead of"} baseline`}
2034
+ ></span>
2035
+ {/if}
2036
+ {/if}
2037
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
2038
+ <div
2039
+ class="sv-gantt-bar sv-gantt-bar-{b.kind}"
2040
+ class:sv-gantt-bar-done={b.progress >= 100}
2041
+ class:sv-gantt-critical={criticalBars.has(b.key)}
2042
+ class:sv-gantt-constrained={constraintBroken.has(b.key)}
2043
+ class:sv-gantt-refused={refused.has(b.key)}
2044
+ class:sv-gantt-dragging={drag?.key === b.key && drag?.moved}
2045
+ class:sv-gantt-link-target={drag?.mode === "link" && drag?.targetKey === b.key}
2046
+ data-key={b.key}
2047
+ style={`left:${b.left}px; ${b.kind === "milestone" ? "" : `width:${b.width}px;`}${b.color ? ` --sv-gantt-accent:${b.color};` : ""}`}
2048
+ role="row"
2049
+ tabindex="0"
2050
+ aria-label={barAria(b)}
2051
+ onmouseenter={(e) => onBarEnter(e, b)}
2052
+ onmouseleave={onBarLeave}
2053
+ onpointerdown={(e) => beginDrag(e, b, "move")}
2054
+ onclick={() => openDrawer(b)}
2055
+ oncontextmenu={(e) => openMenu(e, b)}
2056
+ onkeydown={(e) => onBarKey(e, b)}
2057
+ >
2058
+ {#if b.kind !== "milestone"}
2059
+ <span
2060
+ class="sv-gantt-progress"
2061
+ style={`width:${Math.max(0, Math.min(100, b.progress))}%`}
2062
+ aria-hidden="true"
2063
+ ></span>
2064
+ {/if}
2065
+ {#if gantt.task && b.kind === "task"}
2066
+ <span class="sv-gantt-bar-body">{@render gantt.task(b.row)}</span>
2067
+ {:else if labelInside(b)}
2068
+ <span class="sv-gantt-bar-label">{b.title}</span>
2069
+ {/if}
2070
+ {#if editable}
2071
+ {#if b.kind === "task"}
2072
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2073
+ <span
2074
+ class="sv-gantt-grip sv-gantt-grip-l"
2075
+ onpointerdown={(e) => beginDrag(e, b, "resize-start")}
2076
+ ></span>
2077
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2078
+ <span
2079
+ class="sv-gantt-grip sv-gantt-grip-r"
2080
+ onpointerdown={(e) => beginDrag(e, b, "resize-end")}
2081
+ ></span>
2082
+ <!-- The progress handle rides the fill's leading edge. -->
2083
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2084
+ <span
2085
+ class="sv-gantt-grip-p"
2086
+ style={`left:${Math.max(0, Math.min(100, b.progress))}%`}
2087
+ title="Drag to set progress"
2088
+ onpointerdown={(e) => beginDrag(e, b, "progress")}
2089
+ ></span>
2090
+ {/if}
2091
+ {/if}
2092
+ </div>
2093
+ {#if editable}
2094
+ <!-- The link handles sit OUTSIDE the bar, so they cannot live
2095
+ inside it: the bar clips its children to ellipsis its
2096
+ label, which would clip these away entirely. -->
2097
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2098
+ <span
2099
+ class="sv-gantt-link-dot"
2100
+ style={`left:${b.left - (b.kind === "milestone" ? barH / 2 : 0) - 13}px`}
2101
+ title="Drag to link from the start"
2102
+ onpointerdown={(e) => beginDrag(e, b, "link", "start")}
2103
+ ></span>
2104
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2105
+ <span
2106
+ class="sv-gantt-link-dot"
2107
+ style={`left:${b.left + (b.kind === "milestone" ? barH / 2 : b.width) + 3}px`}
2108
+ title="Drag to link from the finish"
2109
+ onpointerdown={(e) => beginDrag(e, b, "link", "end")}
2110
+ ></span>
2111
+ {/if}
2112
+ {#if constraintOn}
2113
+ {@const c = constraintOf(b.row)}
2114
+ {#if c}
2115
+ <!-- A pin at the DATE the constraint names, not at the bar:
2116
+ "finish by the 25th" is a line on the 25th, and it stays
2117
+ put while the bar is dragged toward it. -->
2118
+ <span
2119
+ class="sv-gantt-constraint"
2120
+ class:sv-gantt-constraint-broken={constraintBroken.has(b.key)}
2121
+ style={`left:${xOf(constraintPinsFinish(c.kind) ? addDays(c.date, 1) : c.date)}px`}
2122
+ title={`${CONSTRAINT_LABEL[c.kind] ?? c.kind} ${fmtDay(c.date)} (${c.kind})`}
2123
+ aria-hidden="true"
2124
+ ></span>
2125
+ {/if}
2126
+ {/if}
2127
+ {#if (!gantt.task || b.kind !== "task") && labelOutside(b)}
2128
+ <span
2129
+ class="sv-gantt-label"
2130
+ style={`left:${b.left + (b.kind === "milestone" ? 12 : b.width) + 8}px`}
2131
+ >{b.title}</span>
2132
+ {/if}
2133
+ </div>
2134
+ {/each}
2135
+
2136
+ {#if drag?.mode === "link" && drag.moved}
2137
+ {@const from = barRects.get(drag.key)}
2138
+ {#if from}
2139
+ {@const ox = scrollEl?.getBoundingClientRect().left ?? 0}
2140
+ {@const oy = scrollEl?.getBoundingClientRect().top ?? 0}
2141
+ <svg class="sv-gantt-linking" width={axisPx} height={bodyH} aria-hidden="true">
2142
+ <path
2143
+ d={`M${drag.fromEdge === "end" ? from.right : from.left},${from.midY} L${
2144
+ drag.pointer.x - ox + (scrollEl?.scrollLeft ?? 0) - (showTable ? tableW : 0)
2145
+ },${drag.pointer.y - oy + (scrollEl?.scrollTop ?? 0) - axisHeadH}`}
2146
+ />
2147
+ </svg>
2148
+ {/if}
2149
+ {/if}
2150
+
2151
+ {#if todayX != null}
2152
+ <div class="sv-gantt-today" style={`left:${todayX}px`} aria-hidden="true"></div>
2153
+ {/if}
2154
+ </div>
2155
+
2156
+ {#if histoOn && loadRows.length}
2157
+ <!-- Resource load. Sticks to the bottom of the scroller so it stays
2158
+ in view down a long plan, and shares the chart's x scale so a
2159
+ spike sits under the week that caused it. -->
2160
+ <div class="sv-gantt-histo" style={`width:${axisPx}px`}>
2161
+ {#each loadRows as r (r.id)}
2162
+ {@const top = Math.max(r.peak, r.capacity)}
2163
+ <div
2164
+ class="sv-gantt-histo-row"
2165
+ class:sv-gantt-histo-over={overloaded.has(r.id)}
2166
+ style={`height:${histoRowH}px`}
2167
+ >
2168
+ <span class="sv-gantt-histo-label">
2169
+ {r.label}
2170
+ <small>peak {r.peak} / {r.capacity}</small>
2171
+ </span>
2172
+ {#each r.cells as c, i (i)}
2173
+ {#if c.load > 0}
2174
+ <div
2175
+ class="sv-gantt-histo-cell"
2176
+ class:sv-gantt-histo-cell-over={c.over}
2177
+ style={`left:${histoCols[i]?.left ?? 0}px; width:${histoCols[i]?.width ?? 0}px; height:${Math.round((c.load / top) * (histoRowH - 4))}px`}
2178
+ title={`${r.label}: ${c.load} of ${r.capacity}`}
2179
+ ></div>
2180
+ {/if}
2181
+ {/each}
2182
+ </div>
2183
+ {/each}
2184
+ </div>
2185
+ {/if}
2186
+ </div>
2187
+ </div>
2188
+ </div>
2189
+ </div>
2190
+
2191
+ {#if menuOpen}
2192
+ <div
2193
+ bind:this={menuPanel}
2194
+ class="sv-gantt-menu"
2195
+ use:portalToBody
2196
+ use:popIn={{}}
2197
+ style:position="fixed"
2198
+ style:top={`${menuPos.y}px`}
2199
+ style:left={`${menuPos.x}px`}
2200
+ aria-label="Task actions"
2201
+ >
2202
+ <SvMenuList
2203
+ items={menuItems}
2204
+ onclose={() => (menuOpen = false)}
2205
+ onselect={() => (menuOpen = false)}
2206
+ />
2207
+ </div>
2208
+ {/if}
2209
+
2210
+ {#if hasDrawer}
2211
+ <SvDrawer
2212
+ bind:open={drawerOpen}
2213
+ title={drawerTitle}
2214
+ side={drawerCfg?.side ?? "right"}
2215
+ size={drawerCfg?.size ?? "360px"}
2216
+ onClosed={() => (drawerBar = null)}
2217
+ >
2218
+ <SvForm
2219
+ fields={drawerFields}
2220
+ initial={drawerInitial}
2221
+ columns={drawerCfg?.columns ?? 1}
2222
+ submitLabel={drawerCfg?.submitLabel ?? "Save"}
2223
+ cancelLabel="Cancel"
2224
+ onSubmit={saveDrawer}
2225
+ onCancel={() => (drawerOpen = false)}
2226
+ onChange={(v) => (drawerValues = v)}
2227
+ />
2228
+ {#if gantt.onTaskDelete}
2229
+ <button type="button" class="sv-gantt-delete" onclick={deleteFromDrawer}>
2230
+ Delete task
2231
+ </button>
2232
+ {/if}
2233
+ </SvDrawer>
2234
+ {/if}
2235
+
2236
+ {#if tipBar}
2237
+ <div
2238
+ class="sv-gantt-tooltip"
2239
+ use:portalToBody
2240
+ role="tooltip"
2241
+ style:position="fixed"
2242
+ style:left={`${tipPos.x}px`}
2243
+ style:top={`${tipPos.y}px`}
2244
+ >
2245
+ {#if tooltipSnippet}
2246
+ {@render tooltipSnippet(tipBar.row)}
2247
+ {:else}
2248
+ <div class="sv-gantt-tooltip-title">{tipBar.title}</div>
2249
+ <div class="sv-gantt-tooltip-meta">{barRange(tipBar)}</div>
2250
+ {#if tipBar.kind !== "milestone"}
2251
+ <div class="sv-gantt-tooltip-meta">
2252
+ {workingDays(tipBar.start, tipBar.end, cal)} working days, {Math.round(tipBar.progress)}%
2253
+ </div>
2254
+ {/if}
2255
+ {#if resourceField && resourceOf(tipBar.row)}
2256
+ <div class="sv-gantt-tooltip-meta">{resourceOf(tipBar.row)}</div>
2257
+ {/if}
2258
+ {#if cpm}
2259
+ <div class="sv-gantt-tooltip-meta">
2260
+ {criticalKeys.has(tipBar.key)
2261
+ ? "On the critical path"
2262
+ : `${slackDays(cpm, tipBar.key)} days of slack`}
2263
+ </div>
2264
+ {/if}
2265
+ {#if baselineOn}
2266
+ {@const bl = baselineFor(tipBar.node)}
2267
+ {#if bl && bl.varianceDays !== 0}
2268
+ <div class="sv-gantt-tooltip-meta">
2269
+ {Math.abs(bl.varianceDays)} day{Math.abs(bl.varianceDays) === 1 ? "" : "s"}
2270
+ {bl.varianceDays > 0 ? "later than" : "ahead of"} baseline
2271
+ </div>
2272
+ {/if}
2273
+ {/if}
2274
+ {#if constraintOn}
2275
+ {@const c = constraintOf(tipBar.row)}
2276
+ {#if c}
2277
+ <div class="sv-gantt-tooltip-meta">
2278
+ {CONSTRAINT_LABEL[c.kind] ?? c.kind} {fmtDay(c.date)}{constraintBroken.has(tipBar.key) ? " - not met" : ""}
2279
+ </div>
2280
+ {/if}
2281
+ {/if}
2282
+ {/if}
2283
+ </div>
2284
+ {/if}
2285
+
2286
+ <style>
2287
+ .sv-gantt {
2288
+ display: flex;
2289
+ flex-direction: column;
2290
+ height: 100%;
2291
+ min-height: 0;
2292
+ font-size: 0.85rem;
2293
+ color: var(--sg-fg, #1f2937);
2294
+ background: var(--sg-bg, #fff);
2295
+ border: 1px solid var(--sg-border, #e5e7eb);
2296
+ border-radius: 8px;
2297
+ overflow: hidden;
2298
+ /* Dragging the splitter must not select the labels it passes over. */
2299
+ -webkit-user-select: none;
2300
+ user-select: none;
2301
+ }
2302
+
2303
+ /* ---- toolbar ---- */
2304
+ .sv-gantt-toolbar {
2305
+ display: flex;
2306
+ align-items: center;
2307
+ gap: 12px;
2308
+ padding: 8px 10px;
2309
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
2310
+ flex: 0 0 auto;
2311
+ }
2312
+ .sv-gantt-title { flex: 1 1 auto; font-weight: 600; font-size: 0.95rem; }
2313
+ .sv-gantt-tools { display: flex; align-items: center; gap: 8px; }
2314
+ .sv-gantt-zoom { display: inline-flex; align-items: center; gap: 4px; }
2315
+ .sv-gantt-zoom-label {
2316
+ font-size: 0.76rem;
2317
+ color: var(--sg-muted, #6b7280);
2318
+ min-width: 54px;
2319
+ text-align: center;
2320
+ text-transform: capitalize;
2321
+ }
2322
+ .sv-gantt-btn {
2323
+ padding: 3px 9px;
2324
+ border: 1px solid var(--sg-border, #e5e7eb);
2325
+ border-radius: 6px;
2326
+ background: var(--sg-bg, #fff);
2327
+ color: inherit;
2328
+ font: inherit;
2329
+ font-size: 0.78rem;
2330
+ cursor: pointer;
2331
+ }
2332
+ .sv-gantt-btn:hover:not(:disabled) {
2333
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent);
2334
+ }
2335
+ .sv-gantt-btn:disabled { opacity: 0.45; cursor: default; }
2336
+
2337
+ /* ---- the single scroll container ---- */
2338
+ .sv-gantt-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; position: relative; }
2339
+ .sv-gantt-panes { display: flex; align-items: flex-start; min-height: 100%; }
2340
+
2341
+ /* ---- task table (sticks to the left through horizontal scroll) ---- */
2342
+ .sv-gantt-table {
2343
+ position: sticky;
2344
+ left: 0;
2345
+ /* Above everything the chart draws: the body scrolls horizontally UNDER
2346
+ this pane, so bars, arrows and the today line must pass beneath it. */
2347
+ z-index: 6;
2348
+ flex: none;
2349
+ background: var(--sg-bg, #fff);
2350
+ border-right: 1px solid var(--sg-border, #e5e7eb);
2351
+ /* The pane is a fixed width the splitter owns; anything a column cannot
2352
+ fit into it is clipped rather than drawn over the chart. */
2353
+ overflow: hidden;
2354
+ }
2355
+ .sv-gantt-table-head,
2356
+ .sv-gantt-tr { display: flex; align-items: stretch; }
2357
+ .sv-gantt-table-head {
2358
+ position: sticky;
2359
+ top: 0;
2360
+ z-index: 2;
2361
+ height: 48px;
2362
+ background: var(--sg-bg, #fff);
2363
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
2364
+ }
2365
+ .sv-gantt-th,
2366
+ .sv-gantt-td {
2367
+ flex: none;
2368
+ display: flex;
2369
+ align-items: center;
2370
+ gap: 6px;
2371
+ padding: 0 8px;
2372
+ min-width: 0;
2373
+ overflow: hidden;
2374
+ }
2375
+ .sv-gantt-th {
2376
+ font-size: 0.72rem;
2377
+ font-weight: 600;
2378
+ text-transform: uppercase;
2379
+ letter-spacing: 0.03em;
2380
+ color: var(--sg-muted, #6b7280);
2381
+ align-self: flex-end;
2382
+ height: 28px;
2383
+ }
2384
+ .sv-gantt-num { justify-content: flex-end; font-variant-numeric: tabular-nums; }
2385
+ .sv-gantt-table-body { position: relative; }
2386
+ .sv-gantt-tr {
2387
+ position: absolute;
2388
+ left: 0;
2389
+ right: 0;
2390
+ height: var(--gantt-row-h);
2391
+ border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 55%, transparent);
2392
+ }
2393
+ .sv-gantt-parent { font-weight: 600; }
2394
+ .sv-gantt-cell-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2395
+ .sv-gantt-chevron {
2396
+ flex: none;
2397
+ width: 16px;
2398
+ height: 16px;
2399
+ display: grid;
2400
+ place-items: center;
2401
+ padding: 0;
2402
+ border: 0;
2403
+ border-radius: 4px;
2404
+ background: transparent;
2405
+ color: var(--sg-muted, #6b7280);
2406
+ font-size: 0.7rem;
2407
+ line-height: 1;
2408
+ cursor: pointer;
2409
+ transition: transform 0.12s ease;
2410
+ }
2411
+ .sv-gantt-chevron:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 8%, transparent); }
2412
+ .sv-gantt-chevron-shut { transform: rotate(-90deg); }
2413
+ .sv-gantt-chevron-gap { flex: none; width: 16px; }
2414
+ .sv-gantt-meter {
2415
+ flex: 1 1 auto;
2416
+ height: 6px;
2417
+ min-width: 24px;
2418
+ border-radius: 3px;
2419
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 10%, transparent);
2420
+ overflow: hidden;
2421
+ }
2422
+ .sv-gantt-meter-fill {
2423
+ display: block;
2424
+ height: 100%;
2425
+ background: var(--sg-accent, #4f46e5);
2426
+ }
2427
+ .sv-gantt-meter-pct {
2428
+ flex: none;
2429
+ font-size: 0.72rem;
2430
+ font-variant-numeric: tabular-nums;
2431
+ color: var(--sg-muted, #6b7280);
2432
+ }
2433
+ .sv-gantt-splitter {
2434
+ position: absolute;
2435
+ top: 0;
2436
+ bottom: 0;
2437
+ right: 0;
2438
+ width: 6px;
2439
+ cursor: col-resize;
2440
+ z-index: 4;
2441
+ }
2442
+ .sv-gantt-splitter:hover {
2443
+ background: color-mix(in srgb, var(--sg-accent, #4f46e5) 35%, transparent);
2444
+ }
2445
+
2446
+ /* ---- chart ---- */
2447
+ .sv-gantt-chart { flex: none; position: relative; }
2448
+ .sv-gantt-axis {
2449
+ position: sticky;
2450
+ top: 0;
2451
+ /* Above the bars and arrows that scroll under it, below the task pane. */
2452
+ z-index: 5;
2453
+ height: 48px;
2454
+ background: var(--sg-bg, #fff);
2455
+ border-bottom: 1px solid var(--sg-border, #e5e7eb);
2456
+ }
2457
+ .sv-gantt-majors,
2458
+ .sv-gantt-ticks { position: relative; height: 24px; }
2459
+ .sv-gantt-majors { border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 60%, transparent); }
2460
+ .sv-gantt-major,
2461
+ .sv-gantt-tick {
2462
+ position: absolute;
2463
+ top: 0;
2464
+ bottom: 0;
2465
+ display: flex;
2466
+ align-items: center;
2467
+ justify-content: center;
2468
+ overflow: hidden;
2469
+ white-space: nowrap;
2470
+ border-left: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 60%, transparent);
2471
+ }
2472
+ .sv-gantt-major { font-size: 0.74rem; font-weight: 600; justify-content: flex-start; }
2473
+ /* A month cell is far wider than the viewport, so a centred label sits
2474
+ off-screen the moment you scroll into the middle of it - the header then
2475
+ reads "r 2026". The label sticks just right of the task pane instead, so
2476
+ you can always see which month you are looking at. */
2477
+ .sv-gantt-major > span {
2478
+ position: sticky;
2479
+ left: calc(var(--gantt-table-w, 0px) + 10px);
2480
+ padding-right: 10px;
2481
+ white-space: nowrap;
2482
+ }
2483
+ .sv-gantt-tick { font-size: 0.7rem; color: var(--sg-muted, #6b7280); }
2484
+ .sv-gantt-tick-today { color: var(--sg-accent, #4f46e5); font-weight: 700; }
2485
+ /* A folded day keeps its column so the header still tiles the chart, but it
2486
+ is only a few pixels wide - a squeezed date would read as noise. */
2487
+ .sv-gantt-tick-folded > span { display: none; }
2488
+
2489
+ .sv-gantt-body { position: relative; }
2490
+
2491
+ /* --- resource load strip (Pro) --- */
2492
+ .sv-gantt-histo {
2493
+ position: sticky;
2494
+ bottom: 0;
2495
+ z-index: 4;
2496
+ background: var(--sg-bg, #fff);
2497
+ border-top: 1px solid var(--sg-border, #e5e7eb);
2498
+ }
2499
+ .sv-gantt-histo-row {
2500
+ position: relative;
2501
+ border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 45%, transparent);
2502
+ }
2503
+ .sv-gantt-histo-row:last-child { border-bottom: 0; }
2504
+ /* The label rides the left edge, the way the month caption does, so it is
2505
+ readable wherever the chart is scrolled to. */
2506
+ .sv-gantt-histo-label {
2507
+ position: sticky;
2508
+ left: calc(var(--gantt-table-w, 0px) + 8px);
2509
+ z-index: 1;
2510
+ display: inline-flex;
2511
+ align-items: baseline;
2512
+ gap: 6px;
2513
+ padding: 1px 6px;
2514
+ font-size: 0.68rem;
2515
+ font-weight: 600;
2516
+ color: var(--sg-fg, #374151);
2517
+ background: color-mix(in srgb, var(--sg-bg, #fff) 82%, transparent);
2518
+ border-radius: 4px;
2519
+ white-space: nowrap;
2520
+ pointer-events: none;
2521
+ }
2522
+ .sv-gantt-histo-label small { font-weight: 400; color: var(--sg-muted, #6b7280); }
2523
+ .sv-gantt-histo-over .sv-gantt-histo-label { color: #b91c1c; }
2524
+ /* Deliberately NOT the theme accent: red has to be the only red in the
2525
+ strip, or an over-allocation stops standing out on a warm theme. */
2526
+ .sv-gantt-histo-cell {
2527
+ position: absolute;
2528
+ bottom: 0;
2529
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 26%, transparent);
2530
+ border-radius: 2px 2px 0 0;
2531
+ }
2532
+ .sv-gantt-histo-cell-over {
2533
+ background: color-mix(in srgb, #dc2626 70%, transparent);
2534
+ }
2535
+ .sv-gantt-shade {
2536
+ position: absolute;
2537
+ top: 0;
2538
+ bottom: 0;
2539
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 4%, transparent);
2540
+ pointer-events: none;
2541
+ }
2542
+ /* The marker left where a weekend was folded out: a hatch, so the break in
2543
+ the timeline is visible rather than a silently missing two days. */
2544
+ .sv-gantt-gap {
2545
+ background: repeating-linear-gradient(
2546
+ -45deg,
2547
+ color-mix(in srgb, var(--sg-fg, #1f2937) 9%, transparent) 0 2px,
2548
+ transparent 2px 5px
2549
+ );
2550
+ }
2551
+ .sv-gantt-gridline {
2552
+ position: absolute;
2553
+ top: 0;
2554
+ bottom: 0;
2555
+ width: 1px;
2556
+ background: color-mix(in srgb, var(--sg-border, #e5e7eb) 55%, transparent);
2557
+ pointer-events: none;
2558
+ }
2559
+ .sv-gantt-today {
2560
+ position: absolute;
2561
+ top: 0;
2562
+ bottom: 0;
2563
+ width: 0;
2564
+ border-left: 2px dashed var(--sg-accent, #4f46e5);
2565
+ pointer-events: none;
2566
+ z-index: 4;
2567
+ }
2568
+
2569
+ .sv-gantt-row { position: absolute; left: 0; right: 0; height: var(--gantt-row-h); }
2570
+ .sv-gantt-bar {
2571
+ position: absolute;
2572
+ top: calc((var(--gantt-row-h) - var(--gantt-bar-h)) / 2);
2573
+ height: var(--gantt-bar-h);
2574
+ display: flex;
2575
+ align-items: center;
2576
+ border-radius: 4px;
2577
+ background: var(--sv-gantt-accent, var(--sg-accent, #4f46e5));
2578
+ color: #fff;
2579
+ overflow: hidden;
2580
+ cursor: default;
2581
+ z-index: 2;
2582
+ }
2583
+ .sv-gantt-bar:focus-visible {
2584
+ outline: 2px solid var(--sg-accent, #4f46e5);
2585
+ outline-offset: 2px;
2586
+ }
2587
+ /* A finger on something draggable drags it; the browser would otherwise
2588
+ turn the touch into a scroll and cancel the pointer. Read-only bars keep
2589
+ scrolling by touch. */
2590
+ .sv-gantt-editable .sv-gantt-bar,
2591
+ .sv-gantt-grip,
2592
+ .sv-gantt-grip-p,
2593
+ .sv-gantt-link-dot,
2594
+ .sv-gantt-splitter {
2595
+ touch-action: none;
2596
+ }
2597
+ .sv-gantt-progress {
2598
+ position: absolute;
2599
+ left: 0;
2600
+ top: 0;
2601
+ bottom: 0;
2602
+ background: color-mix(in srgb, #000 28%, transparent);
2603
+ pointer-events: none;
2604
+ }
2605
+ .sv-gantt-bar-label,
2606
+ .sv-gantt-bar-body {
2607
+ position: relative;
2608
+ z-index: 1;
2609
+ padding: 0 6px;
2610
+ font-size: 0.75rem;
2611
+ overflow: hidden;
2612
+ text-overflow: ellipsis;
2613
+ white-space: nowrap;
2614
+ }
2615
+ .sv-gantt-label {
2616
+ position: absolute;
2617
+ top: 0;
2618
+ height: var(--gantt-row-h);
2619
+ display: flex;
2620
+ align-items: center;
2621
+ font-size: 0.75rem;
2622
+ white-space: nowrap;
2623
+ pointer-events: none;
2624
+ color: var(--sg-fg, #1f2937);
2625
+ }
2626
+
2627
+ /* A parent's rolled-up span: a slim spine with end caps, so it reads as a
2628
+ container rather than as work of its own. */
2629
+ .sv-gantt-bar-summary {
2630
+ height: 8px;
2631
+ top: calc((var(--gantt-row-h) - 8px) / 2);
2632
+ border-radius: 2px;
2633
+ background: color-mix(in srgb, var(--sv-gantt-accent, var(--sg-fg, #1f2937)) 75%, transparent);
2634
+ }
2635
+ .sv-gantt-bar-summary::before,
2636
+ .sv-gantt-bar-summary::after {
2637
+ content: "";
2638
+ position: absolute;
2639
+ top: 100%;
2640
+ border-top: 5px solid color-mix(in srgb, var(--sv-gantt-accent, var(--sg-fg, #1f2937)) 75%, transparent);
2641
+ border-left: 4px solid transparent;
2642
+ border-right: 4px solid transparent;
2643
+ }
2644
+ .sv-gantt-bar-summary::before { left: 0; }
2645
+ .sv-gantt-bar-summary::after { right: 0; }
2646
+
2647
+ .sv-gantt-bar-milestone {
2648
+ width: var(--gantt-bar-h);
2649
+ border-radius: 2px;
2650
+ transform: translateX(-50%) rotate(45deg);
2651
+ background: var(--sv-gantt-accent, var(--sg-accent, #4f46e5));
2652
+ }
2653
+
2654
+
2655
+ /* ---- editing: grips, link handles, refusal ---- */
2656
+ .sv-gantt-editable .sv-gantt-bar-task,
2657
+ .sv-gantt-editable .sv-gantt-bar-summary,
2658
+ .sv-gantt-editable .sv-gantt-bar-milestone { cursor: grab; }
2659
+ .sv-gantt-bar.sv-gantt-dragging { cursor: grabbing; opacity: 0.85; }
2660
+
2661
+ /* Edge grips. Revealed on hover so a read-only glance stays clean, and wide
2662
+ enough (7px) to hit without zooming in. */
2663
+ .sv-gantt-grip {
2664
+ position: absolute;
2665
+ top: 0;
2666
+ bottom: 0;
2667
+ width: 7px;
2668
+ cursor: ew-resize;
2669
+ opacity: 0;
2670
+ z-index: 2;
2671
+ }
2672
+ .sv-gantt-grip-l { left: 0; }
2673
+ .sv-gantt-grip-r { right: 0; }
2674
+ .sv-gantt-bar:hover .sv-gantt-grip,
2675
+ .sv-gantt-bar:focus-visible .sv-gantt-grip { opacity: 1; }
2676
+ .sv-gantt-grip::after {
2677
+ content: "";
2678
+ position: absolute;
2679
+ top: 50%;
2680
+ left: 50%;
2681
+ width: 2px;
2682
+ height: 10px;
2683
+ transform: translate(-50%, -50%);
2684
+ border-radius: 1px;
2685
+ background: color-mix(in srgb, #fff 75%, transparent);
2686
+ }
2687
+
2688
+ /* The progress handle rides the leading edge of the fill. */
2689
+ .sv-gantt-grip-p {
2690
+ position: absolute;
2691
+ top: 50%;
2692
+ width: 11px;
2693
+ height: 11px;
2694
+ margin-left: -5.5px;
2695
+ transform: translateY(-50%) rotate(45deg);
2696
+ background: var(--sg-bg, #fff);
2697
+ border: 1.5px solid color-mix(in srgb, #000 35%, transparent);
2698
+ border-radius: 2px;
2699
+ cursor: ew-resize;
2700
+ opacity: 0;
2701
+ z-index: 3;
2702
+ }
2703
+ .sv-gantt-bar:hover .sv-gantt-grip-p,
2704
+ .sv-gantt-bar:focus-visible .sv-gantt-grip-p { opacity: 1; }
2705
+
2706
+ /* Link handles sit just OUTSIDE each end, so grabbing one is never mistaken
2707
+ for grabbing the bar. */
2708
+ .sv-gantt-link-dot {
2709
+ position: absolute;
2710
+ top: 50%;
2711
+ width: 10px;
2712
+ height: 10px;
2713
+ transform: translateY(-50%);
2714
+ border-radius: 50%;
2715
+ background: var(--sg-bg, #fff);
2716
+ border: 2px solid var(--sg-accent, #4f46e5);
2717
+ cursor: crosshair;
2718
+ opacity: 0;
2719
+ z-index: 4;
2720
+ }
2721
+ /* Revealed by hovering the ROW, since the handles are its children rather
2722
+ than the bar's - see the note in the markup. */
2723
+ .sv-gantt-row:hover .sv-gantt-link-dot,
2724
+ .sv-gantt-row:focus-within .sv-gantt-link-dot { opacity: 1; }
2725
+ .sv-gantt-bar.sv-gantt-link-target {
2726
+ outline: 2px solid var(--sv-gantt-accent, var(--sg-accent, #4f46e5));
2727
+ outline-offset: 2px;
2728
+ }
2729
+
2730
+ /* The rubber band while a link is being drawn. */
2731
+ .sv-gantt-linking {
2732
+ position: absolute;
2733
+ left: 0;
2734
+ top: 0;
2735
+ pointer-events: none;
2736
+ overflow: visible;
2737
+ z-index: 4;
2738
+ }
2739
+ .sv-gantt-linking path {
2740
+ fill: none;
2741
+ stroke: var(--sv-gantt-accent, var(--sg-accent, #4f46e5));
2742
+ stroke-width: 2;
2743
+ stroke-dasharray: 4 3;
2744
+ }
2745
+
2746
+ /* A refused edit (a cycle, or a link that already exists) flashes rather
2747
+ than throwing: the gesture was understood, the result is not legal. */
2748
+ .sv-gantt-refused {
2749
+ animation: sv-gantt-refuse 0.32s ease;
2750
+ outline: 2px solid var(--sv-gantt-dep-bad, #dc2626);
2751
+ outline-offset: 2px;
2752
+ }
2753
+ @keyframes sv-gantt-refuse {
2754
+ 0%, 100% { transform: translateX(0); }
2755
+ 25% { transform: translateX(-3px); }
2756
+ 75% { transform: translateX(3px); }
2757
+ }
2758
+ @media (prefers-reduced-motion: reduce) {
2759
+ .sv-gantt-refused { animation: none; }
2760
+ }
2761
+
2762
+ /* ---- Gantt Pro: critical path, baselines, constraints ---- */
2763
+ /* Critical bars keep their own colour and gain a ring, so the plan does not
2764
+ lose its phase colouring the moment the path is turned on. */
2765
+ .sv-gantt-bar.sv-gantt-critical {
2766
+ box-shadow: 0 0 0 2px var(--sv-gantt-critical, #dc2626);
2767
+ }
2768
+ .sv-gantt-bar-summary.sv-gantt-critical { box-shadow: 0 0 0 1.5px var(--sv-gantt-critical, #dc2626); }
2769
+ /* The line keeps `fill: none` - a fill on the elbow path paints the polygon
2770
+ it encloses, and every critical link came out as a solid red slab. */
2771
+ .sv-gantt-dep-line.sv-gantt-dep-critical {
2772
+ stroke: var(--sv-gantt-critical, #dc2626);
2773
+ stroke-width: 2;
2774
+ }
2775
+ .sv-gantt-dep-arrow.sv-gantt-dep-critical {
2776
+ fill: var(--sv-gantt-critical, #dc2626);
2777
+ }
2778
+
2779
+ /* The baseline: a thin ghost under the bar, so drift reads as the gap
2780
+ between the two rather than as a second bar competing with it. */
2781
+ .sv-gantt-baseline {
2782
+ position: absolute;
2783
+ top: calc((var(--gantt-row-h) - var(--gantt-bar-h)) / 2 + var(--gantt-bar-h) - 1px);
2784
+ height: 5px;
2785
+ border-radius: 2px;
2786
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 28%, transparent);
2787
+ pointer-events: none;
2788
+ }
2789
+ .sv-gantt-baseline.sv-gantt-baseline-late {
2790
+ background: color-mix(in srgb, var(--sv-gantt-dep-bad, #dc2626) 45%, transparent);
2791
+ }
2792
+
2793
+ /* A constraint is a pin on the date it names: a flag at the top of the row
2794
+ and a dashed tick down through it. It sits above the arrows so an incoming
2795
+ link cannot cover it, and takes no pointer events so the bar under it can
2796
+ still be dragged. */
2797
+ .sv-gantt-constraint {
2798
+ --sv-gantt-pin: color-mix(in srgb, var(--sg-fg, #1f2937) 60%, transparent);
2799
+ position: absolute;
2800
+ top: 0;
2801
+ width: 0;
2802
+ height: var(--gantt-row-h);
2803
+ border-left: 1.5px dashed var(--sv-gantt-pin);
2804
+ z-index: 4;
2805
+ pointer-events: none;
2806
+ }
2807
+ .sv-gantt-constraint::before {
2808
+ content: "";
2809
+ position: absolute;
2810
+ top: 1px;
2811
+ left: -5.5px;
2812
+ border: 5px solid transparent;
2813
+ border-top: 7px solid var(--sv-gantt-pin);
2814
+ border-bottom: 0;
2815
+ }
2816
+ .sv-gantt-constraint-broken { --sv-gantt-pin: var(--sv-gantt-dep-bad, #dc2626); }
2817
+ /* A bar whose own dates break its constraint. The cascade stops at the cap
2818
+ rather than overrunning it, so this is a state to read, not an error. */
2819
+ .sv-gantt-bar.sv-gantt-constrained {
2820
+ outline: 1.5px dashed var(--sv-gantt-dep-bad, #dc2626);
2821
+ outline-offset: 1px;
2822
+ }
2823
+
2824
+ /* ---- context menu + drawer ---- */
2825
+ /* Portalled to the body, so it carries its own panel: without the
2826
+ background it rendered as bare text over whatever was behind it. */
2827
+ .sv-gantt-menu {
2828
+ z-index: 2147483001;
2829
+ background: var(--sg-bg, #fff);
2830
+ border: 1px solid var(--sg-border, #e5e7eb);
2831
+ border-radius: 8px;
2832
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.16);
2833
+ overflow: hidden;
2834
+ }
2835
+ .sv-gantt-delete {
2836
+ margin-top: 12px;
2837
+ width: 100%;
2838
+ padding: 7px 10px;
2839
+ border: 1px solid color-mix(in srgb, var(--sv-gantt-dep-bad, #dc2626) 45%, transparent);
2840
+ border-radius: 6px;
2841
+ background: transparent;
2842
+ color: var(--sv-gantt-dep-bad, #dc2626);
2843
+ font: inherit;
2844
+ font-size: 0.82rem;
2845
+ cursor: pointer;
2846
+ }
2847
+ .sv-gantt-delete:hover {
2848
+ background: color-mix(in srgb, var(--sv-gantt-dep-bad, #dc2626) 10%, transparent);
2849
+ }
2850
+
2851
+ /* ---- dependency arrows (shared geometry with the scheduler timeline) ---- */
2852
+ .sv-gantt-deps {
2853
+ position: absolute;
2854
+ left: 0;
2855
+ top: 0;
2856
+ pointer-events: none;
2857
+ overflow: visible;
2858
+ z-index: 3;
2859
+ }
2860
+ .sv-gantt-dep-line {
2861
+ fill: none;
2862
+ stroke: var(--sv-gantt-dep, color-mix(in srgb, var(--sg-fg, #1f2937) 45%, transparent));
2863
+ stroke-width: 1.5;
2864
+ }
2865
+ .sv-gantt-dep-arrow {
2866
+ fill: var(--sv-gantt-dep, color-mix(in srgb, var(--sg-fg, #1f2937) 45%, transparent));
2867
+ }
2868
+ .sv-gantt-dep-line.sv-gantt-dep-bad {
2869
+ stroke: var(--sv-gantt-dep-bad, #dc2626);
2870
+ stroke-dasharray: 4 3;
2871
+ }
2872
+ .sv-gantt-dep-arrow.sv-gantt-dep-bad { fill: var(--sv-gantt-dep-bad, #dc2626); }
2873
+
2874
+ /* ---- tooltip ---- */
2875
+ .sv-gantt-tooltip {
2876
+ z-index: 2147483002;
2877
+ transform: translate(-50%, calc(-100% - 8px));
2878
+ max-width: 260px;
2879
+ padding: 7px 10px;
2880
+ border-radius: 8px;
2881
+ font-size: 0.78rem;
2882
+ line-height: 1.35;
2883
+ color: var(--sg-bg, #fff);
2884
+ background: color-mix(in srgb, var(--sg-fg, #1f2937) 92%, transparent);
2885
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
2886
+ pointer-events: none;
2887
+ }
2888
+ .sv-gantt-tooltip-title { font-weight: 600; }
2889
+ .sv-gantt-tooltip-meta { opacity: 0.85; font-variant-numeric: tabular-nums; }
2890
+ </style>