dreamcontext 0.17.1 → 0.18.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 (181) hide show
  1. package/README.md +321 -565
  2. package/agents/sleep-product.md +21 -2
  3. package/agents/sleep-state.md +24 -0
  4. package/agents/sleep-tasks.md +16 -0
  5. package/dist/agents/sleep-product.md +21 -2
  6. package/dist/agents/sleep-state.md +24 -0
  7. package/dist/agents/sleep-tasks.md +16 -0
  8. package/dist/dashboard/announcements/dashboard-highlights-0-17-0-18.excalidraw.md +1531 -0
  9. package/dist/dashboard/announcements/goal-skill-v2.excalidraw.md +1490 -0
  10. package/dist/dashboard/announcements/task-manager.excalidraw.md +1217 -0
  11. package/dist/dashboard/announcements/visual-announcements.excalidraw.md +1373 -0
  12. package/dist/dashboard/announcements.json +38 -0
  13. package/dist/dashboard/assets/{BrainCanvas3D-DyF_3yYD.js → BrainCanvas3D-D9G3dOLf.js} +1 -1
  14. package/dist/dashboard/assets/{_baseUniq-Do8VMBA0.js → _baseUniq-Cyshlz4Y.js} +1 -1
  15. package/dist/dashboard/assets/{ar-SA-G6X2FPQ2-DmbXDb2R.js → ar-SA-G6X2FPQ2-qj-2Cbgo.js} +1 -1
  16. package/dist/dashboard/assets/{arc-CwtUBQfU.js → arc-0OnX5WDs.js} +1 -1
  17. package/dist/dashboard/assets/{architectureDiagram-Q4EWVU46-O9bOog_f.js → architectureDiagram-Q4EWVU46-Cx3c0nUi.js} +1 -1
  18. package/dist/dashboard/assets/{az-AZ-76LH7QW2-c9Zk10vG.js → az-AZ-76LH7QW2-CBuoh6M3.js} +1 -1
  19. package/dist/dashboard/assets/{bg-BG-XCXSNQG7-uMTg84lX.js → bg-BG-XCXSNQG7-X5QYyZWx.js} +1 -1
  20. package/dist/dashboard/assets/{blockDiagram-DXYQGD6D-BTENXCSO.js → blockDiagram-DXYQGD6D-BwESAg1j.js} +1 -1
  21. package/dist/dashboard/assets/{bn-BD-2XOGV67Q-DUFKHcua.js → bn-BD-2XOGV67Q-DlodQLNR.js} +1 -1
  22. package/dist/dashboard/assets/{c4Diagram-AHTNJAMY-DMXPWRiQ.js → c4Diagram-AHTNJAMY-CopL0lbP.js} +1 -1
  23. package/dist/dashboard/assets/{ca-ES-6MX7JW3Y-BuYX1q89.js → ca-ES-6MX7JW3Y-DfqF6Yhy.js} +1 -1
  24. package/dist/dashboard/assets/channel-BLisRbYb.js +1 -0
  25. package/dist/dashboard/assets/{chunk-4BX2VUAB-DgefvnWS.js → chunk-4BX2VUAB-CRwifLK3.js} +1 -1
  26. package/dist/dashboard/assets/{chunk-4TB4RGXK-BnFO5G1t.js → chunk-4TB4RGXK-uxJYY_64.js} +1 -1
  27. package/dist/dashboard/assets/{chunk-55IACEB6-COm2TYEM.js → chunk-55IACEB6-DhYIp7Oc.js} +1 -1
  28. package/dist/dashboard/assets/{chunk-EDXVE4YY-CtsGCwZp.js → chunk-EDXVE4YY-CCxfcirt.js} +1 -1
  29. package/dist/dashboard/assets/{chunk-FMBD7UC4-cfSvx35j.js → chunk-FMBD7UC4-D7OC8QFC.js} +1 -1
  30. package/dist/dashboard/assets/{chunk-OYMX7WX6-DAhDKteL.js → chunk-OYMX7WX6-CwHgaUQo.js} +1 -1
  31. package/dist/dashboard/assets/{chunk-QZHKN3VN-B5h4qML3.js → chunk-QZHKN3VN-7Idpf6YJ.js} +1 -1
  32. package/dist/dashboard/assets/{chunk-YZCP3GAM-CGp_NiRg.js → chunk-YZCP3GAM-D4Lh0fgr.js} +1 -1
  33. package/dist/dashboard/assets/classDiagram-6PBFFD2Q-CgqeQiHi.js +1 -0
  34. package/dist/dashboard/assets/classDiagram-v2-HSJHXN6E-CgqeQiHi.js +1 -0
  35. package/dist/dashboard/assets/clone-CQXNMCV5.js +1 -0
  36. package/dist/dashboard/assets/{cose-bilkent-S5V4N54A-Dk7CMFA4.js → cose-bilkent-S5V4N54A-BYcMtJTF.js} +1 -1
  37. package/dist/dashboard/assets/{cs-CZ-2BRQDIVT-zfUTiS_d.js → cs-CZ-2BRQDIVT-rnrpKdSJ.js} +1 -1
  38. package/dist/dashboard/assets/{da-DK-5WZEPLOC-CU14f8NA.js → da-DK-5WZEPLOC-D5OGAvk7.js} +1 -1
  39. package/dist/dashboard/assets/{dagre-KV5264BT-BiMVxC4Q.js → dagre-KV5264BT-V0f1_b4R.js} +1 -1
  40. package/dist/dashboard/assets/{de-DE-XR44H4JA-CDOHAJGo.js → de-DE-XR44H4JA-CDyb8YfL.js} +1 -1
  41. package/dist/dashboard/assets/{diagram-5BDNPKRD-DfJEsVTA.js → diagram-5BDNPKRD-BmpizoaS.js} +1 -1
  42. package/dist/dashboard/assets/{diagram-G4DWMVQ6-CztLHIcY.js → diagram-G4DWMVQ6-ClQg-LTu.js} +1 -1
  43. package/dist/dashboard/assets/{diagram-MMDJMWI5-DqoQLlWK.js → diagram-MMDJMWI5-BkXcDMIV.js} +1 -1
  44. package/dist/dashboard/assets/{diagram-TYMM5635-DvyvQBCc.js → diagram-TYMM5635-BbFiiCM2.js} +1 -1
  45. package/dist/dashboard/assets/{el-GR-BZB4AONW-ChUphbcv.js → el-GR-BZB4AONW-Y1YnKZL0.js} +1 -1
  46. package/dist/dashboard/assets/{erDiagram-SMLLAGMA-D_uSs2_2.js → erDiagram-SMLLAGMA-Dzdv7_l5.js} +1 -1
  47. package/dist/dashboard/assets/{es-ES-U4NZUMDT-BNalBEhb.js → es-ES-U4NZUMDT-D0T536hs.js} +1 -1
  48. package/dist/dashboard/assets/{eu-ES-A7QVB2H4-BMiQyA2y.js → eu-ES-A7QVB2H4-Bfrya0et.js} +1 -1
  49. package/dist/dashboard/assets/{fa-IR-HGAKTJCU-Bd6ZnTSo.js → fa-IR-HGAKTJCU-pYE_hG-L.js} +1 -1
  50. package/dist/dashboard/assets/{fi-FI-Z5N7JZ37-BWAfJadC.js → fi-FI-Z5N7JZ37-D3C-fNyk.js} +1 -1
  51. package/dist/dashboard/assets/{flowDiagram-DWJPFMVM-Bmsp6kyU.js → flowDiagram-DWJPFMVM-CFhyjwKY.js} +1 -1
  52. package/dist/dashboard/assets/{fr-FR-RHASNOE6-CspbYzOW.js → fr-FR-RHASNOE6-CgwnG5JW.js} +1 -1
  53. package/dist/dashboard/assets/{ganttDiagram-T4ZO3ILL-wEdysXo_.js → ganttDiagram-T4ZO3ILL-BRKRiSzf.js} +1 -1
  54. package/dist/dashboard/assets/{gitGraphDiagram-UUTBAWPF-BEiGpPBj.js → gitGraphDiagram-UUTBAWPF-CTqCK49d.js} +1 -1
  55. package/dist/dashboard/assets/{gl-ES-HMX3MZ6V-CV76vsWr.js → gl-ES-HMX3MZ6V-DjJTqTm3.js} +1 -1
  56. package/dist/dashboard/assets/{graph-CZFIeNHZ.js → graph-D429kSbY.js} +1 -1
  57. package/dist/dashboard/assets/{he-IL-6SHJWFNN-Y1VoXJ_H.js → he-IL-6SHJWFNN-C84FlH6r.js} +1 -1
  58. package/dist/dashboard/assets/{hi-IN-IWLTKZ5I-Crh5S49o.js → hi-IN-IWLTKZ5I-Blhkfum2.js} +1 -1
  59. package/dist/dashboard/assets/{hu-HU-A5ZG7DT2-D4I3gCbp.js → hu-HU-A5ZG7DT2-DLC1JfN-.js} +1 -1
  60. package/dist/dashboard/assets/{id-ID-SAP4L64H-Chvzkuvi.js → id-ID-SAP4L64H-C3pB_IWB.js} +1 -1
  61. package/dist/dashboard/assets/image-FgR93jdt.js +1 -0
  62. package/dist/dashboard/assets/index-2m8ueOUn.js +1 -0
  63. package/dist/dashboard/assets/index-CWwjuihq.css +32 -0
  64. package/dist/dashboard/assets/{index-C7Bo5AQH.js → index-CbOXCDms.js} +1 -1
  65. package/dist/dashboard/assets/index-SJ64pf-4.js +551 -0
  66. package/dist/dashboard/assets/{infoDiagram-42DDH7IO-BzfuDHl4.js → infoDiagram-42DDH7IO-CIzDShDw.js} +1 -1
  67. package/dist/dashboard/assets/{ishikawaDiagram-UXIWVN3A-BVoNJ9VS.js → ishikawaDiagram-UXIWVN3A-CuasYzdX.js} +1 -1
  68. package/dist/dashboard/assets/{it-IT-JPQ66NNP-BH_gN5es.js → it-IT-JPQ66NNP-DGGEoNPu.js} +1 -1
  69. package/dist/dashboard/assets/{ja-JP-DBVTYXUO-DU_WmLVa.js → ja-JP-DBVTYXUO-CEqP9aps.js} +1 -1
  70. package/dist/dashboard/assets/{journeyDiagram-VCZTEJTY-DbwICa_a.js → journeyDiagram-VCZTEJTY-DHiq2OTQ.js} +1 -1
  71. package/dist/dashboard/assets/{kaa-6HZHGXH3-KYU9qowM.js → kaa-6HZHGXH3-GOFmTAXq.js} +1 -1
  72. package/dist/dashboard/assets/{kab-KAB-ZGHBKWFO-bjhyiUq5.js → kab-KAB-ZGHBKWFO-B865Wwzu.js} +1 -1
  73. package/dist/dashboard/assets/{kanban-definition-6JOO6SKY-D9XucLs4.js → kanban-definition-6JOO6SKY-BhlTKFbL.js} +1 -1
  74. package/dist/dashboard/assets/{kk-KZ-P5N5QNE5-CYTx_dAZ.js → kk-KZ-P5N5QNE5-CNfuE6b6.js} +1 -1
  75. package/dist/dashboard/assets/{km-KH-HSX4SM5Z-DqsuY4Lf.js → km-KH-HSX4SM5Z-CsmgRWxu.js} +1 -1
  76. package/dist/dashboard/assets/{ko-KR-MTYHY66A-BZe7NywF.js → ko-KR-MTYHY66A-B_02RgEz.js} +1 -1
  77. package/dist/dashboard/assets/{ku-TR-6OUDTVRD-F5d6HFWt.js → ku-TR-6OUDTVRD-JfQA5u05.js} +1 -1
  78. package/dist/dashboard/assets/{layout-CotqU0S9.js → layout-DvrQSvOI.js} +1 -1
  79. package/dist/dashboard/assets/{linear-Dqv87Scf.js → linear-6r0qttLs.js} +1 -1
  80. package/dist/dashboard/assets/{lt-LT-XHIRWOB4-BCCuaPbS.js → lt-LT-XHIRWOB4-DWFUXEdf.js} +1 -1
  81. package/dist/dashboard/assets/{lv-LV-5QDEKY6T-NLGi-vJ6.js → lv-LV-5QDEKY6T-C5yAd0n_.js} +1 -1
  82. package/dist/dashboard/assets/{min-BbJdnc3h.js → min-D_EP9dBf.js} +1 -1
  83. package/dist/dashboard/assets/{mindmap-definition-QFDTVHPH-Dk4QySu7.js → mindmap-definition-QFDTVHPH-Col3ao9B.js} +1 -1
  84. package/dist/dashboard/assets/{mr-IN-CRQNXWMA-BnGDIgVN.js → mr-IN-CRQNXWMA-Dwjz0Xdm.js} +1 -1
  85. package/dist/dashboard/assets/{my-MM-5M5IBNSE-CspgYE9_.js → my-MM-5M5IBNSE-EXJDNyjA.js} +1 -1
  86. package/dist/dashboard/assets/{nb-NO-T6EIAALU-BeRPw3hu.js → nb-NO-T6EIAALU-BWm2bgLd.js} +1 -1
  87. package/dist/dashboard/assets/{nl-NL-IS3SIHDZ-C-G-bljM.js → nl-NL-IS3SIHDZ-f8nP-yp-.js} +1 -1
  88. package/dist/dashboard/assets/{nn-NO-6E72VCQL-Ct7VZY8D.js → nn-NO-6E72VCQL-B0c_gCNR.js} +1 -1
  89. package/dist/dashboard/assets/{oc-FR-POXYY2M6-Cv5-C4Qq.js → oc-FR-POXYY2M6-C40PSwAh.js} +1 -1
  90. package/dist/dashboard/assets/{pa-IN-N4M65BXN-DAgkIThL.js → pa-IN-N4M65BXN-DDcgXFkB.js} +1 -1
  91. package/dist/dashboard/assets/{percentages-BXMCSKIN-vidRYabK.js → percentages-BXMCSKIN-MHBQP8nI.js} +7 -7
  92. package/dist/dashboard/assets/{pica-Cy-Xl-mi.js → pica-C5CFA09W.js} +1 -1
  93. package/dist/dashboard/assets/{pieDiagram-DEJITSTG-BnP4-zwd.js → pieDiagram-DEJITSTG-DFHmB0fL.js} +1 -1
  94. package/dist/dashboard/assets/{pl-PL-T2D74RX3-BuJQ3HD-.js → pl-PL-T2D74RX3-yFTBt0Xu.js} +1 -1
  95. package/dist/dashboard/assets/{pt-BR-5N22H2LF-BbAqsvD6.js → pt-BR-5N22H2LF-ilAW3Fdv.js} +1 -1
  96. package/dist/dashboard/assets/{pt-PT-UZXXM6DQ-DzFJedU3.js → pt-PT-UZXXM6DQ-CXHFN95K.js} +1 -1
  97. package/dist/dashboard/assets/{quadrantDiagram-34T5L4WZ-BXN88cHy.js → quadrantDiagram-34T5L4WZ-CR732_oP.js} +1 -1
  98. package/dist/dashboard/assets/{requirementDiagram-MS252O5E-D34hL42a.js → requirementDiagram-MS252O5E-mKoVG52Z.js} +1 -1
  99. package/dist/dashboard/assets/{ro-RO-JPDTUUEW-BV2-sOiv.js → ro-RO-JPDTUUEW-BKlWpyF4.js} +1 -1
  100. package/dist/dashboard/assets/{ru-RU-B4JR7IUQ-BCL4N9Ej.js → ru-RU-B4JR7IUQ-BGoxlhJm.js} +1 -1
  101. package/dist/dashboard/assets/{sankeyDiagram-XADWPNL6-0mFeok-3.js → sankeyDiagram-XADWPNL6-DXipts7M.js} +1 -1
  102. package/dist/dashboard/assets/{sequenceDiagram-FGHM5R23-C-5nW5j6.js → sequenceDiagram-FGHM5R23-DFQhU8q7.js} +1 -1
  103. package/dist/dashboard/assets/{si-LK-N5RQ5JYF-CNhaw4jx.js → si-LK-N5RQ5JYF-yDbJWFTH.js} +1 -1
  104. package/dist/dashboard/assets/{sk-SK-C5VTKIMK-mHCphOXu.js → sk-SK-C5VTKIMK-DglyhbNB.js} +1 -1
  105. package/dist/dashboard/assets/{sl-SI-NN7IZMDC--lzgHthQ.js → sl-SI-NN7IZMDC-DPfq0s4I.js} +1 -1
  106. package/dist/dashboard/assets/{stateDiagram-FHFEXIEX-C50iDFqe.js → stateDiagram-FHFEXIEX-Dm50jiEh.js} +1 -1
  107. package/dist/dashboard/assets/stateDiagram-v2-QKLJ7IA2-CoSm11sD.js +1 -0
  108. package/dist/dashboard/assets/{subset-shared.chunk-DNsjL37s.js → subset-shared.chunk-OHz6M6ct.js} +1 -1
  109. package/dist/dashboard/assets/{subset-worker.chunk-BaV3r-bl.js → subset-worker.chunk-BzwrOBtO.js} +1 -1
  110. package/dist/dashboard/assets/{sv-SE-XGPEYMSR-ckw8jfGN.js → sv-SE-XGPEYMSR-BLHNwr4k.js} +1 -1
  111. package/dist/dashboard/assets/{ta-IN-2NMHFXQM-CSeNmVhS.js → ta-IN-2NMHFXQM-Dw-6hUpN.js} +1 -1
  112. package/dist/dashboard/assets/{th-TH-HPSO5L25-Cvn1sg_3.js → th-TH-HPSO5L25-DPkcM_k9.js} +1 -1
  113. package/dist/dashboard/assets/{timeline-definition-GMOUNBTQ-DRYfuqHT.js → timeline-definition-GMOUNBTQ-BKFNQ_7e.js} +1 -1
  114. package/dist/dashboard/assets/{tr-TR-DEFEU3FU-CyhY22TB.js → tr-TR-DEFEU3FU-rBVsY5RS.js} +1 -1
  115. package/dist/dashboard/assets/{uk-UA-QMV73CPH-BvvAEO6U.js → uk-UA-QMV73CPH-CV6_yTZs.js} +1 -1
  116. package/dist/dashboard/assets/{vennDiagram-DHZGUBPP-Crril7Cj.js → vennDiagram-DHZGUBPP-BZb_r5tY.js} +1 -1
  117. package/dist/dashboard/assets/{vi-VN-M7AON7JQ-c7AwSUZ8.js → vi-VN-M7AON7JQ-CJMgSsLz.js} +1 -1
  118. package/dist/dashboard/assets/{wardley-RL74JXVD-Da5AhSWU.js → wardley-RL74JXVD-DgqjmWyN.js} +1 -1
  119. package/dist/dashboard/assets/{wardleyDiagram-NUSXRM2D-C4GeB_Df.js → wardleyDiagram-NUSXRM2D-BDpniS9m.js} +1 -1
  120. package/dist/dashboard/assets/webviewWindow-Bwv2421L.js +1 -0
  121. package/dist/dashboard/assets/window-BObcHN8e.js +1 -0
  122. package/dist/dashboard/assets/{xychartDiagram-5P7HB3ND-BPCFKXqR.js → xychartDiagram-5P7HB3ND-CCyPcx2A.js} +1 -1
  123. package/dist/dashboard/assets/{zh-CN-LNUGB5OW-BvNiZwKU.js → zh-CN-LNUGB5OW-9LJLF_AC.js} +1 -1
  124. package/dist/dashboard/assets/{zh-HK-E62DVLB3-CCFtoCWb.js → zh-HK-E62DVLB3-IefAiePp.js} +1 -1
  125. package/dist/dashboard/assets/{zh-TW-RAJ6MFWO-bYBr-stu.js → zh-TW-RAJ6MFWO-CHeY9rNZ.js} +1 -1
  126. package/dist/dashboard/index.html +2 -2
  127. package/dist/index.js +4099 -1538
  128. package/dist/skill-packs/agents/goal-implementer.md +42 -1
  129. package/dist/skill-packs/agents/goal-plan-reviewer.md +7 -0
  130. package/dist/skill-packs/agents/goal-planner.md +34 -0
  131. package/dist/skill-packs/catalog.json +31 -21
  132. package/dist/skill-packs/excalidraw/SKILL.md +244 -13
  133. package/dist/skill-packs/excalidraw/examples/Chart Kit.excalidraw.md +15740 -0
  134. package/dist/skill-packs/excalidraw/examples/Wireframe Kit.excalidraw.md +16860 -0
  135. package/dist/skill-packs/excalidraw/examples/atomic_check.js +99 -0
  136. package/dist/skill-packs/excalidraw/examples/chart.spec.json +122 -0
  137. package/dist/skill-packs/excalidraw/examples/chart_board.js +173 -0
  138. package/dist/skill-packs/excalidraw/examples/hello.spec.json +79 -9
  139. package/dist/skill-packs/excalidraw/examples/visual_board.js +82 -0
  140. package/dist/skill-packs/excalidraw/examples/wireframe_board.js +161 -0
  141. package/dist/skill-packs/excalidraw/scripts/build_excalidraw.js +292 -27
  142. package/dist/skill-packs/excalidraw/scripts/lib/charts.js +771 -0
  143. package/dist/skill-packs/excalidraw/scripts/lib/style.js +369 -35
  144. package/dist/skill-packs/excalidraw/scripts/lib/wireframe.js +406 -0
  145. package/dist/skill-packs/goal-skill/SKILL.md +369 -100
  146. package/dist/skill-packs/goal-skill/assets/goal-skill-demo.cjs +51 -0
  147. package/dist/skill-packs/goal-skill/assets/goal-skill-viewer.cjs +181 -0
  148. package/package.json +2 -1
  149. package/skill/references/integrations.md +54 -0
  150. package/skill/references/knowledge-and-recall.md +3 -1
  151. package/skill/references/sleep.md +4 -4
  152. package/skill-packs/agents/goal-implementer.md +42 -1
  153. package/skill-packs/agents/goal-plan-reviewer.md +7 -0
  154. package/skill-packs/agents/goal-planner.md +34 -0
  155. package/skill-packs/catalog.json +31 -21
  156. package/skill-packs/excalidraw/SKILL.md +244 -13
  157. package/skill-packs/excalidraw/examples/Chart Kit.excalidraw.md +15740 -0
  158. package/skill-packs/excalidraw/examples/Wireframe Kit.excalidraw.md +16860 -0
  159. package/skill-packs/excalidraw/examples/atomic_check.js +99 -0
  160. package/skill-packs/excalidraw/examples/chart.spec.json +122 -0
  161. package/skill-packs/excalidraw/examples/chart_board.js +173 -0
  162. package/skill-packs/excalidraw/examples/hello.spec.json +79 -9
  163. package/skill-packs/excalidraw/examples/visual_board.js +82 -0
  164. package/skill-packs/excalidraw/examples/wireframe_board.js +161 -0
  165. package/skill-packs/excalidraw/scripts/build_excalidraw.js +292 -27
  166. package/skill-packs/excalidraw/scripts/lib/charts.js +771 -0
  167. package/skill-packs/excalidraw/scripts/lib/style.js +369 -35
  168. package/skill-packs/excalidraw/scripts/lib/wireframe.js +406 -0
  169. package/skill-packs/goal-skill/SKILL.md +369 -100
  170. package/skill-packs/goal-skill/assets/goal-skill-demo.cjs +51 -0
  171. package/skill-packs/goal-skill/assets/goal-skill-viewer.cjs +181 -0
  172. package/skill-task-manager/SKILL.md +93 -0
  173. package/dist/dashboard/assets/channel-2YfRjala.js +0 -1
  174. package/dist/dashboard/assets/classDiagram-6PBFFD2Q-DB_UnN1h.js +0 -1
  175. package/dist/dashboard/assets/classDiagram-v2-HSJHXN6E-DB_UnN1h.js +0 -1
  176. package/dist/dashboard/assets/clone-DaDvB3N6.js +0 -1
  177. package/dist/dashboard/assets/index-BIfrbaFS.js +0 -516
  178. package/dist/dashboard/assets/index-BdZ3tppu.css +0 -32
  179. package/dist/dashboard/assets/stateDiagram-v2-QKLJ7IA2-eW9-s0Sm.js +0 -1
  180. package/dist/dashboard/assets/webviewWindow-BbG9Cr7f.js +0 -1
  181. package/dist/dashboard/assets/window-DmNpeJAL.js +0 -1
@@ -0,0 +1,771 @@
1
+ // Deterministic chart kit for Excalidraw boards. Same contract as style.js: every builder returns an
2
+ // ElementSpec[] that also carries `.x/.y/.w/.h/.nextX/.nextY`, so charts compose inside stack()/row()
3
+ // exactly like cards and funnels do.
4
+ //
5
+ // Why this exists: hand-rolling a chart out of raw rects+text is where boards break. The measure rule
6
+ // gets violated, labels collide with bars, axes drift. These builders own the geometry so the author
7
+ // only supplies DATA. Nothing here is random or time-dependent — same input ⇒ same bytes.
8
+ //
9
+ // Excalidraw has no arc primitive, so wedges/rings are emitted as filled polygons (`line` with
10
+ // backgroundColor + fillStyle:'solid'), the same trick funnel() uses for its trapezoids.
11
+ //
12
+ // Every chart:
13
+ // - keeps its own text inside its own box (labels are measured, never assumed)
14
+ // - colors by the semantic PALETTE (or an explicit per-series color)
15
+ // - degrades sanely on empty/1-point data instead of dividing by zero
16
+
17
+ const {
18
+ INK, PALETTE, WIRE, READ_W, pal,
19
+ measureText, wrapToWidth, fitText, box, bbox, translate,
20
+ } = require('./style.js');
21
+
22
+ const LH = 1.25;
23
+ const FONT = 5; // Excalifont — matches the house style
24
+
25
+ // default series color cycle (semantic palette, in a visually distinguishable order)
26
+ const CYCLE = ['blue', 'red', 'green', 'purple', 'yellow', 'mint', 'gray'];
27
+ const cyc = (i) => CYCLE[i % CYCLE.length];
28
+
29
+ const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
30
+ const txt = (o) => Object.assign({ type: 'text', fontFamily: FONT, color: INK }, o);
31
+
32
+ // Tag a composite's elements with ONE group id: the chart moves as a unit in Excalidraw, and the
33
+ // build-time auditors treat its own internals (label over its own bar, dot over its own tint) as
34
+ // intentional rather than a collision. Nested composites keep the group they already have.
35
+ let _gc = 0;
36
+ function G(els, hint) {
37
+ const g = `chart${++_gc}-${hint}`;
38
+ for (const e of els) if (e.group == null) e.group = g;
39
+ return els;
40
+ }
41
+
42
+
43
+ // ── scales ────────────────────────────────────────────────────────────────
44
+ // A linear map d0..d1 → r0..r1. Degenerate domains collapse to the range midpoint rather than NaN.
45
+ function linScale(d0, d1, r0, r1) {
46
+ const dd = d1 - d0;
47
+ if (!isFinite(dd) || dd === 0) return () => (r0 + r1) / 2;
48
+ return (v) => r0 + ((num(v) - d0) / dd) * (r1 - r0);
49
+ }
50
+
51
+ // "Nice" axis bounds: expand [min,max] to round numbers and emit evenly spaced ticks.
52
+ function niceScale(min, max, count = 5) {
53
+ let lo = num(min), hi = num(max);
54
+ if (lo === hi) { hi = lo + 1; }
55
+ if (hi < lo) { const t = lo; lo = hi; hi = t; }
56
+ const raw = (hi - lo) / Math.max(1, count);
57
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
58
+ const norm = raw / mag;
59
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
60
+ const nlo = Math.floor(lo / step) * step;
61
+ const nhi = Math.ceil(hi / step) * step;
62
+ const ticks = [];
63
+ // toFixed guards float drift (0.1+0.2) so tick labels read clean
64
+ const dp = Math.max(0, -Math.floor(Math.log10(step)));
65
+ for (let v = nlo; v <= nhi + step / 1e6; v += step) ticks.push(Number(v.toFixed(dp + 2)));
66
+ return { min: nlo, max: nhi, step, ticks, dp };
67
+ }
68
+
69
+ // Compact axis label: 12500 → 12.5K, 3_400_000 → 3.4M
70
+ function fmtNum(v, dp = 0) {
71
+ const n = num(v);
72
+ const a = Math.abs(n);
73
+ if (a >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
74
+ if (a >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
75
+ if (a >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
76
+ return dp > 0 ? n.toFixed(dp) : String(Math.round(n * 100) / 100);
77
+ }
78
+
79
+ // ── shared chrome ─────────────────────────────────────────────────────────
80
+ // Title + optional legend above a plot. Returns { els, top } where `top` is the first free y.
81
+ function chartHead({ x, y, w, title, titleSize = 20, legend = null, legendSize = 13 }) {
82
+ const els = [];
83
+ let cy = y;
84
+ let used = w;
85
+ if (title) {
86
+ // a narrow chart (heatmap, donut) must not shred its title into 3 lines — let the title set the
87
+ // width and report it, so the caller's box grows instead of the text wrapping to nothing
88
+ const tw = Math.max(w, Math.min(measureText(String(title), titleSize) + 4, READ_W * 1.6));
89
+ const t = wrapToWidth(title, titleSize, tw);
90
+ els.push(txt({ x, y: cy, text: t, fontSize: titleSize, width: tw, align: 'left' }));
91
+ cy += t.split('\n').length * titleSize * LH + 8;
92
+ used = Math.max(used, tw);
93
+ }
94
+ if (legend && legend.length) {
95
+ let lx = x;
96
+ const sw = 14, sgap = 6, igap = 18;
97
+ for (const s of legend) {
98
+ const p = pal(s.color);
99
+ const tw = measureText(s.label, legendSize);
100
+ els.push({ type: 'rectangle', x: lx, y: cy + 2, width: sw, height: sw, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1.5, roundness: true });
101
+ els.push(txt({ x: lx + sw + sgap, y: cy, text: s.label, fontSize: legendSize, color: WIRE.strong, width: tw + 4, align: 'left' }));
102
+ lx += sw + sgap + tw + igap;
103
+ }
104
+ cy += legendSize * LH + 10;
105
+ }
106
+ return { els, top: cy, w: used };
107
+ }
108
+
109
+ // The plot rectangle + axes + gridlines + tick labels. This is the piece every xy chart shares, so
110
+ // axis geometry is defined once instead of re-derived (and re-broken) per chart.
111
+ // yScale : {min,max,ticks,dp} from niceScale
112
+ // xLabels: categorical labels drawn under the axis (optional)
113
+ // Returns { els, px, py, pw, ph, sy } — sy maps a value to a canvas y.
114
+ function plotFrame({
115
+ x, y, w, h, yScale, xLabels = [], grid = true, yFmt = fmtNum,
116
+ labelSize = 12, xLabelRotate = false, padR = 16, padT = 8, yTitle = null, xTitle = null,
117
+ }) {
118
+ const els = [];
119
+ const yLabels = yScale.ticks.map((t) => yFmt(t, yScale.dp));
120
+ const padL = Math.max(...yLabels.map((l) => measureText(l, labelSize))) + 14 + (yTitle ? labelSize * LH + 8 : 0);
121
+ // x labels sit under the axis; rotated labels need the longest label's width as height
122
+ const xLabH = xLabels.length
123
+ ? (xLabelRotate ? Math.max(...xLabels.map((l) => measureText(String(l), labelSize))) * 0.72 + 10 : labelSize * LH + 8)
124
+ : 6;
125
+ const padB = xLabH + (xTitle ? labelSize * LH + 8 : 0);
126
+ const px = x + padL, py = y + padT;
127
+ const pw = Math.max(40, w - padL - padR), ph = Math.max(40, h - padT - padB);
128
+ const sy = linScale(yScale.min, yScale.max, py + ph, py);
129
+
130
+ // gridlines + y ticks
131
+ yScale.ticks.forEach((t, i) => {
132
+ const gy = sy(t);
133
+ if (grid) els.push({ type: 'line', points: [[px, gy], [px + pw, gy]], strokeColor: WIRE.line, strokeWidth: 1 });
134
+ els.push(txt({ x: px - measureText(yLabels[i], labelSize) - 10, y: gy - labelSize * 0.62, text: yLabels[i], fontSize: labelSize, color: WIRE.text, width: measureText(yLabels[i], labelSize) + 4, align: 'right' }));
135
+ });
136
+ // axes
137
+ els.push({ type: 'line', points: [[px, py], [px, py + ph]], strokeColor: WIRE.edge, strokeWidth: 1.5 });
138
+ els.push({ type: 'line', points: [[px, py + ph], [px + pw, py + ph]], strokeColor: WIRE.edge, strokeWidth: 1.5 });
139
+ if (yTitle) els.push(txt({ x, y: py + ph / 2 - labelSize, text: yTitle, fontSize: labelSize, color: WIRE.strong, width: padL - 10, align: 'left' }));
140
+ if (xTitle) els.push(txt({ x: px, y: py + ph + xLabH + 2, text: xTitle, fontSize: labelSize, color: WIRE.strong, width: pw, align: 'center' }));
141
+ return { els, px, py, pw, ph, sy, padL, padB };
142
+ }
143
+
144
+ // Place categorical x labels centered on given canvas x positions, skipping some when they'd collide.
145
+ function xTickLabels({ xs, labels, py, ph, labelSize = 12 }) {
146
+ const els = [];
147
+ if (!labels.length) return els;
148
+ // thin out labels until neighbours stop overlapping — a dense x axis stays readable
149
+ const widest = Math.max(...labels.map((l) => measureText(String(l), labelSize)));
150
+ const gapAvail = xs.length > 1 ? Math.abs(xs[1] - xs[0]) : widest + 10;
151
+ const every = Math.max(1, Math.ceil((widest + 8) / Math.max(1, gapAvail)));
152
+ labels.forEach((l, i) => {
153
+ if (i % every !== 0 && i !== labels.length - 1) return;
154
+ const tw = measureText(String(l), labelSize) + 4;
155
+ els.push(txt({ x: xs[i] - tw / 2, y: py + ph + 8, text: String(l), fontSize: labelSize, color: WIRE.text, width: tw, align: 'center' }));
156
+ });
157
+ return els;
158
+ }
159
+
160
+ // ── lineChart ─────────────────────────────────────────────────────────────
161
+ // Trend over an ordered x axis. `series[].points` is a plain number[] aligned to `xLabels`
162
+ // (or [{x,y}] for irregular spacing). Use for time series: DAU, spend, error rate.
163
+ function lineChart(o) {
164
+ const {
165
+ x = 0, y = 0, w = 720, h = 360, series = [], xLabels = [], title = null,
166
+ yMin = null, yMax = null, yTicks = 5, grid = true, markers = true, area = false,
167
+ legend = true, strokeWidth = 2.5, labelSize = 12, yFmt = fmtNum, xTitle = null, yTitle = null,
168
+ xLabelRotate = false,
169
+ } = o;
170
+ const S = series.map((s, i) => ({
171
+ label: s.label || `series ${i + 1}`,
172
+ color: s.color || cyc(i),
173
+ pts: (s.points || []).map((p, k) => (typeof p === 'number' ? { x: k, y: p } : { x: num(p.x), y: num(p.y) })),
174
+ }));
175
+ const all = S.flatMap((s) => s.pts.map((p) => p.y));
176
+ const lo = yMin != null ? yMin : Math.min(0, ...(all.length ? all : [0]));
177
+ const hi = yMax != null ? yMax : Math.max(...(all.length ? all : [1]));
178
+ const yScale = niceScale(lo, hi, yTicks);
179
+ const head = chartHead({ x, y, w, title, legend: legend && S.length > 1 ? S : null });
180
+ const fr = plotFrame({ x, y: head.top, w, h: h - (head.top - y), yScale, xLabels, grid, yFmt, labelSize, xTitle, yTitle, xLabelRotate });
181
+ const els = [...head.els, ...fr.els];
182
+
183
+ const xDom = S.flatMap((s) => s.pts.map((p) => p.x));
184
+ const sx = linScale(Math.min(...(xDom.length ? xDom : [0])), Math.max(...(xDom.length ? xDom : [1])), fr.px, fr.px + fr.pw);
185
+ for (const s of S) {
186
+ if (!s.pts.length) continue;
187
+ const p = pal(s.color);
188
+ const pts = s.pts.map((q) => [sx(q.x), fr.sy(q.y)]);
189
+ if (area && pts.length > 1) {
190
+ els.push({
191
+ type: 'line', strokeColor: 'transparent', backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1,
192
+ points: [...pts, [pts[pts.length - 1][0], fr.py + fr.ph], [pts[0][0], fr.py + fr.ph], pts[0]],
193
+ });
194
+ }
195
+ if (pts.length > 1) els.push({ type: 'line', points: pts, strokeColor: p.stroke, strokeWidth });
196
+ if (markers) for (const [mx, my] of pts) {
197
+ els.push({ type: 'ellipse', x: mx - 4, y: my - 4, width: 8, height: 8, strokeColor: p.stroke, backgroundColor: p.stroke, fillStyle: 'solid', strokeWidth: 1 });
198
+ }
199
+ }
200
+ if (xLabels.length) els.push(...xTickLabels({ xs: xLabels.map((_, i) => sx(i)), labels: xLabels, py: fr.py, ph: fr.ph, labelSize }));
201
+ return G(box(els, x, y, w, Math.max(h, fr.py + fr.ph + fr.padB - y)), 'xy');
202
+ }
203
+
204
+ // ── barChart ──────────────────────────────────────────────────────────────
205
+ // One value per category. `horizontal:true` when labels are long (they get their own column instead
206
+ // of being crammed under vertical bars).
207
+ function barChart(o) {
208
+ const {
209
+ x = 0, y = 0, w = 720, h = 360, bars = [], title = null, horizontal = false,
210
+ yMin = null, yMax = null, yTicks = 5, grid = true, valueLabels = true, color = 'blue',
211
+ labelSize = 12, valueSize = 13, yFmt = fmtNum, barGap = 0.3, xTitle = null, yTitle = null,
212
+ } = o;
213
+ const B = bars.map((b, i) => ({ label: String(b.label ?? i), value: num(b.value), color: b.color || color }));
214
+ const vals = B.map((b) => b.value);
215
+ const lo = yMin != null ? yMin : Math.min(0, ...(vals.length ? vals : [0]));
216
+ const hi = yMax != null ? yMax : Math.max(...(vals.length ? vals : [1]));
217
+ const scale = niceScale(lo, hi, yTicks);
218
+ const head = chartHead({ x, y, w, title });
219
+ const els = [...head.els];
220
+
221
+ if (horizontal) {
222
+ // labels in a left gutter, bars run rightwards
223
+ const labelW = Math.min(READ_W / 2, Math.max(...B.map((b) => measureText(b.label, labelSize))) + 12);
224
+ const px = x + labelW + 8, pw = Math.max(40, w - labelW - 60);
225
+ const top = head.top, ph = Math.max(40, h - (top - y) - 24);
226
+ const sxv = linScale(scale.min, scale.max, px, px + pw);
227
+ const band = ph / Math.max(1, B.length);
228
+ const bh = band * (1 - barGap);
229
+ if (grid) for (const t of scale.ticks) {
230
+ els.push({ type: 'line', points: [[sxv(t), top], [sxv(t), top + ph]], strokeColor: WIRE.line, strokeWidth: 1 });
231
+ const l = yFmt(t, scale.dp), tw = measureText(l, labelSize) + 4;
232
+ els.push(txt({ x: sxv(t) - tw / 2, y: top + ph + 6, text: l, fontSize: labelSize, color: WIRE.text, width: tw, align: 'center' }));
233
+ }
234
+ B.forEach((b, i) => {
235
+ const p = pal(b.color);
236
+ const by = top + i * band + (band - bh) / 2;
237
+ const x0 = sxv(Math.max(scale.min, Math.min(0, b.value))), x1 = sxv(b.value);
238
+ els.push({ type: 'rectangle', x: Math.min(x0, x1), y: by, width: Math.max(2, Math.abs(x1 - x0)), height: bh, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true });
239
+ els.push(txt({ x, y: by + bh / 2 - labelSize * 0.62, text: b.label, fontSize: labelSize, color: WIRE.strong, width: labelW, align: 'right' }));
240
+ if (valueLabels) els.push(txt({ x: Math.max(x0, x1) + 8, y: by + bh / 2 - valueSize * 0.62, text: yFmt(b.value, scale.dp), fontSize: valueSize, color: INK, width: 80, align: 'left' }));
241
+ });
242
+ els.push({ type: 'line', points: [[sxv(Math.max(scale.min, 0)), top], [sxv(Math.max(scale.min, 0)), top + ph]], strokeColor: WIRE.edge, strokeWidth: 1.5 });
243
+ return G(box(els, x, y, w, top + ph + 26 - y), 'barh');
244
+ }
245
+
246
+ const fr = plotFrame({ x, y: head.top, w, h: h - (head.top - y), yScale: scale, xLabels: B.map((b) => b.label), grid, yFmt, labelSize, xTitle, yTitle });
247
+ els.push(...fr.els);
248
+ const band = fr.pw / Math.max(1, B.length);
249
+ const bw = band * (1 - barGap);
250
+ const zero = fr.sy(Math.max(scale.min, Math.min(0, ...vals, 0)));
251
+ B.forEach((b, i) => {
252
+ const p = pal(b.color);
253
+ const bx = fr.px + i * band + (band - bw) / 2;
254
+ const vy = fr.sy(b.value);
255
+ els.push({ type: 'rectangle', x: bx, y: Math.min(vy, zero), width: bw, height: Math.max(2, Math.abs(zero - vy)), strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true });
256
+ if (valueLabels) {
257
+ const l = yFmt(b.value, scale.dp), tw = measureText(l, valueSize) + 6;
258
+ els.push(txt({ x: bx + bw / 2 - tw / 2, y: Math.min(vy, zero) - valueSize * LH - 3, text: l, fontSize: valueSize, width: tw, align: 'center' }));
259
+ }
260
+ });
261
+ els.push(...xTickLabels({ xs: B.map((_, i) => fr.px + i * band + band / 2), labels: B.map((b) => b.label), py: fr.py, ph: fr.ph, labelSize }));
262
+ return box(els, x, y, w, Math.max(h, fr.py + fr.ph + fr.padB - y));
263
+ }
264
+
265
+ // ── barCompare ────────────────────────────────────────────────────────────
266
+ // Grouped bars: the same categories measured across 2+ scenarios (W1 vs W2, before/after, plan/actual).
267
+ // This is the "did it move?" chart — the one a weekly review actually needs.
268
+ function barCompare(o) {
269
+ const {
270
+ x = 0, y = 0, w = 720, h = 380, groups = [], seriesLabels = [], title = null,
271
+ yMin = null, yMax = null, yTicks = 5, grid = true, valueLabels = true, colors = null,
272
+ labelSize = 12, valueSize = 12, yFmt = fmtNum, groupGap = 0.3, legend = true, yTitle = null,
273
+ } = o;
274
+ const G = groups.map((g, i) => ({ label: String(g.label ?? i), values: (g.values || []).map(num) }));
275
+ const nS = Math.max(0, ...G.map((g) => g.values.length));
276
+ const sLabels = Array.from({ length: nS }, (_, i) => seriesLabels[i] || `s${i + 1}`);
277
+ const sColors = Array.from({ length: nS }, (_, i) => (colors && colors[i]) || cyc(i));
278
+ const vals = G.flatMap((g) => g.values);
279
+ const lo = yMin != null ? yMin : Math.min(0, ...(vals.length ? vals : [0]));
280
+ const hi = yMax != null ? yMax : Math.max(...(vals.length ? vals : [1]));
281
+ const scale = niceScale(lo, hi, yTicks);
282
+ const head = chartHead({ x, y, w, title, legend: legend ? sLabels.map((l, i) => ({ label: l, color: sColors[i] })) : null });
283
+ const fr = plotFrame({ x, y: head.top, w, h: h - (head.top - y), yScale: scale, xLabels: G.map((g) => g.label), grid, yFmt, labelSize, yTitle });
284
+ const els = [...head.els, ...fr.els];
285
+ const band = fr.pw / Math.max(1, G.length);
286
+ const inner = band * (1 - groupGap);
287
+ const bw = inner / Math.max(1, nS);
288
+ const zero = fr.sy(Math.max(scale.min, 0));
289
+ G.forEach((g, gi) => {
290
+ g.values.forEach((v, si) => {
291
+ const p = pal(sColors[si]);
292
+ const bx = fr.px + gi * band + (band - inner) / 2 + si * bw;
293
+ const vy = fr.sy(v);
294
+ els.push({ type: 'rectangle', x: bx + 1, y: Math.min(vy, zero), width: Math.max(2, bw - 2), height: Math.max(2, Math.abs(zero - vy)), strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true });
295
+ if (valueLabels && bw > 26) {
296
+ const l = yFmt(v, scale.dp), tw = measureText(l, valueSize) + 4;
297
+ els.push(txt({ x: bx + bw / 2 - tw / 2, y: Math.min(vy, zero) - valueSize * LH - 2, text: l, fontSize: valueSize, width: tw, align: 'center' }));
298
+ }
299
+ });
300
+ });
301
+ els.push(...xTickLabels({ xs: G.map((_, i) => fr.px + i * band + band / 2), labels: G.map((g) => g.label), py: fr.py, ph: fr.ph, labelSize }));
302
+ return box(els, x, y, w, Math.max(h, fr.py + fr.ph + fr.padB - y));
303
+ }
304
+
305
+ // ── stackedBar ────────────────────────────────────────────────────────────
306
+ // Composition per category (revenue by plan, traffic by source). Segments stack to each group's total.
307
+ function stackedBar(o) {
308
+ const {
309
+ x = 0, y = 0, w = 720, h = 380, groups = [], seriesLabels = [], title = null,
310
+ yTicks = 5, grid = true, colors = null, labelSize = 12, yFmt = fmtNum, barGap = 0.35,
311
+ legend = true, totals = true, valueSize = 12, yTitle = null,
312
+ } = o;
313
+ const G = groups.map((g, i) => ({ label: String(g.label ?? i), values: (g.values || []).map(num) }));
314
+ const nS = Math.max(0, ...G.map((g) => g.values.length));
315
+ const sLabels = Array.from({ length: nS }, (_, i) => seriesLabels[i] || `s${i + 1}`);
316
+ const sColors = Array.from({ length: nS }, (_, i) => (colors && colors[i]) || cyc(i));
317
+ const sums = G.map((g) => g.values.reduce((a, b) => a + b, 0));
318
+ const scale = niceScale(0, Math.max(...(sums.length ? sums : [1])), yTicks);
319
+ const head = chartHead({ x, y, w, title, legend: legend ? sLabels.map((l, i) => ({ label: l, color: sColors[i] })) : null });
320
+ const fr = plotFrame({ x, y: head.top, w, h: h - (head.top - y), yScale: scale, xLabels: G.map((g) => g.label), grid, yFmt, labelSize, yTitle });
321
+ const els = [...head.els, ...fr.els];
322
+ const band = fr.pw / Math.max(1, G.length);
323
+ const bw = band * (1 - barGap);
324
+ G.forEach((g, gi) => {
325
+ const bx = fr.px + gi * band + (band - bw) / 2;
326
+ let acc = 0;
327
+ g.values.forEach((v, si) => {
328
+ const p = pal(sColors[si]);
329
+ const y0 = fr.sy(acc), y1 = fr.sy(acc + v);
330
+ if (Math.abs(y0 - y1) >= 1) {
331
+ els.push({ type: 'rectangle', x: bx, y: Math.min(y0, y1), width: bw, height: Math.max(1, Math.abs(y0 - y1)), strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1.5, roundness: false });
332
+ }
333
+ acc += v;
334
+ });
335
+ if (totals) {
336
+ const l = yFmt(sums[gi], scale.dp), tw = measureText(l, valueSize) + 4;
337
+ els.push(txt({ x: bx + bw / 2 - tw / 2, y: fr.sy(sums[gi]) - valueSize * LH - 3, text: l, fontSize: valueSize, width: tw, align: 'center' }));
338
+ }
339
+ });
340
+ els.push(...xTickLabels({ xs: G.map((_, i) => fr.px + i * band + band / 2), labels: G.map((g) => g.label), py: fr.py, ph: fr.ph, labelSize }));
341
+ return box(els, x, y, w, Math.max(h, fr.py + fr.ph + fr.padB - y));
342
+ }
343
+
344
+ // ── gantt ─────────────────────────────────────────────────────────────────
345
+ // Timeline bars over a date axis. `start`/`end` accept 'YYYY-MM-DD' or a day number; the axis ticks
346
+ // are derived, never hand-placed. `done` (0..1) overlays a progress fill; `milestone:true` draws a diamond.
347
+ function parseDay(v, origin) {
348
+ if (typeof v === 'number') return v;
349
+ const t = Date.parse(String(v) + (/^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? 'T00:00:00Z' : ''));
350
+ if (!isFinite(t)) return 0;
351
+ return Math.round(t / 86400000) - origin;
352
+ }
353
+ function gantt(o) {
354
+ const {
355
+ x = 0, y = 0, w = 860, tasks = [], title = null, rowH = 34, rowGap = 8,
356
+ labelSize = 13, tickSize = 11, ticks = 6, grid = true, today = null, color = 'blue',
357
+ } = o;
358
+ // resolve every date against the earliest date so the axis is plain day-offsets
359
+ const stamps = tasks.flatMap((t) => [t.start, t.end]).filter((v) => typeof v === 'string');
360
+ const origin = stamps.length ? Math.min(...stamps.map((s) => Math.round(Date.parse(s + 'T00:00:00Z') / 86400000))) : 0;
361
+ const T = tasks.map((t, i) => ({
362
+ label: String(t.label ?? i),
363
+ s: parseDay(t.start, origin),
364
+ e: parseDay(t.end != null ? t.end : t.start, origin),
365
+ color: t.color || color,
366
+ done: t.done != null ? Math.max(0, Math.min(1, num(t.done))) : null,
367
+ milestone: !!t.milestone,
368
+ }));
369
+ const lo = Math.min(...(T.length ? T.map((t) => t.s) : [0]));
370
+ const hi = Math.max(...(T.length ? T.map((t) => t.e) : [1]));
371
+ const head = chartHead({ x, y, w, title });
372
+ const els = [...head.els];
373
+ const labelW = Math.min(READ_W / 2, Math.max(60, ...T.map((t) => measureText(t.label, labelSize))) + 14);
374
+ const px = x + labelW + 10, pw = Math.max(60, w - labelW - 30);
375
+ const top = head.top + tickSize * LH + 8;
376
+ const sx = linScale(lo, hi === lo ? lo + 1 : hi, px, px + pw);
377
+ const fmtDay = (d) => {
378
+ if (!stamps.length) return String(Math.round(d));
379
+ const dt = new Date((origin + d) * 86400000);
380
+ return `${dt.getUTCDate()}/${dt.getUTCMonth() + 1}`;
381
+ };
382
+ // axis ticks across the span
383
+ const span = (hi - lo) || 1;
384
+ const step = Math.max(1, Math.round(span / Math.max(1, ticks)));
385
+ const H = T.length * (rowH + rowGap) - rowGap;
386
+ for (let d = lo; d <= hi + 0.001; d += step) {
387
+ const gx = sx(d);
388
+ if (grid) els.push({ type: 'line', points: [[gx, top], [gx, top + Math.max(0, H)]], strokeColor: WIRE.line, strokeWidth: 1 });
389
+ const l = fmtDay(d), tw = measureText(l, tickSize) + 4;
390
+ els.push(txt({ x: gx - tw / 2, y: head.top, text: l, fontSize: tickSize, color: WIRE.text, width: tw, align: 'center' }));
391
+ }
392
+ T.forEach((t, i) => {
393
+ const by = top + i * (rowH + rowGap);
394
+ const p = pal(t.color);
395
+ els.push(txt({ x, y: by + rowH / 2 - labelSize * 0.62, text: t.label, fontSize: labelSize, color: WIRE.strong, width: labelW, align: 'right' }));
396
+ if (t.milestone) {
397
+ const cx = sx(t.s), r = rowH * 0.38;
398
+ els.push({ type: 'diamond', x: cx - r, y: by + rowH / 2 - r, width: r * 2, height: r * 2, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2 });
399
+ return;
400
+ }
401
+ const x0 = sx(t.s), x1 = Math.max(sx(t.e), x0 + 3);
402
+ els.push({ type: 'rectangle', x: x0, y: by, width: x1 - x0, height: rowH, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true });
403
+ if (t.done != null && t.done > 0) {
404
+ els.push({ type: 'rectangle', x: x0, y: by + rowH * 0.3, width: Math.max(2, (x1 - x0) * t.done), height: rowH * 0.4, strokeColor: 'transparent', backgroundColor: p.stroke, fillStyle: 'solid', strokeWidth: 1, roundness: true });
405
+ }
406
+ });
407
+ if (today != null) {
408
+ const tx = sx(parseDay(today, origin));
409
+ els.push({ type: 'line', points: [[tx, top - 6], [tx, top + Math.max(0, H)]], strokeColor: PALETTE.red.stroke, strokeWidth: 2, strokeStyle: 'dashed' });
410
+ }
411
+ return G(box(els, x, y, w, top + Math.max(0, H) - y), 'gantt');
412
+ }
413
+
414
+ // ── quadrant ──────────────────────────────────────────────────────────────
415
+ // 2×2 positioning map (impact/effort, reach/confidence, Eisenhower). `items[]` carry {label,x,y} in
416
+ // 0..1 (or supply xDomain/yDomain). Quadrant tints are fixed so "top-right = win" reads instantly.
417
+ function quadrant(o) {
418
+ const {
419
+ x = 0, y = 0, w = 560, h = 560, items = [], title = null,
420
+ xAxis = { left: 'low', right: 'high' }, yAxis = { bottom: 'low', top: 'high' },
421
+ xDomain = [0, 1], yDomain = [0, 1], quadrantLabels = null,
422
+ labelSize = 13, axisSize = 13, dot = 7, tint = true, color = 'blue',
423
+ } = o;
424
+ const head = chartHead({ x, y, w, title });
425
+ const els = [...head.els];
426
+ // gutter must fit the y captions horizontally, else they wrap to one glyph per line
427
+ const padL = Math.max(measureText(String(yAxis.top || ''), axisSize), measureText(String(yAxis.bottom || ''), axisSize)) + 12;
428
+ const padB = axisSize * LH + 10;
429
+ const px = x + padL, py = head.top;
430
+ const pw = Math.max(60, w - padL - 8), ph = Math.max(60, h - (head.top - y) - padB);
431
+ const sx = linScale(xDomain[0], xDomain[1], px, px + pw);
432
+ const sy = linScale(yDomain[0], yDomain[1], py + ph, py);
433
+ const mx = px + pw / 2, my = py + ph / 2;
434
+ if (tint) {
435
+ // TL / TR / BL / BR — TR is the "go" quadrant
436
+ const q = [['#f8f9fa', px, py], [PALETTE.paleGreen.fill, mx, py], [PALETTE.gray.fill, px, my], [PALETTE.paleBlue.fill, mx, my]];
437
+ for (const [fill, qx, qy] of q) els.push({ type: 'rectangle', x: qx, y: qy, width: pw / 2, height: ph / 2, strokeColor: 'transparent', backgroundColor: fill, fillStyle: 'solid', strokeWidth: 1, roundness: false });
438
+ }
439
+ els.push({ type: 'rectangle', x: px, y: py, width: pw, height: ph, strokeColor: WIRE.edge, backgroundColor: 'transparent', strokeWidth: 1.5, roundness: false });
440
+ els.push({ type: 'line', points: [[mx, py], [mx, py + ph]], strokeColor: WIRE.edge, strokeWidth: 1.5, strokeStyle: 'dashed' });
441
+ els.push({ type: 'line', points: [[px, my], [px + pw, my]], strokeColor: WIRE.edge, strokeWidth: 1.5, strokeStyle: 'dashed' });
442
+ if (quadrantLabels) {
443
+ // outer corners, aligned outward — the middle is where the points live
444
+ const qw = pw / 2 - 20;
445
+ const corners = [
446
+ [quadrantLabels.tl, px + 10, py + 8, 'left'],
447
+ [quadrantLabels.tr, mx + 10, py + 8, 'right'],
448
+ [quadrantLabels.bl, px + 10, my + ph / 2 - labelSize * LH - 8, 'left'],
449
+ [quadrantLabels.br, mx + 10, my + ph / 2 - labelSize * LH - 8, 'right'],
450
+ ];
451
+ for (const [l, lx, ly, al] of corners) {
452
+ if (!l) continue;
453
+ els.push(txt({ x: lx, y: ly, text: wrapToWidth(String(l), labelSize, qw), fontSize: labelSize, color: WIRE.hint, width: qw, align: al }));
454
+ }
455
+ }
456
+ // axis captions
457
+ els.push(txt({ x: px, y: py + ph + 6, text: xAxis.left || '', fontSize: axisSize, color: WIRE.strong, width: pw / 2, align: 'left' }));
458
+ els.push(txt({ x: mx, y: py + ph + 6, text: xAxis.right || '', fontSize: axisSize, color: WIRE.strong, width: pw / 2, align: 'right' }));
459
+ els.push(txt({ x, y: py + ph - axisSize * LH, text: String(yAxis.bottom || ''), fontSize: axisSize, color: WIRE.strong, width: padL - 6, align: 'left' }));
460
+ els.push(txt({ x, y: py, text: String(yAxis.top || ''), fontSize: axisSize, color: WIRE.strong, width: padL - 6, align: 'left' }));
461
+ for (const it of items) {
462
+ const p = pal(it.color || color);
463
+ const cx = sx(num(it.x)), cy = sy(num(it.y));
464
+ els.push({ type: 'ellipse', x: cx - dot, y: cy - dot, width: dot * 2, height: dot * 2, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2 });
465
+ if (it.label) {
466
+ // label sits toward the plot interior so it can't run off the frame
467
+ const tw = Math.min(measureText(it.label, labelSize) + 4, pw / 2);
468
+ const right = cx < mx;
469
+ els.push(txt({ x: right ? cx + dot + 5 : cx - dot - 5 - tw, y: cy - labelSize * 0.62, text: it.label, fontSize: labelSize, width: tw, align: right ? 'left' : 'right' }));
470
+ }
471
+ }
472
+ return G(box(els, x, y, w, py + ph + padB - y), 'quadrant');
473
+ }
474
+
475
+ // ── donut / pie ───────────────────────────────────────────────────────────
476
+ // Composition of a whole. Excalidraw has no arc, so each wedge is a filled polygon approximating the
477
+ // sector; `inner` > 0 makes it a ring. Keep to <= ~6 slices — beyond that a barChart reads better.
478
+ function donut(o) {
479
+ const {
480
+ x = 0, y = 0, r = 130, slices = [], title = null, inner = 0.55, legend = true,
481
+ seg = 64, labelSize = 13, startAngle = -Math.PI / 2, percent = true, legendGap = 28,
482
+ } = o;
483
+ const S = slices.map((s, i) => ({ label: String(s.label ?? i), value: Math.max(0, num(s.value)), color: s.color || cyc(i) }));
484
+ const total = S.reduce((a, s) => a + s.value, 0) || 1;
485
+ const legendW = legend ? Math.max(0, ...S.map((s) => measureText(`${s.label} — ${percent ? Math.round((s.value / total) * 100) + '%' : fmtNum(s.value)}`, labelSize))) + 26 : 0;
486
+ const w = r * 2 + (legend ? legendGap + legendW : 0);
487
+ const head = chartHead({ x, y, w, title });
488
+ const els = [...head.els];
489
+ const cx = x + r, cy = head.top + r;
490
+ let a0 = startAngle;
491
+ for (const s of S) {
492
+ const sweep = (s.value / total) * Math.PI * 2;
493
+ if (sweep <= 0) continue;
494
+ const p = pal(s.color);
495
+ const n = Math.max(2, Math.ceil((sweep / (Math.PI * 2)) * seg));
496
+ const outer = [], innerPts = [];
497
+ for (let i = 0; i <= n; i++) {
498
+ const a = a0 + (sweep * i) / n;
499
+ outer.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
500
+ if (inner > 0) innerPts.push([cx + Math.cos(a) * r * inner, cy + Math.sin(a) * r * inner]);
501
+ }
502
+ const ring = inner > 0 ? [...outer, ...innerPts.reverse(), outer[0]] : [[cx, cy], ...outer, [cx, cy]];
503
+ els.push({ type: 'line', points: ring, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1.5 });
504
+ a0 += sweep;
505
+ }
506
+ if (legend) {
507
+ let ly = head.top + Math.max(0, r - (S.length * (labelSize * LH + 8)) / 2);
508
+ const lx = x + r * 2 + legendGap;
509
+ for (const s of S) {
510
+ const p = pal(s.color);
511
+ els.push({ type: 'rectangle', x: lx, y: ly + 1, width: 13, height: 13, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1.5, roundness: true });
512
+ const l = `${s.label} — ${percent ? Math.round((s.value / total) * 100) + '%' : fmtNum(s.value)}`;
513
+ els.push(txt({ x: lx + 20, y: ly, text: l, fontSize: labelSize, color: WIRE.strong, width: legendW, align: 'left' }));
514
+ ly += labelSize * LH + 8;
515
+ }
516
+ }
517
+ return G(box(els, x, y, Math.max(w, head.w), head.top + r * 2 - y), 'donut');
518
+ }
519
+
520
+ // ── sparkline ─────────────────────────────────────────────────────────────
521
+ // A tiny trend with no axes — for inside a KPI tile or a table cell. Not a chart; a glyph.
522
+ function sparkline(o) {
523
+ const {
524
+ x = 0, y = 0, w = 140, h = 36, points = [], color = 'blue', strokeWidth = 2,
525
+ fill = false, last = true, baseline = false,
526
+ } = o;
527
+ const P = points.map(num);
528
+ if (!P.length) return box([], x, y, w, h);
529
+ const lo = Math.min(...P), hi = Math.max(...P);
530
+ const sy = linScale(lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi, y + h - 2, y + 2);
531
+ const sx = linScale(0, Math.max(1, P.length - 1), x, x + w);
532
+ const pts = P.map((v, i) => [sx(i), sy(v)]);
533
+ const p = pal(color);
534
+ const els = [];
535
+ if (baseline) els.push({ type: 'line', points: [[x, y + h - 1], [x + w, y + h - 1]], strokeColor: WIRE.line, strokeWidth: 1 });
536
+ if (fill && pts.length > 1) els.push({ type: 'line', strokeColor: 'transparent', backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1, points: [...pts, [pts[pts.length - 1][0], y + h], [pts[0][0], y + h], pts[0]] });
537
+ if (pts.length > 1) els.push({ type: 'line', points: pts, strokeColor: p.stroke, strokeWidth });
538
+ if (last) { const [lx, ly] = pts[pts.length - 1]; els.push({ type: 'ellipse', x: lx - 3, y: ly - 3, width: 6, height: 6, strokeColor: p.stroke, backgroundColor: p.stroke, fillStyle: 'solid', strokeWidth: 1 }); }
539
+ return G(box(els, x, y, w, h), 'spark');
540
+ }
541
+
542
+ // ── heatmap ───────────────────────────────────────────────────────────────
543
+ // Matrix of intensities (cohort × day retention, activity by hour). Cell tint is interpolated between
544
+ // two hexes, so magnitude reads without a legend lookup.
545
+ function lerpHex(a, b, t) {
546
+ const pc = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
547
+ const [r1, g1, b1] = pc(a), [r2, g2, b2] = pc(b);
548
+ const m = (u, v) => Math.round(u + (v - u) * Math.max(0, Math.min(1, t))).toString(16).padStart(2, '0');
549
+ return `#${m(r1, r2)}${m(g1, g2)}${m(b1, b2)}`;
550
+ }
551
+ function heatmap(o) {
552
+ const {
553
+ x = 0, y = 0, rows = [], cols = [], values = [], title = null,
554
+ cell = 44, gap = 3, from = '#ffffff', to = '#1971c2', labelSize = 12, valueSize = 11,
555
+ showValues = true, vMin = null, vMax = null, fmt = (v) => (v == null ? '' : String(v)),
556
+ } = o;
557
+ const flat = values.flat().filter((v) => typeof v === 'number');
558
+ const lo = vMin != null ? vMin : Math.min(...(flat.length ? flat : [0]));
559
+ const hi = vMax != null ? vMax : Math.max(...(flat.length ? flat : [1]));
560
+ const rowW = Math.max(0, ...rows.map((r) => measureText(String(r), labelSize))) + 10;
561
+ const w = rowW + cols.length * (cell + gap);
562
+ const head = chartHead({ x, y, w, title });
563
+ const els = [...head.els];
564
+ const top = head.top + labelSize * LH + 6;
565
+ cols.forEach((c, j) => {
566
+ const tw = measureText(String(c), labelSize) + 4;
567
+ els.push(txt({ x: x + rowW + j * (cell + gap) + cell / 2 - tw / 2, y: head.top, text: String(c), fontSize: labelSize, color: WIRE.text, width: tw, align: 'center' }));
568
+ });
569
+ rows.forEach((r, i) => {
570
+ const cy = top + i * (cell + gap);
571
+ els.push(txt({ x, y: cy + cell / 2 - labelSize * 0.62, text: String(r), fontSize: labelSize, color: WIRE.strong, width: rowW - 8, align: 'right' }));
572
+ cols.forEach((_, j) => {
573
+ const v = (values[i] || [])[j];
574
+ const has = typeof v === 'number';
575
+ const t = has && hi !== lo ? (v - lo) / (hi - lo) : (has ? 1 : 0);
576
+ const cx = x + rowW + j * (cell + gap);
577
+ els.push({ type: 'rectangle', x: cx, y: cy, width: cell, height: cell, strokeColor: WIRE.line, backgroundColor: has ? lerpHex(from, to, t) : WIRE.panel, fillStyle: 'solid', strokeWidth: 1, roundness: true });
578
+ if (showValues && has) {
579
+ // flip label to white once the cell is dark enough to swallow ink
580
+ els.push(txt({ x: cx, y: cy + cell / 2 - valueSize * 0.62, text: fmt(v), fontSize: valueSize, color: t > 0.55 ? '#ffffff' : INK, width: cell, align: 'center' }));
581
+ }
582
+ });
583
+ });
584
+ return G(box(els, x, y, Math.max(w, head.w), top + rows.length * (cell + gap) - gap - y), 'heatmap');
585
+ }
586
+
587
+ // ── table ─────────────────────────────────────────────────────────────────
588
+ // A real grid. Column widths derive from content (capped), so cells never spill — the failure mode of
589
+ // faking a table with hand-placed text.
590
+ function table(o) {
591
+ const {
592
+ x = 0, y = 0, headers = [], rows = [], title = null, fontSize = 13, headerSize = 13,
593
+ padX = 10, padY = 8, maxColW = 260, minColW = 60, zebra = true, headColor = 'gray', align = [],
594
+ } = o;
595
+ const nC = Math.max(headers.length, ...(rows.length ? rows.map((r) => r.length) : [0]));
596
+ const colW = [];
597
+ for (let j = 0; j < nC; j++) {
598
+ const cells = [headers[j] ?? '', ...rows.map((r) => (r[j] ?? ''))].map((c) => String(typeof c === 'object' && c ? c.text ?? '' : c));
599
+ const want = Math.max(...cells.map((c) => measureText(c, fontSize))) + padX * 2;
600
+ colW.push(Math.max(minColW, Math.min(maxColW, want)));
601
+ }
602
+ const w = colW.reduce((a, b) => a + b, 0);
603
+ const head = chartHead({ x, y, w, title });
604
+ const els = [...head.els];
605
+ const wrapCell = (c, j) => wrapToWidth(String(c), fontSize, colW[j] - padX * 2);
606
+ const rowHeights = rows.map((r) => Math.max(...Array.from({ length: nC }, (_, j) => {
607
+ const c = r[j]; const t = typeof c === 'object' && c ? c.text ?? '' : c ?? '';
608
+ return wrapCell(t, j).split('\n').length;
609
+ })) * fontSize * LH + padY * 2);
610
+ const headH = headers.length ? Math.max(...headers.map((c, j) => wrapCell(c ?? '', j).split('\n').length)) * headerSize * LH + padY * 2 : 0;
611
+ let cy = head.top;
612
+ const colX = []; let acc = x;
613
+ for (let j = 0; j < nC; j++) { colX.push(acc); acc += colW[j]; }
614
+ if (headers.length) {
615
+ const p = pal(headColor);
616
+ els.push({ type: 'rectangle', x, y: cy, width: w, height: headH, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1.5, roundness: false });
617
+ headers.forEach((c, j) => els.push(txt({ x: colX[j] + padX, y: cy + padY, text: wrapCell(c ?? '', j), fontSize: headerSize, color: INK, width: colW[j] - padX * 2, align: align[j] || 'left' })));
618
+ cy += headH;
619
+ }
620
+ rows.forEach((r, i) => {
621
+ const rh = rowHeights[i];
622
+ els.push({ type: 'rectangle', x, y: cy, width: w, height: rh, strokeColor: WIRE.line, backgroundColor: zebra && i % 2 ? WIRE.panel : '#ffffff', fillStyle: 'solid', strokeWidth: 1, roundness: false });
623
+ for (let j = 0; j < nC; j++) {
624
+ const c = r[j];
625
+ const obj = typeof c === 'object' && c ? c : null;
626
+ const t = obj ? obj.text ?? '' : c ?? '';
627
+ if (obj && obj.color) {
628
+ const p = pal(obj.color);
629
+ els.push({ type: 'rectangle', x: colX[j] + 2, y: cy + 2, width: colW[j] - 4, height: rh - 4, strokeColor: 'transparent', backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 1, roundness: true });
630
+ }
631
+ els.push(txt({ x: colX[j] + padX, y: cy + padY, text: wrapCell(t, j), fontSize, color: INK, width: colW[j] - padX * 2, align: align[j] || 'left' }));
632
+ }
633
+ cy += rh;
634
+ });
635
+ // column rules drawn last so they sit above the row fills
636
+ for (let j = 1; j < nC; j++) els.push({ type: 'line', points: [[colX[j], head.top], [colX[j], cy]], strokeColor: WIRE.line, strokeWidth: 1 });
637
+ return G(box(els, x, y, Math.max(w, head.w), cy - y), 'table');
638
+ }
639
+
640
+ // ── timeline ──────────────────────────────────────────────────────────────
641
+ // Milestones on a horizontal track, labels alternating above/below so they never collide.
642
+ function timeline(o) {
643
+ const {
644
+ x = 0, y = 0, w = 860, events = [], title = null, labelSize = 13, dateSize = 11,
645
+ color = 'blue', dot = 9, laneGap = 26, noteW = 190,
646
+ } = o;
647
+ const stamps = events.map((e) => e.at).filter((v) => typeof v === 'string');
648
+ const origin = stamps.length ? Math.min(...stamps.map((s) => Math.round(Date.parse(s + 'T00:00:00Z') / 86400000))) : 0;
649
+ const E = events.map((e, i) => ({
650
+ label: String(e.label ?? i), at: parseDay(e.at, origin), color: e.color || color,
651
+ date: typeof e.at === 'string' ? e.at.slice(5) : String(e.at),
652
+ }));
653
+ const lo = Math.min(...(E.length ? E.map((e) => e.at) : [0]));
654
+ const hi = Math.max(...(E.length ? E.map((e) => e.at) : [1]));
655
+ const head = chartHead({ x, y, w, title });
656
+ const els = [...head.els];
657
+ const wrapped = E.map((e) => wrapToWidth(e.label, labelSize, noteW));
658
+ const maxLines = Math.max(1, ...wrapped.map((t) => t.split('\n').length));
659
+ const laneH = maxLines * labelSize * LH + dateSize * LH + 6;
660
+ const trackY = head.top + laneH + laneGap;
661
+ const px = x + noteW / 2, pw = Math.max(60, w - noteW);
662
+ const sx = linScale(lo, hi === lo ? lo + 1 : hi, px, px + pw);
663
+ els.push({ type: 'line', points: [[x, trackY], [x + w, trackY]], strokeColor: WIRE.edge, strokeWidth: 2 });
664
+ E.forEach((e, i) => {
665
+ const cx = sx(e.at), p = pal(e.color), up = i % 2 === 0;
666
+ els.push({ type: 'ellipse', x: cx - dot, y: trackY - dot, width: dot * 2, height: dot * 2, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2 });
667
+ els.push({ type: 'line', points: [[cx, trackY + (up ? -dot : dot)], [cx, trackY + (up ? -laneGap + 4 : laneGap - 4)]], strokeColor: WIRE.edge, strokeWidth: 1.5 });
668
+ const t = wrapped[i];
669
+ const lines = t.split('\n').length;
670
+ const ty = up ? trackY - laneGap - (lines * labelSize * LH) - dateSize * LH - 2 : trackY + laneGap;
671
+ els.push(txt({ x: cx - noteW / 2, y: ty, text: e.date, fontSize: dateSize, color: WIRE.text, width: noteW, align: 'center' }));
672
+ els.push(txt({ x: cx - noteW / 2, y: ty + dateSize * LH + 2, text: t, fontSize: labelSize, color: INK, width: noteW, align: 'center' }));
673
+ });
674
+ return G(box(els, x, y, w, trackY + laneH + laneGap - y), 'timeline');
675
+ }
676
+
677
+ // ── kpi ───────────────────────────────────────────────────────────────────
678
+ // The metric tile: label / big value / delta, three type sizes for hierarchy. Optional `spark`
679
+ // (number[]) draws a sparkline in the tile's footer. This is the piece every review board rebuilds.
680
+ function kpi(o) {
681
+ const {
682
+ x = 0, y = 0, w = 300, h = 160, label = '', value = '', delta = null, color = 'blue',
683
+ valueSize = 40, labelSize = 14, deltaSize = 13, minValueFont = 18, spark = null,
684
+ sparkColor = null, padX = 14, padY = 12, align = 'center',
685
+ } = o;
686
+ const p = pal(color);
687
+ const inner = w - padX * 2;
688
+ const lTxt = wrapToWidth(String(label), labelSize, inner);
689
+ const lH = lTxt.split('\n').length * labelSize * LH;
690
+ const vFit = fitText({ text: String(value), w, h: valueSize * 1.6, fontSize: valueSize, minFont: minValueFont, padX });
691
+ const vH = vFit.lineCount * vFit.fontSize * LH;
692
+ const dTxt = delta ? wrapToWidth(String(delta), deltaSize, inner) : null;
693
+ const dH = dTxt ? dTxt.split('\n').length * deltaSize * LH : 0;
694
+ const spH = spark && spark.length ? 30 : 0;
695
+ const total = lH + 8 + vH + (dTxt ? 6 + dH : 0) + (spH ? 8 + spH : 0);
696
+ const height = Math.max(h, total + padY * 2);
697
+ let cy = y + (height - total) / 2;
698
+ const els = [{ type: 'rectangle', x, y, width: w, height, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true }];
699
+ els.push(txt({ x: x + padX, y: cy, text: lTxt, fontSize: labelSize, color: WIRE.strong, width: inner, align }));
700
+ cy += lH + 8;
701
+ els.push(txt({ x: x + padX, y: cy, text: vFit.text, fontSize: vFit.fontSize, color: INK, width: inner, align }));
702
+ cy += vH;
703
+ if (dTxt) { cy += 6; els.push(txt({ x: x + padX, y: cy, text: dTxt, fontSize: deltaSize, color: WIRE.strong, width: inner, align })); cy += dH; }
704
+ if (spH) { cy += 8; els.push(...sparkline({ x: x + padX, y: cy, w: inner, h: spH, points: spark, color: sparkColor || color, fill: true })); }
705
+ return G(box(els, x, y, w, height), 'kpi');
706
+ }
707
+
708
+ // ── callout ───────────────────────────────────────────────────────────────
709
+ // A titled note band. Body copy is ALWAYS bounded to <= READ_W: a wide band puts its heading beside
710
+ // the text (label | body) rather than stretching one line edge-to-edge. Never splits short copy into
711
+ // columns — a 1-line "column" reads as a torn sentence, which is worse than a long line.
712
+ // `w` is a MAXIMUM, not a target: the band shrinks to hug its own wrapped text. A callout handed 1080px
713
+ // for 620px of copy used to pad the difference with dead space (~27% on a real board — an empty title
714
+ // gutter plus a right margin). Sizing to content is what makes the primitive atomic: the caller says
715
+ // "at most this wide" and the builder works out the rest. Pass `fit: false` for a deliberate full-width
716
+ // band, or pin `titleW` to align the title gutters of several stacked callouts.
717
+ function callout(o) {
718
+ const {
719
+ x = 0, y = 0, w = 660, title = null, text = '', color = 'gray',
720
+ fontSize = 15, titleSize = 16, minH = 0, sideTitle = null, titleW = null,
721
+ padX = 16, padY = 14, gap = 40, fit = true, minW = 0,
722
+ } = o;
723
+ const p = pal(color);
724
+ const avail = w - padX * 2;
725
+ // auto: a band with room for a title column AND a full measure beside it uses the side layout
726
+ const side = sideTitle == null ? (!!title && avail >= READ_W + 200) : (sideTitle && !!title);
727
+ const widest = (s, fs) => Math.max(0, ...String(s).split('\n').map((l) => measureText(l, fs)));
728
+ const parts = [];
729
+ let h, natural;
730
+ if (side) {
731
+ const tCap = titleW != null ? titleW : Math.min(300, Math.max(80, avail - READ_W - gap));
732
+ const tWrap = wrapToWidth(String(title), titleSize, tCap);
733
+ const tW = titleW != null ? titleW : Math.min(tCap, Math.max(40, widest(tWrap, titleSize)));
734
+ const bCap = Math.min(avail - tW - gap, READ_W);
735
+ const bWrap = wrapToWidth(String(text), fontSize, bCap);
736
+ const bW = Math.min(bCap, Math.max(40, widest(bWrap, fontSize)));
737
+ parts.push({ text: tWrap, size: titleSize, dy: padY, color: p.stroke, x: x + padX, w: tW });
738
+ parts.push({ text: bWrap, size: fontSize, dy: padY, color: INK, x: x + padX + tW + gap, w: bW });
739
+ h = Math.max(tWrap.split('\n').length * titleSize * LH, bWrap.split('\n').length * fontSize * LH) + padY * 2;
740
+ natural = padX + tW + gap + bW + padX;
741
+ } else {
742
+ const colW = Math.min(avail, READ_W);
743
+ h = padY;
744
+ let used = 0;
745
+ if (title) {
746
+ const t = wrapToWidth(String(title), titleSize, colW);
747
+ parts.push({ text: t, size: titleSize, dy: h, color: p.stroke, x: x + padX, w: colW });
748
+ h += t.split('\n').length * titleSize * LH + 6;
749
+ used = Math.max(used, widest(t, titleSize));
750
+ }
751
+ const b = wrapToWidth(String(text), fontSize, colW);
752
+ parts.push({ text: b, size: fontSize, dy: h, color: INK, x: x + padX, w: colW });
753
+ h += b.split('\n').length * fontSize * LH + padY;
754
+ used = Math.max(used, widest(b, fontSize));
755
+ natural = padX + Math.max(40, used) + padX;
756
+ }
757
+ h = Math.max(h, minH);
758
+ const width = fit ? Math.max(minW, Math.min(w, Math.ceil(natural))) : w;
759
+ const els = [{ type: 'rectangle', x, y, width, height: h, strokeColor: p.stroke, backgroundColor: p.fill, fillStyle: 'solid', strokeWidth: 2, roundness: true }];
760
+ for (const pp of parts) els.push(txt({ x: pp.x, y: y + pp.dy, text: pp.text, fontSize: pp.size, color: pp.color, width: pp.w, align: 'left' }));
761
+ return G(box(els, x, y, width, h), 'callout');
762
+ }
763
+
764
+ module.exports = {
765
+ // charts
766
+ lineChart, barChart, barCompare, stackedBar, gantt, quadrant, donut, sparkline, heatmap, table, timeline,
767
+ // tiles / copy
768
+ kpi, callout,
769
+ // scale + format helpers (exported for custom charts)
770
+ linScale, niceScale, fmtNum, lerpHex, chartHead, plotFrame, xTickLabels, CYCLE,
771
+ };