matterviz 0.3.1 → 0.3.3

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 (358) hide show
  1. package/dist/EmptyState.svelte +10 -2
  2. package/dist/FilePicker.svelte +154 -96
  3. package/dist/Icon.svelte +20 -14
  4. package/dist/MillerIndexInput.svelte +27 -21
  5. package/dist/api/optimade.js +6 -6
  6. package/dist/app.css +216 -178
  7. package/dist/brillouin/BrillouinZone.svelte +299 -198
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  9. package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +74 -55
  11. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte +277 -165
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  15. package/dist/brillouin/BrillouinZoneTooltip.svelte +17 -7
  16. package/dist/brillouin/compute.js +11 -6
  17. package/dist/chempot-diagram/ChemPotDiagram.svelte +327 -0
  18. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  19. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +847 -0
  20. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  21. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +3194 -0
  22. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  23. package/dist/chempot-diagram/ChemPotScene3D.svelte +11 -0
  24. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  25. package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
  26. package/dist/chempot-diagram/async-compute.svelte.js +77 -0
  27. package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
  28. package/dist/chempot-diagram/chempot-worker.js +11 -0
  29. package/dist/chempot-diagram/color.d.ts +10 -0
  30. package/dist/chempot-diagram/color.js +32 -0
  31. package/dist/chempot-diagram/compute.d.ts +48 -0
  32. package/dist/chempot-diagram/compute.js +812 -0
  33. package/dist/chempot-diagram/index.d.ts +6 -0
  34. package/dist/chempot-diagram/index.js +6 -0
  35. package/dist/chempot-diagram/pointer.d.ts +16 -0
  36. package/dist/chempot-diagram/pointer.js +40 -0
  37. package/dist/chempot-diagram/temperature.d.ts +15 -0
  38. package/dist/chempot-diagram/temperature.js +36 -0
  39. package/dist/chempot-diagram/types.d.ts +86 -0
  40. package/dist/chempot-diagram/types.js +28 -0
  41. package/dist/colors/index.d.ts +3 -1
  42. package/dist/colors/index.js +9 -3
  43. package/dist/composition/BarChart.svelte +141 -77
  44. package/dist/composition/BubbleChart.svelte +107 -52
  45. package/dist/composition/Composition.svelte +100 -79
  46. package/dist/composition/Formula.svelte +108 -62
  47. package/dist/composition/FormulaFilter.svelte +973 -353
  48. package/dist/composition/FormulaFilter.svelte.d.ts +35 -1
  49. package/dist/composition/PieChart.svelte +199 -99
  50. package/dist/composition/PieChart.svelte.d.ts +1 -1
  51. package/dist/composition/format.d.ts +5 -0
  52. package/dist/composition/format.js +20 -3
  53. package/dist/composition/parse.js +14 -9
  54. package/dist/convex-hull/ConvexHull.svelte +93 -38
  55. package/dist/convex-hull/ConvexHull2D.svelte +551 -393
  56. package/dist/convex-hull/ConvexHull3D.svelte +1303 -825
  57. package/dist/convex-hull/ConvexHull4D.svelte +1012 -686
  58. package/dist/convex-hull/ConvexHullControls.svelte +115 -28
  59. package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
  60. package/dist/convex-hull/ConvexHullStats.svelte +821 -249
  61. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  62. package/dist/convex-hull/ConvexHullTooltip.svelte +41 -16
  63. package/dist/convex-hull/GasPressureControls.svelte +104 -61
  64. package/dist/convex-hull/StructurePopup.svelte +25 -4
  65. package/dist/convex-hull/TemperatureSlider.svelte +45 -25
  66. package/dist/convex-hull/barycentric-coords.js +13 -7
  67. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  68. package/dist/convex-hull/demo-temperature.js +40 -0
  69. package/dist/convex-hull/gas-thermodynamics.js +17 -12
  70. package/dist/convex-hull/helpers.d.ts +10 -1
  71. package/dist/convex-hull/helpers.js +79 -38
  72. package/dist/convex-hull/index.d.ts +1 -0
  73. package/dist/convex-hull/index.js +1 -0
  74. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  75. package/dist/convex-hull/thermodynamics.js +163 -69
  76. package/dist/convex-hull/types.d.ts +12 -12
  77. package/dist/convex-hull/types.js +0 -12
  78. package/dist/coordination/CoordinationBarPlot.svelte +232 -176
  79. package/dist/element/BohrAtom.svelte +56 -13
  80. package/dist/element/ElementHeading.svelte +7 -2
  81. package/dist/element/ElementPhoto.svelte +15 -9
  82. package/dist/element/ElementStats.svelte +10 -4
  83. package/dist/element/ElementTile.svelte +137 -73
  84. package/dist/element/Nucleus.svelte +39 -11
  85. package/dist/element/data.js +2 -14
  86. package/dist/element/data.json.gz +0 -0
  87. package/dist/element/types.d.ts +1 -0
  88. package/dist/feedback/ClickFeedback.svelte +16 -5
  89. package/dist/feedback/DragOverlay.svelte +10 -2
  90. package/dist/feedback/Spinner.svelte +4 -2
  91. package/dist/feedback/StatusMessage.svelte +8 -2
  92. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  93. package/dist/fermi-surface/FermiSurface.svelte +336 -239
  94. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  95. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  96. package/dist/fermi-surface/FermiSurfaceScene.svelte +536 -343
  97. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  98. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  99. package/dist/fermi-surface/compute.js +16 -20
  100. package/dist/fermi-surface/parse.js +37 -33
  101. package/dist/fermi-surface/symmetry.js +2 -7
  102. package/dist/fermi-surface/types.d.ts +3 -5
  103. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1527 -0
  104. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  105. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +225 -0
  106. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +30 -0
  107. package/dist/heatmap-matrix/index.d.ts +53 -0
  108. package/dist/heatmap-matrix/index.js +100 -0
  109. package/dist/heatmap-matrix/shared.d.ts +2 -0
  110. package/dist/heatmap-matrix/shared.js +4 -0
  111. package/dist/icons.d.ts +111 -0
  112. package/dist/icons.js +158 -0
  113. package/dist/index.d.ts +5 -2
  114. package/dist/index.js +5 -2
  115. package/dist/io/decompress.js +1 -1
  116. package/dist/io/export.d.ts +3 -0
  117. package/dist/io/export.js +138 -140
  118. package/dist/io/file-drop.d.ts +7 -0
  119. package/dist/io/file-drop.js +43 -0
  120. package/dist/io/index.d.ts +2 -2
  121. package/dist/io/index.js +2 -112
  122. package/dist/io/is-binary.js +2 -3
  123. package/dist/io/types.d.ts +1 -0
  124. package/dist/io/url-drop.d.ts +2 -0
  125. package/dist/io/url-drop.js +117 -0
  126. package/dist/isosurface/Isosurface.svelte +220 -110
  127. package/dist/isosurface/IsosurfaceControls.svelte +65 -28
  128. package/dist/isosurface/parse.js +104 -56
  129. package/dist/isosurface/slice.d.ts +2 -1
  130. package/dist/isosurface/slice.js +8 -13
  131. package/dist/isosurface/types.d.ts +14 -1
  132. package/dist/isosurface/types.js +152 -5
  133. package/dist/labels.d.ts +2 -1
  134. package/dist/labels.js +12 -8
  135. package/dist/layout/FullscreenToggle.svelte +11 -2
  136. package/dist/layout/InfoCard.svelte +38 -6
  137. package/dist/layout/InfoTag.svelte +125 -94
  138. package/dist/layout/PropertyFilter.svelte +82 -37
  139. package/dist/layout/SettingsSection.svelte +85 -55
  140. package/dist/layout/SubpageGrid.svelte +82 -0
  141. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  142. package/dist/layout/index.d.ts +1 -0
  143. package/dist/layout/index.js +1 -0
  144. package/dist/layout/json-tree/JsonNode.svelte +266 -223
  145. package/dist/layout/json-tree/JsonTree.svelte +516 -429
  146. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  147. package/dist/layout/json-tree/JsonValue.svelte +281 -173
  148. package/dist/layout/json-tree/types.d.ts +10 -2
  149. package/dist/layout/json-tree/utils.d.ts +2 -0
  150. package/dist/layout/json-tree/utils.js +37 -2
  151. package/dist/marching-cubes.js +25 -2
  152. package/dist/math.d.ts +20 -17
  153. package/dist/math.js +474 -57
  154. package/dist/overlays/ContextMenu.svelte +66 -40
  155. package/dist/overlays/DraggablePane.svelte +331 -154
  156. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  157. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  158. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  159. package/dist/periodic-table/PropertySelect.svelte +25 -7
  160. package/dist/periodic-table/TableInset.svelte +8 -3
  161. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +559 -267
  162. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  163. package/dist/phase-diagram/PhaseDiagramControls.svelte +131 -51
  164. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  165. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +126 -0
  166. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  167. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +160 -110
  168. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +8 -1
  169. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +217 -86
  170. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  171. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  172. package/dist/phase-diagram/build-diagram.js +9 -9
  173. package/dist/phase-diagram/colors.js +1 -3
  174. package/dist/phase-diagram/index.d.ts +2 -0
  175. package/dist/phase-diagram/index.js +2 -0
  176. package/dist/phase-diagram/parse.js +10 -9
  177. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  178. package/dist/phase-diagram/svg-to-diagram.js +869 -0
  179. package/dist/phase-diagram/types.d.ts +10 -0
  180. package/dist/phase-diagram/utils.d.ts +8 -4
  181. package/dist/phase-diagram/utils.js +219 -74
  182. package/dist/plot/AxisLabel.svelte +51 -0
  183. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  184. package/dist/plot/BarPlot.svelte +1461 -768
  185. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  186. package/dist/plot/BarPlotControls.svelte +33 -6
  187. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  188. package/dist/plot/ColorBar.svelte +533 -383
  189. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  190. package/dist/plot/ColorScaleSelect.svelte +28 -7
  191. package/dist/plot/ElementScatter.svelte +38 -16
  192. package/dist/plot/FillArea.svelte +152 -92
  193. package/dist/plot/Histogram.svelte +1162 -709
  194. package/dist/plot/Histogram.svelte.d.ts +1 -1
  195. package/dist/plot/HistogramControls.svelte +81 -18
  196. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  197. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  198. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  199. package/dist/plot/Line.svelte +63 -28
  200. package/dist/plot/PlotControls.svelte +221 -96
  201. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  202. package/dist/plot/PlotLegend.svelte +174 -91
  203. package/dist/plot/PlotTooltip.svelte +45 -6
  204. package/dist/plot/PortalSelect.svelte +175 -146
  205. package/dist/plot/ReferenceLine.svelte +77 -22
  206. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  207. package/dist/plot/ReferenceLine3D.svelte +132 -107
  208. package/dist/plot/ReferencePlane.svelte +146 -123
  209. package/dist/plot/ScatterPlot.svelte +1880 -1156
  210. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  211. package/dist/plot/ScatterPlot3D.svelte +256 -131
  212. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  213. package/dist/plot/ScatterPlot3DControls.svelte +300 -297
  214. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  215. package/dist/plot/ScatterPlot3DScene.svelte +608 -406
  216. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  217. package/dist/plot/ScatterPlotControls.svelte +150 -70
  218. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  219. package/dist/plot/ScatterPoint.svelte +98 -26
  220. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  221. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  222. package/dist/plot/Surface3D.svelte +159 -108
  223. package/dist/plot/ZeroLines.svelte +96 -0
  224. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  225. package/dist/plot/ZoomRect.svelte +23 -0
  226. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  227. package/dist/plot/axis-utils.d.ts +1 -1
  228. package/dist/plot/axis-utils.js +1 -3
  229. package/dist/plot/data-cleaning.js +12 -28
  230. package/dist/plot/data-transform.js +2 -1
  231. package/dist/plot/fill-utils.js +2 -0
  232. package/dist/plot/index.d.ts +6 -2
  233. package/dist/plot/index.js +6 -2
  234. package/dist/plot/interactions.d.ts +8 -10
  235. package/dist/plot/interactions.js +2 -3
  236. package/dist/plot/layout.d.ts +11 -2
  237. package/dist/plot/layout.js +44 -17
  238. package/dist/plot/reference-line.d.ts +5 -22
  239. package/dist/plot/reference-line.js +12 -84
  240. package/dist/plot/scales.js +24 -36
  241. package/dist/plot/types.d.ts +53 -40
  242. package/dist/plot/types.js +12 -7
  243. package/dist/plot/utils/label-placement.d.ts +32 -15
  244. package/dist/plot/utils/label-placement.js +227 -63
  245. package/dist/plot/utils/series-visibility.js +2 -3
  246. package/dist/plot/utils.d.ts +1 -0
  247. package/dist/plot/utils.js +14 -0
  248. package/dist/rdf/RdfPlot.svelte +173 -132
  249. package/dist/rdf/calc-rdf.js +4 -5
  250. package/dist/sanitize.d.ts +4 -0
  251. package/dist/sanitize.js +107 -0
  252. package/dist/settings.d.ts +21 -6
  253. package/dist/settings.js +63 -19
  254. package/dist/spectral/Bands.svelte +963 -412
  255. package/dist/spectral/Bands.svelte.d.ts +22 -2
  256. package/dist/spectral/BandsAndDos.svelte +90 -49
  257. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  258. package/dist/spectral/Dos.svelte +389 -258
  259. package/dist/spectral/helpers.d.ts +23 -1
  260. package/dist/spectral/helpers.js +119 -51
  261. package/dist/spectral/types.d.ts +2 -0
  262. package/dist/state.svelte.d.ts +1 -1
  263. package/dist/state.svelte.js +3 -2
  264. package/dist/structure/Arrow.svelte +59 -20
  265. package/dist/structure/AtomLegend.svelte +231 -129
  266. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  267. package/dist/structure/Bond.svelte +73 -47
  268. package/dist/structure/CanvasTooltip.svelte +10 -2
  269. package/dist/structure/CellSelect.svelte +148 -51
  270. package/dist/structure/Cylinder.svelte +33 -17
  271. package/dist/structure/Lattice.svelte +88 -33
  272. package/dist/structure/Structure.svelte +1077 -821
  273. package/dist/structure/Structure.svelte.d.ts +1 -1
  274. package/dist/structure/StructureControls.svelte +373 -139
  275. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  276. package/dist/structure/StructureExportPane.svelte +124 -89
  277. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  278. package/dist/structure/StructureInfoPane.svelte +304 -231
  279. package/dist/structure/StructureScene.svelte +919 -445
  280. package/dist/structure/StructureScene.svelte.d.ts +16 -7
  281. package/dist/structure/atom-properties.d.ts +6 -2
  282. package/dist/structure/atom-properties.js +42 -29
  283. package/dist/structure/bonding.js +6 -7
  284. package/dist/structure/export.js +22 -34
  285. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  286. package/dist/structure/ferrox-wasm-types.js +0 -3
  287. package/dist/structure/ferrox-wasm.d.ts +3 -2
  288. package/dist/structure/ferrox-wasm.js +2 -3
  289. package/dist/structure/index.d.ts +16 -0
  290. package/dist/structure/index.js +88 -6
  291. package/dist/structure/measure.d.ts +2 -2
  292. package/dist/structure/measure.js +4 -44
  293. package/dist/structure/parse.js +130 -155
  294. package/dist/structure/partial-occupancy.d.ts +25 -0
  295. package/dist/structure/partial-occupancy.js +99 -0
  296. package/dist/structure/pbc.d.ts +1 -0
  297. package/dist/structure/pbc.js +16 -6
  298. package/dist/structure/supercell.d.ts +2 -2
  299. package/dist/structure/supercell.js +12 -22
  300. package/dist/structure/validation.js +5 -3
  301. package/dist/symmetry/SymmetryStats.svelte +94 -37
  302. package/dist/symmetry/WyckoffTable.svelte +42 -14
  303. package/dist/symmetry/cell-transform.js +5 -3
  304. package/dist/symmetry/index.d.ts +7 -4
  305. package/dist/symmetry/index.js +87 -21
  306. package/dist/symmetry/spacegroups.js +148 -148
  307. package/dist/table/HeatmapTable.svelte +1112 -516
  308. package/dist/table/HeatmapTable.svelte.d.ts +12 -1
  309. package/dist/table/ToggleMenu.svelte +125 -90
  310. package/dist/table/index.d.ts +2 -0
  311. package/dist/table/index.js +2 -4
  312. package/dist/theme/ThemeControl.svelte +21 -12
  313. package/dist/time.js +4 -1
  314. package/dist/tooltip/TooltipContent.svelte +33 -8
  315. package/dist/trajectory/Trajectory.svelte +889 -687
  316. package/dist/trajectory/TrajectoryError.svelte +14 -3
  317. package/dist/trajectory/TrajectoryExportPane.svelte +148 -90
  318. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  319. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  320. package/dist/trajectory/constants.d.ts +6 -0
  321. package/dist/trajectory/constants.js +7 -0
  322. package/dist/trajectory/extract.js +13 -31
  323. package/dist/trajectory/format-detect.d.ts +9 -0
  324. package/dist/trajectory/format-detect.js +76 -0
  325. package/dist/trajectory/frame-reader.d.ts +17 -0
  326. package/dist/trajectory/frame-reader.js +332 -0
  327. package/dist/trajectory/helpers.d.ts +14 -0
  328. package/dist/trajectory/helpers.js +172 -0
  329. package/dist/trajectory/index.d.ts +1 -0
  330. package/dist/trajectory/index.js +23 -14
  331. package/dist/trajectory/parse/ase.d.ts +2 -0
  332. package/dist/trajectory/parse/ase.js +77 -0
  333. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  334. package/dist/trajectory/parse/hdf5.js +129 -0
  335. package/dist/trajectory/parse/index.d.ts +12 -0
  336. package/dist/trajectory/parse/index.js +299 -0
  337. package/dist/trajectory/parse/lammps.d.ts +5 -0
  338. package/dist/trajectory/parse/lammps.js +179 -0
  339. package/dist/trajectory/parse/vasp.d.ts +2 -0
  340. package/dist/trajectory/parse/vasp.js +68 -0
  341. package/dist/trajectory/parse/xyz.d.ts +2 -0
  342. package/dist/trajectory/parse/xyz.js +110 -0
  343. package/dist/trajectory/plotting.js +13 -8
  344. package/dist/trajectory/types.d.ts +11 -0
  345. package/dist/trajectory/types.js +1 -0
  346. package/dist/utils.d.ts +3 -0
  347. package/dist/utils.js +17 -0
  348. package/dist/xrd/XrdPlot.svelte +337 -245
  349. package/dist/xrd/broadening.js +14 -9
  350. package/dist/xrd/calc-xrd.js +12 -19
  351. package/dist/xrd/parse.d.ts +1 -1
  352. package/dist/xrd/parse.js +17 -17
  353. package/package.json +103 -101
  354. package/readme.md +4 -4
  355. package/dist/trajectory/parse.d.ts +0 -42
  356. package/dist/trajectory/parse.js +0 -1267
  357. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
  358. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,35 +1,227 @@
1
1
  <script
2
2
  lang="ts"
3
3
  generics="Metadata extends Record<string, unknown> = Record<string, unknown>"
4
- >import { format_value } from '../labels';
5
- import { FullscreenToggle, set_fullscreen_bg } from '../layout';
6
- import { BarPlotControls, compute_element_placement, InteractiveAxisLabel, PlotLegend, ReferenceLine, ScatterPoint, } from './';
7
- import { AXIS_LABEL_CONTAINER, create_axis_change_handler, } from './axis-utils';
8
- import { process_prop } from './data-transform';
9
- import { create_dimension_tracker, create_hover_lock, } from './hover-lock.svelte';
10
- import { get_relative_coords, pan_range, PINCH_ZOOM_THRESHOLD, pixels_to_data_delta, } from './interactions';
11
- import { group_ref_lines_by_z, index_ref_lines } from './reference-line';
12
- import { create_color_scale, create_scale, create_size_scale, generate_ticks, get_nice_data_range, } from './scales';
13
- import { DEFAULT_GRID_STYLE, DEFAULT_MARKERS, get_scale_type_name, } from './types';
14
- import { DEFAULTS } from '../settings';
15
- import { extent } from 'd3-array';
16
- import { untrack } from 'svelte';
17
- import { Tween } from 'svelte/motion';
18
- import { SvelteMap } from 'svelte/reactivity';
19
- import { calc_auto_padding, constrain_tooltip_position, filter_padding, LABEL_GAP_DEFAULT, measure_text_width, } from './layout';
20
- import PlotTooltip from './PlotTooltip.svelte';
21
- import { bar_path } from './svg';
22
- let { series = $bindable([]), orientation = $bindable(`vertical`), mode = $bindable(`overlay`), x_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.bar.display), x_range = [null, null], y_range = [null, null], y2_range = [null, null], range_padding = 0.05, padding = { t: 20, b: 60, l: 60, r: 20 }, legend = {}, show_legend, bar = {}, line = {}, tooltip, user_content, hovered = $bindable(false), change = () => { }, on_bar_click, on_bar_hover,
23
- // Line marker props (matching ScatterPlot)
24
- color_scale = {
25
- type: `linear`,
26
- scheme: `interpolateViridis`,
27
- value_range: undefined,
28
- }, size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, point_tween, on_point_click, on_point_hover, ref_lines = $bindable([]), on_ref_line_click, on_ref_line_hover, show_controls = $bindable(true), controls_open = $bindable(false), controls_toggle_props, controls_pane_props, fullscreen = $bindable(false), fullscreen_toggle = true, children, header_controls, controls_extra, data_loader, on_axis_change, on_error, pan = {}, ...rest } = $props();
29
- // Initialize bar, line, y2_axis with defaults - using $derived for reactivity
30
- let bar_state = $derived({ ...DEFAULTS.bar.bar, ...bar });
31
- let line_state = $derived({ ...DEFAULTS.bar.line, ...line });
32
- y2_axis = {
4
+ >
5
+ import type { D3ColorSchemeName, D3InterpolateName } from '../colors'
6
+ import { format_value } from '../labels'
7
+ import { sanitize_html } from '../sanitize'
8
+ import { FullscreenToggle, set_fullscreen_bg } from '../layout'
9
+ import type {
10
+ AxisLoadError,
11
+ BarHandlerProps,
12
+ BarMode,
13
+ BarSeries,
14
+ BarStyle,
15
+ BasePlotProps,
16
+ DataLoaderFn,
17
+ InitialRanges,
18
+ InternalPoint,
19
+ LegendConfig,
20
+ LegendItem,
21
+ LineStyle,
22
+ Orientation,
23
+ PanConfig,
24
+ PlotConfig,
25
+ RefLine,
26
+ RefLineEvent,
27
+ ScaleType,
28
+ TweenedOptions,
29
+ UserContentProps,
30
+ XyObj,
31
+ } from './'
32
+ import {
33
+ AxisLabel,
34
+ BarPlotControls,
35
+ compute_element_placement,
36
+ PlotLegend,
37
+ ReferenceLine,
38
+ ScatterPoint,
39
+ } from './'
40
+ import type { AxisChangeState } from './axis-utils'
41
+ import { create_axis_change_handler } from './axis-utils'
42
+ import { process_prop } from './data-transform'
43
+ import {
44
+ create_dimension_tracker,
45
+ create_hover_lock,
46
+ } from './hover-lock.svelte'
47
+ import {
48
+ get_relative_coords,
49
+ pan_range,
50
+ PINCH_ZOOM_THRESHOLD,
51
+ pixels_to_data_delta,
52
+ } from './interactions'
53
+ import type { IndexedRefLine } from './reference-line'
54
+ import { group_ref_lines_by_z, index_ref_lines } from './reference-line'
55
+ import {
56
+ create_color_scale,
57
+ create_scale,
58
+ create_size_scale,
59
+ generate_ticks,
60
+ get_nice_data_range,
61
+ get_tick_label,
62
+ } from './scales'
63
+ import {
64
+ DEFAULT_GRID_STYLE,
65
+ DEFAULT_MARKERS,
66
+ get_scale_type_name,
67
+ } from './types'
68
+ import { DEFAULTS } from '../settings'
69
+ import { extent } from 'd3-array'
70
+ import type { Snippet } from 'svelte'
71
+ import { untrack } from 'svelte'
72
+ import type { HTMLAttributes } from 'svelte/elements'
73
+ import { Tween } from 'svelte/motion'
74
+ import { SvelteMap } from 'svelte/reactivity'
75
+ import type { Vec2 } from '../math'
76
+ import {
77
+ calc_auto_padding,
78
+ constrain_tooltip_position,
79
+ filter_padding,
80
+ LABEL_GAP_DEFAULT,
81
+ measure_max_tick_width,
82
+ } from './layout'
83
+ import PlotTooltip from './PlotTooltip.svelte'
84
+ import { bar_path } from './svg'
85
+ import ZeroLines from './ZeroLines.svelte'
86
+ import ZoomRect from './ZoomRect.svelte'
87
+
88
+ // Handler props for line marker events (extends BarHandlerProps with point-specific data)
89
+ interface LineMarkerHandlerProps extends BarHandlerProps<Metadata> {
90
+ point: InternalPoint<Metadata>
91
+ }
92
+
93
+ // Extended point type with computed screen coordinates (used internally for rendering)
94
+ type LineSeriesPoint = InternalPoint<Metadata> & {
95
+ x: number // Screen x coordinate
96
+ y: number // Screen y coordinate
97
+ data_x: number // Original data x value
98
+ data_y: number // Original data y value
99
+ idx: number // Index in series
100
+ }
101
+
102
+ let {
103
+ series = $bindable([]),
104
+ orientation = $bindable(`vertical`),
105
+ mode = $bindable(`overlay`),
106
+ x_axis = $bindable({}),
107
+ x2_axis = $bindable({}),
108
+ y_axis = $bindable({}),
109
+ y2_axis = $bindable({}),
110
+ display = $bindable(DEFAULTS.bar.display),
111
+ x_range = [null, null],
112
+ x2_range = [null, null],
113
+ y_range = [null, null],
114
+ y2_range = [null, null],
115
+ range_padding = 0.05,
116
+ padding = { t: 20, b: 60, l: 60, r: 20 },
117
+ legend = {},
118
+ show_legend,
119
+ bar = {},
120
+ line = {},
121
+ tooltip,
122
+ user_content,
123
+ hovered = $bindable(false),
124
+ change = () => {},
125
+ on_bar_click,
126
+ on_bar_hover,
127
+ // Line marker props (matching ScatterPlot)
128
+ color_scale = {
129
+ type: `linear`,
130
+ scheme: `interpolateViridis`,
131
+ value_range: undefined,
132
+ },
133
+ size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined },
134
+ point_tween,
135
+ on_point_click,
136
+ on_point_hover,
137
+ ref_lines = $bindable([]),
138
+ on_ref_line_click,
139
+ on_ref_line_hover,
140
+ show_controls = $bindable(true),
141
+ controls_open = $bindable(false),
142
+ controls_toggle_props,
143
+ controls_pane_props,
144
+ fullscreen = $bindable(false),
145
+ fullscreen_toggle = true,
146
+ children,
147
+ header_controls,
148
+ controls_extra,
149
+ data_loader,
150
+ on_axis_change,
151
+ on_error,
152
+ pan = {},
153
+ ...rest
154
+ }: HTMLAttributes<HTMLDivElement> & BasePlotProps & PlotConfig & {
155
+ series?: BarSeries<Metadata>[]
156
+ // Component-specific props
157
+ orientation?: Orientation
158
+ mode?: BarMode
159
+ legend?: LegendConfig | null
160
+ show_legend?: boolean
161
+ bar?: BarStyle
162
+ line?: LineStyle
163
+ tooltip?: Snippet<[BarHandlerProps<Metadata>]>
164
+ user_content?: Snippet<[UserContentProps]>
165
+ header_controls?: Snippet<
166
+ [{ height: number; width: number; fullscreen: boolean }]
167
+ >
168
+ controls_extra?: Snippet<
169
+ [{ orientation: Orientation; mode: BarMode } & Required<PlotConfig>]
170
+ >
171
+ change?: (data: BarHandlerProps<Metadata> | null) => void
172
+ on_bar_click?: (
173
+ data: BarHandlerProps<Metadata> & { event: MouseEvent | KeyboardEvent },
174
+ ) => void
175
+ on_bar_hover?: (
176
+ data:
177
+ | (BarHandlerProps<Metadata> & {
178
+ event: MouseEvent | FocusEvent | KeyboardEvent
179
+ })
180
+ | null,
181
+ ) => void
182
+ // Line marker props (matching ScatterPlot)
183
+ // Note: For line series with markers, BOTH on_bar_* AND on_point_* events fire.
184
+ // Use on_point_* for marker-specific data (includes `point` with InternalPoint details)
185
+ // or on_bar_* for backward compatibility with bar-style event handling.
186
+ color_scale?: {
187
+ type?: ScaleType
188
+ scheme?: D3ColorSchemeName | D3InterpolateName
189
+ value_range?: [number, number]
190
+ } | D3InterpolateName
191
+ size_scale?: {
192
+ type?: ScaleType
193
+ radius_range?: [number, number]
194
+ value_range?: [number, number]
195
+ }
196
+ point_tween?: TweenedOptions<XyObj>
197
+ on_point_click?: (
198
+ data: LineMarkerHandlerProps & { event: MouseEvent | KeyboardEvent },
199
+ ) => void
200
+ on_point_hover?: (
201
+ data:
202
+ | (LineMarkerHandlerProps & {
203
+ event: MouseEvent | FocusEvent | KeyboardEvent
204
+ })
205
+ | null,
206
+ ) => void
207
+ ref_lines?: RefLine[]
208
+ on_ref_line_click?: (event: RefLineEvent) => void
209
+ on_ref_line_hover?: (event: RefLineEvent | null) => void
210
+ // Interactive axis props
211
+ data_loader?: DataLoaderFn<Metadata, BarSeries<Metadata>>
212
+ on_axis_change?: (
213
+ axis: `x` | `x2` | `y` | `y2`,
214
+ key: string,
215
+ new_series: BarSeries<Metadata>[],
216
+ ) => void
217
+ on_error?: (error: AxisLoadError) => void
218
+ pan?: PanConfig
219
+ } = $props()
220
+
221
+ // Initialize bar, line, y2_axis with defaults - using $derived for reactivity
222
+ let bar_state = $derived({ ...DEFAULTS.bar.bar, ...bar })
223
+ let line_state = $derived({ ...DEFAULTS.bar.line, ...line })
224
+ y2_axis = {
33
225
  format: ``,
34
226
  scale_type: `linear`,
35
227
  ticks: 5,
@@ -37,636 +229,1082 @@ y2_axis = {
37
229
  tick: { label: { shift: { x: 0, y: 0 } } }, // base offset handled in rendering
38
230
  range: [null, null],
39
231
  ...y2_axis,
40
- };
41
- let [width, height] = $state([0, 0]);
42
- let wrapper = $state();
43
- let svg_element = $state(null);
44
- let clip_path_id = `chart-clip-${crypto?.randomUUID?.()}`;
45
- // Reference line hover state
46
- let hovered_ref_line_idx = $state(null);
47
- // Interactive axis loading state
48
- let axis_loading = $state(null);
49
- // Compute ref_lines with index and group by z-index (using shared utilities)
50
- let indexed_ref_lines = $derived(index_ref_lines(ref_lines));
51
- let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines));
52
- // Compute auto ranges from visible series
53
- let visible_series = $derived(series.filter((srs) => srs?.visible ?? true));
54
- // Separate series by y-axis
55
- let y1_series = $derived(visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`));
56
- let y2_series = $derived(visible_series.filter((srs) => srs.y_axis === `y2`));
57
- let auto_ranges = $derived.by(() => {
232
+ }
233
+ x2_axis = {
234
+ format: ``,
235
+ scale_type: `linear`,
236
+ ticks: 5,
237
+ label_shift: { x: 0, y: 40 },
238
+ tick: { label: { shift: { x: 0, y: 0 } } },
239
+ range: [null, null],
240
+ ...x2_axis,
241
+ }
242
+
243
+ let [width, height] = $state([0, 0])
244
+ let wrapper: HTMLDivElement | undefined = $state()
245
+ let svg_element: SVGElement | null = $state(null)
246
+ let clip_path_id = `chart-clip-${crypto?.randomUUID?.()}`
247
+
248
+ // Reference line hover state
249
+ let hovered_ref_line_idx = $state<number | null>(null)
250
+
251
+ // Interactive axis loading state
252
+ let axis_loading = $state<`x` | `x2` | `y` | `y2` | null>(null)
253
+
254
+ // Compute ref_lines with index and group by z-index (using shared utilities)
255
+ let indexed_ref_lines = $derived(index_ref_lines(ref_lines))
256
+ let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines))
257
+
258
+ // === Categorical Normalization ===
259
+ // Internal type with guaranteed numeric x (for downstream scale/rendering code)
260
+ type NumericBarSeries = Omit<BarSeries<Metadata>, `x`> & { x: readonly number[] }
261
+
262
+ let is_categorical = $derived(
263
+ series.some((srs) => srs.x.some((val) => typeof val === `string`)),
264
+ )
265
+
266
+ let category_list = $derived.by(() => {
267
+ if (!is_categorical) return [] as string[]
268
+ if (x_axis.categories?.length) return [...x_axis.categories]
269
+ return [...new Set(series.flatMap((srs) => srs.x.map(String)))]
270
+ })
271
+
272
+ let category_indices = $derived(
273
+ category_list.length ? category_list.map((_, idx) => idx) : null,
274
+ )
275
+
276
+ let internal_series = $derived.by<NumericBarSeries[]>(() => {
277
+ // safe: when !category_indices, all x values are numeric (is_categorical is false)
278
+ if (!category_indices) return series as unknown as NumericBarSeries[]
279
+ return series.map((srs) => {
280
+ const orig_map = new Map(srs.x.map((val, idx) => [String(val), idx]))
281
+ if (orig_map.size < srs.x.length) {
282
+ console.warn(
283
+ `BarPlot: series "${
284
+ srs.label ?? `?`
285
+ }" has duplicate x values — last occurrence wins`,
286
+ )
287
+ }
288
+ // Resolve original index for each category (undefined if series lacks it)
289
+ const orig_indices = category_list.map((cat) => orig_map.get(cat))
290
+ const remap = <T>(arr: readonly T[] | null | undefined, fallback: T): T[] =>
291
+ orig_indices.map((oi) => oi != null ? (arr?.[oi] ?? fallback) : fallback)
292
+ const bw_arr = Array.isArray(srs.bar_width) ? srs.bar_width : null
293
+ const meta_arr = Array.isArray(srs.metadata) ? srs.metadata : null
294
+ return {
295
+ ...srs,
296
+ x: category_indices,
297
+ y: remap(srs.y, srs.render_mode === `line` ? NaN : 0),
298
+ labels: remap(srs.labels, null),
299
+ metadata: orig_indices.map((oi) =>
300
+ oi != null ? (meta_arr ? meta_arr[oi] : srs.metadata) : undefined
301
+ ) as Metadata[],
302
+ ...(bw_arr ? { bar_width: remap(bw_arr, 0.5) } : {}),
303
+ ...(srs.color_values ? { color_values: remap(srs.color_values, null) } : {}),
304
+ ...(srs.size_values ? { size_values: remap(srs.size_values, null) } : {}),
305
+ } as NumericBarSeries
306
+ })
307
+ })
308
+
309
+ // Compute auto ranges from visible series
310
+ let visible_series = $derived(
311
+ internal_series.filter((srs) => srs?.visible ?? true),
312
+ )
313
+
314
+ // Separate series by y-axis
315
+ let y1_series = $derived(
316
+ visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`),
317
+ )
318
+ let y2_series = $derived(
319
+ visible_series.filter((srs) => srs.y_axis === `y2`),
320
+ )
321
+ let x2_series = $derived(
322
+ visible_series.filter((srs) => srs.x_axis === `x2`),
323
+ )
324
+
325
+ let auto_ranges = $derived.by(() => {
58
326
  // Calculate separate ranges for y1 and y2 axes
59
- const calc_y_range = (series_list, y_limit, scale_type) => {
60
- let points = series_list.flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] })));
61
- // In stacked mode, calculate stacked totals for accurate range (only for bars on the same axis)
62
- if (mode === `stacked`) {
63
- const stacked_totals = new SvelteMap();
64
- // Only include visible bar series (not lines) in stacking
65
- series_list
66
- .filter((srs) => srs.render_mode !== `line`)
67
- .forEach((srs) => srs.x.forEach((x_val, idx) => {
68
- const y_val = srs.y[idx] ?? 0;
69
- const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 };
70
- if (y_val >= 0)
71
- totals.pos += y_val;
72
- else
73
- totals.neg += y_val;
74
- stacked_totals.set(x_val, totals);
75
- }));
76
- // Replace points with stacked totals + line series (which don't stack)
77
- points = [
78
- ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
79
- ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
80
- ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
81
- ]),
82
- ...series_list
83
- .filter((srs) => srs.render_mode === `line`)
84
- .flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))),
85
- ];
86
- }
87
- if (!points.length)
88
- return [0, 1];
89
- let y_range = get_nice_data_range(points, (pt) => pt.y, y_limit, scale_type, range_padding, false);
90
- // For bar plots, ensure the value axis starts at 0 unless there are negative values
91
- // Only apply zero-clamping for linear and arcsinh scales (not log)
92
- const type_name = get_scale_type_name(scale_type);
93
- if (type_name === `linear` || type_name === `arcsinh`) {
94
- const has_negative = points.some((pt) => pt.y < 0);
95
- const has_positive = points.some((pt) => pt.y > 0);
96
- // Only adjust if no explicit y_range is set
97
- if (y_limit?.[0] == null && y_limit?.[1] == null) {
98
- if (has_positive && !has_negative)
99
- y_range = [0, y_range[1]];
100
- else if (has_negative && !has_positive)
101
- y_range = [y_range[0], 0];
102
- }
327
+ const calc_y_range = (
328
+ series_list: typeof visible_series,
329
+ y_limit: typeof y_range,
330
+ scale_type: ScaleType,
331
+ ) => {
332
+ let points = series_list.flatMap((srs) =>
333
+ srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))
334
+ )
335
+
336
+ // In stacked mode, calculate stacked totals for accurate range (only for bars on the same axis)
337
+ if (mode === `stacked`) {
338
+ const stacked_totals = new SvelteMap<number, { pos: number; neg: number }>()
339
+
340
+ // Only include visible bar series (not lines) in stacking
341
+ series_list
342
+ .filter((srs) => srs.render_mode !== `line`)
343
+ .forEach((srs) =>
344
+ srs.x.forEach((x_val, idx) => {
345
+ const y_val = srs.y[idx] ?? 0
346
+ const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 }
347
+ if (y_val >= 0) totals.pos += y_val
348
+ else totals.neg += y_val
349
+ stacked_totals.set(x_val, totals)
350
+ })
351
+ )
352
+
353
+ // Replace points with stacked totals + line series (which don't stack)
354
+ points = [
355
+ ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
356
+ ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
357
+ ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
358
+ ]),
359
+ ...series_list
360
+ .filter((srs) => srs.render_mode === `line`)
361
+ .flatMap((srs) =>
362
+ srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))
363
+ ),
364
+ ]
365
+ }
366
+
367
+ if (!points.length) return [0, 1]
368
+
369
+ let y_range = get_nice_data_range(
370
+ points,
371
+ (pt) => pt.y,
372
+ y_limit,
373
+ scale_type,
374
+ range_padding,
375
+ false,
376
+ )
377
+
378
+ // For bar plots, ensure the value axis starts at 0 unless there are negative values
379
+ // Only apply zero-clamping for linear and arcsinh scales (not log)
380
+ const type_name = get_scale_type_name(scale_type)
381
+ if (type_name === `linear` || type_name === `arcsinh`) {
382
+ const has_negative = points.some((pt) => pt.y < 0)
383
+ const has_positive = points.some((pt) => pt.y > 0)
384
+
385
+ // Only adjust if no explicit y_range is set
386
+ if (y_limit?.[0] == null && y_limit?.[1] == null) {
387
+ if (has_positive && !has_negative) y_range = [0, y_range[1]]
388
+ else if (has_negative && !has_positive) y_range = [y_range[0], 0]
103
389
  }
104
- return y_range;
105
- };
106
- // Get all x values for x_range calculation
107
- const all_x_points = visible_series.flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })));
108
- const x_scale_type = x_axis.scale_type ?? `linear`;
109
- const x_auto_range = all_x_points.length
110
- ? get_nice_data_range(all_x_points, (pt) => pt.x, x_range, x_scale_type, range_padding, x_axis.format?.startsWith(`%`) || false)
111
- : [0, 1];
112
- const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`);
113
- const y2_auto_range = calc_y_range(y2_series, y2_range, y2_axis.scale_type ?? `linear`);
390
+ }
391
+
392
+ return y_range
393
+ }
394
+
395
+ // Get x values split by axis for range calculation
396
+ // For categorical data, use fixed range centered on integer indices
397
+ let x_auto_range: number[]
398
+ if (category_list.length) {
399
+ x_auto_range = [-0.5, category_list.length - 0.5]
400
+ } else {
401
+ const x1_x_points = visible_series
402
+ .filter((srs) => (srs.x_axis ?? `x1`) === `x1`)
403
+ .flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })))
404
+ x_auto_range = x1_x_points.length
405
+ ? get_nice_data_range(
406
+ x1_x_points,
407
+ (pt) => pt.x,
408
+ x_range,
409
+ x_axis.scale_type ?? `linear`,
410
+ range_padding,
411
+ x_axis.format?.startsWith(`%`) || false,
412
+ )
413
+ : [0, 1]
414
+ }
415
+
416
+ const x2_x_points = x2_series.flatMap((srs) =>
417
+ srs.x.map((x_val) => ({ x: x_val, y: 0 }))
418
+ )
419
+ const x2_scale_type = x2_axis.scale_type ?? `linear`
420
+ const x2_auto_range = x2_x_points.length
421
+ ? get_nice_data_range(
422
+ x2_x_points,
423
+ (pt) => pt.x,
424
+ x2_range,
425
+ x2_scale_type,
426
+ range_padding,
427
+ x2_axis.format?.startsWith(`%`) || false,
428
+ )
429
+ : [0, 1]
430
+
431
+ const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`)
432
+ const y2_auto_range = calc_y_range(
433
+ y2_series,
434
+ y2_range,
435
+ y2_axis.scale_type ?? `linear`,
436
+ )
437
+
114
438
  // Map data ranges to axis ranges depending on orientation
115
439
  return orientation === `horizontal`
116
- ? ({ x: y1_range, y: x_auto_range, y2: y2_auto_range })
117
- : ({ x: x_auto_range, y: y1_range, y2: y2_auto_range });
118
- });
119
- // Initialize and current ranges
120
- let ranges = $state({
121
- initial: { x: [0, 1], y: [0, 1], y2: [0, 1] },
122
- current: { x: [0, 1], y: [0, 1], y2: [0, 1] },
123
- });
124
- $effect(() => {
440
+ ? ({ x: y1_range, x2: x2_auto_range, y: x_auto_range, y2: y2_auto_range })
441
+ : ({ x: x_auto_range, x2: x2_auto_range, y: y1_range, y2: y2_auto_range })
442
+ })
443
+
444
+ // Initialize and current ranges
445
+ let ranges = $state<{
446
+ initial: { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 }
447
+ current: { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 }
448
+ }>({
449
+ initial: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
450
+ current: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
451
+ })
452
+
453
+ $effect(() => { // handle x_axis.range / x2_axis.range / y_axis.range / y2_axis.range changes
125
454
  const new_x = [
126
- x_axis.range?.[0] ?? auto_ranges.x[0],
127
- x_axis.range?.[1] ?? auto_ranges.x[1],
128
- ];
455
+ x_axis.range?.[0] ?? auto_ranges.x[0],
456
+ x_axis.range?.[1] ?? auto_ranges.x[1],
457
+ ] as Vec2
458
+ const new_x2 = [
459
+ x2_axis.range?.[0] ?? auto_ranges.x2[0],
460
+ x2_axis.range?.[1] ?? auto_ranges.x2[1],
461
+ ] as Vec2
129
462
  const new_y = [
130
- y_axis.range?.[0] ?? auto_ranges.y[0],
131
- y_axis.range?.[1] ?? auto_ranges.y[1],
132
- ];
463
+ y_axis.range?.[0] ?? auto_ranges.y[0],
464
+ y_axis.range?.[1] ?? auto_ranges.y[1],
465
+ ] as Vec2
133
466
  const new_y2 = [
134
- y2_axis.range?.[0] ?? auto_ranges.y2[0],
135
- y2_axis.range?.[1] ?? auto_ranges.y2[1],
136
- ];
467
+ y2_axis.range?.[0] ?? auto_ranges.y2[0],
468
+ y2_axis.range?.[1] ?? auto_ranges.y2[1],
469
+ ] as Vec2
137
470
  // Only update if the initial (data-driven) ranges changed, not when user pans
138
471
  // Comparing against initial preserves user's pan/zoom state
139
- if (ranges.initial.x[0] !== new_x[0] ||
140
- ranges.initial.x[1] !== new_x[1] ||
141
- ranges.initial.y[0] !== new_y[0] ||
142
- ranges.initial.y[1] !== new_y[1] ||
143
- ranges.initial.y2[0] !== new_y2[0] ||
144
- ranges.initial.y2[1] !== new_y2[1]) {
145
- ranges = {
146
- initial: { x: new_x, y: new_y, y2: new_y2 },
147
- current: { x: new_x, y: new_y, y2: new_y2 },
148
- };
472
+ if (
473
+ ranges.initial.x[0] !== new_x[0] ||
474
+ ranges.initial.x[1] !== new_x[1] ||
475
+ ranges.initial.x2[0] !== new_x2[0] ||
476
+ ranges.initial.x2[1] !== new_x2[1] ||
477
+ ranges.initial.y[0] !== new_y[0] ||
478
+ ranges.initial.y[1] !== new_y[1] ||
479
+ ranges.initial.y2[0] !== new_y2[0] ||
480
+ ranges.initial.y2[1] !== new_y2[1]
481
+ ) {
482
+ ranges = {
483
+ initial: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
484
+ current: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
485
+ }
149
486
  }
150
- });
151
- // Layout: dynamic padding based on tick label widths
152
- const default_padding = { t: 20, b: 60, l: 60, r: 20 };
153
- let pad = $derived(filter_padding(padding, default_padding));
154
- // Update padding when format or ticks change
155
- $effect(() => {
487
+ })
488
+
489
+ // Layout: dynamic padding based on tick label widths
490
+ const default_padding = { t: 20, b: 60, l: 60, r: 20 }
491
+ let pad = $derived(filter_padding(padding, default_padding))
492
+
493
+ // Update padding when format or ticks change
494
+ $effect(() => {
156
495
  const new_pad = width && height && ticks.y.length
157
- ? calc_auto_padding({
158
- padding,
159
- default_padding,
160
- y_axis: { ...y_axis, tick_values: ticks.y },
161
- y2_axis: { ...y2_axis, tick_values: ticks.y2 },
162
- })
163
- : filter_padding(padding, default_padding);
496
+ ? calc_auto_padding({
497
+ padding,
498
+ default_padding,
499
+ x2_axis: { ...x2_axis, tick_values: ticks.x2 },
500
+ y_axis: { ...y_axis, tick_values: ticks.y },
501
+ y2_axis: { ...y2_axis, tick_values: ticks.y2 },
502
+ })
503
+ : filter_padding(padding, default_padding)
164
504
  // Expand right padding if y2 ticks are shown (only for vertical orientation)
165
- if (width && height && y2_series.length && ticks.y2.length &&
166
- orientation === `vertical`) {
167
- const y2_tick_width = Math.max(0, ...ticks.y2.map((tick) => measure_text_width(format_value(tick, y2_axis.format), `12px sans-serif`)));
168
- // Need space for: tick shift + tick width + gap (30px) + label space (20px if present)
169
- // When ticks are inside, they don't contribute to padding
170
- const inside = y2_axis.tick?.label?.inside ?? false;
171
- const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8;
172
- const tick_width_contribution = inside ? 0 : y2_tick_width;
173
- const label_space = y2_axis.label ? 20 : 0;
174
- new_pad.r = Math.max(new_pad.r, tick_shift + tick_width_contribution + 30 + label_space);
505
+ if (
506
+ width && height && y2_series.length && ticks.y2.length &&
507
+ orientation === `vertical`
508
+ ) {
509
+ // Need space for: tick shift + tick width + gap (30px) + label space (20px if present)
510
+ // When ticks are inside, they don't contribute to padding
511
+ const inside = y2_axis.tick?.label?.inside ?? false
512
+ const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8
513
+ const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max
514
+ const label_space = y2_axis.label ? 20 : 0
515
+ new_pad.r = Math.max(
516
+ new_pad.r,
517
+ tick_shift + tick_width_contribution + 30 + label_space,
518
+ )
519
+ }
520
+ // Expand top padding if x2 ticks are shown (only for vertical orientation)
521
+ if (
522
+ width && height && x2_series.length && ticks.x2.length &&
523
+ orientation === `vertical`
524
+ ) {
525
+ const inside = x2_axis.tick?.label?.inside ?? false
526
+ const tick_shift = inside ? 0 : Math.abs(x2_axis.tick?.label?.shift?.y ?? 0) + 5
527
+ const tick_height = inside ? 0 : 16
528
+ const label_space = x2_axis.label ? 20 : 0
529
+ new_pad.t = Math.max(new_pad.t, tick_shift + tick_height + 30 + label_space)
175
530
  }
531
+
176
532
  // Only update if padding actually changed (prevents infinite loop)
177
- if (pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
178
- pad.r !== new_pad.r)
179
- pad = new_pad;
180
- });
181
- const chart_width = $derived(Math.max(1, width - pad.l - pad.r));
182
- const chart_height = $derived(Math.max(1, height - pad.t - pad.b));
183
- // Scales
184
- let scales = $derived({
533
+ if (
534
+ pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
535
+ pad.r !== new_pad.r
536
+ ) pad = new_pad
537
+ })
538
+ const chart_width = $derived(Math.max(1, width - pad.l - pad.r))
539
+ const chart_height = $derived(Math.max(1, height - pad.t - pad.b))
540
+
541
+ // Scales
542
+ let scales = $derived({
185
543
  x: create_scale(x_axis.scale_type ?? `linear`, ranges.current.x, [
186
- pad.l,
187
- width - pad.r,
544
+ pad.l,
545
+ width - pad.r,
546
+ ]),
547
+ x2: create_scale(x2_axis.scale_type ?? `linear`, ranges.current.x2, [
548
+ pad.l,
549
+ width - pad.r,
188
550
  ]),
189
551
  y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [
190
- height - pad.b,
191
- pad.t,
552
+ height - pad.b,
553
+ pad.t,
192
554
  ]),
193
555
  y2: create_scale(y2_axis.scale_type ?? `linear`, ranges.current.y2, [
194
- height - pad.b,
195
- pad.t,
556
+ height - pad.b,
557
+ pad.t,
196
558
  ]),
197
- });
198
- // Compute plot center for point tweening origin
199
- let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2);
200
- let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2);
201
- // Compute color values from line series for color scaling (filter to numbers only)
202
- let all_color_values = $derived(visible_series
203
- .filter((srs) => srs.render_mode === `line`)
204
- .flatMap((srs) => (srs.color_values ?? []).filter((val) => typeof val === `number`)));
205
- // Create auto color range (safely handle empty arrays or undefined extent results)
206
- let auto_color_range = $derived.by(() => {
207
- if (all_color_values.length === 0)
208
- return [0, 1];
209
- const [min_val, max_val] = extent(all_color_values);
210
- return [min_val ?? 0, max_val ?? 1];
211
- });
212
- // All size values from line series (for size scale, filter to numbers only)
213
- let all_size_values = $derived(visible_series
214
- .filter((srs) => srs.render_mode === `line`)
215
- .flatMap((srs) => [...(srs.size_values ?? [])].filter((val) => typeof val === `number`)));
216
- // Color scale function (using shared utility)
217
- let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range));
218
- // Size scale function (using shared utility)
219
- let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values));
220
- // Ticks
221
- let ticks = $derived({
559
+ })
560
+
561
+ // Compute plot center for point tweening origin
562
+ let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2)
563
+ let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2)
564
+
565
+ // Compute color values from line series for color scaling (filter to numbers only)
566
+ let all_color_values = $derived(
567
+ visible_series
568
+ .filter((srs: BarSeries<Metadata>) => srs.render_mode === `line`)
569
+ .flatMap((srs: BarSeries<Metadata>) =>
570
+ (srs.color_values ?? []).filter(
571
+ (val): val is number => typeof val === `number`,
572
+ )
573
+ ),
574
+ )
575
+
576
+ // Create auto color range (safely handle empty arrays or undefined extent results)
577
+ let auto_color_range: [number, number] = $derived.by(() => {
578
+ if (all_color_values.length === 0) return [0, 1]
579
+ const [min_val, max_val] = extent(all_color_values)
580
+ return [min_val ?? 0, max_val ?? 1]
581
+ })
582
+
583
+ // All size values from line series (for size scale, filter to numbers only)
584
+ let all_size_values = $derived(
585
+ visible_series
586
+ .filter((srs: BarSeries<Metadata>) => srs.render_mode === `line`)
587
+ .flatMap((srs: BarSeries<Metadata>) =>
588
+ [...(srs.size_values ?? [])].filter(
589
+ (val): val is number => typeof val === `number`,
590
+ )
591
+ ),
592
+ )
593
+
594
+ // Color scale function (using shared utility)
595
+ let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range))
596
+
597
+ // Size scale function (using shared utility)
598
+ let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values))
599
+
600
+ // Auto-generate tick labels for categorical data (unless user provides explicit ticks)
601
+ // In vertical mode categories are on x-axis; in horizontal mode on y-axis
602
+ let cat_axis = $derived(orientation === `horizontal` ? `y` : `x`)
603
+ let effective_cat_ticks = $derived.by(() => {
604
+ if (!category_list.length) return undefined
605
+ // Only respect user ticks when they're a Record (custom label mapping),
606
+ // not a number (tick count) or array (tick positions)
607
+ const user_ticks = cat_axis === `x` ? x_axis.ticks : y_axis.ticks
608
+ if (
609
+ user_ticks != null && typeof user_ticks === `object` &&
610
+ !Array.isArray(user_ticks)
611
+ ) return user_ticks
612
+ return Object.fromEntries(
613
+ category_list.map((cat, idx) => [idx, cat]),
614
+ ) as Record<number, string>
615
+ })
616
+
617
+ // Ticks
618
+ let ticks = $derived({
222
619
  x: width && height
223
- ? generate_ticks(ranges.current.x, x_axis.scale_type ?? `linear`, x_axis.ticks, scales.x, {
224
- default_count: 8,
225
- })
226
- : [],
620
+ ? (category_indices && cat_axis === `x` ? category_indices : generate_ticks(
621
+ ranges.current.x,
622
+ x_axis.scale_type ?? `linear`,
623
+ x_axis.ticks,
624
+ scales.x,
625
+ { default_count: 8 },
626
+ ))
627
+ : [],
227
628
  y: width && height
228
- ? generate_ticks(ranges.current.y, y_axis.scale_type ?? `linear`, y_axis.ticks, scales.y, {
229
- default_count: 6,
230
- })
231
- : [],
629
+ ? (category_indices && cat_axis === `y` ? category_indices : generate_ticks(
630
+ ranges.current.y,
631
+ y_axis.scale_type ?? `linear`,
632
+ y_axis.ticks,
633
+ scales.y,
634
+ { default_count: 6 },
635
+ ))
636
+ : [],
232
637
  y2: width && height && y2_series.length > 0 && orientation === `vertical`
233
- ? generate_ticks(ranges.current.y2, y2_axis.scale_type ?? `linear`, y2_axis.ticks, scales.y2, {
234
- default_count: 6,
235
- })
236
- : [],
237
- });
238
- // Zoom drag state
239
- let drag_state = $state({ start: null, current: null, bounds: null });
240
- // Pan state
241
- let is_focused = $state(false);
242
- let shift_held = $state(false);
243
- let pan_drag_state = $state(null);
244
- let touch_state = $state(null);
245
- const on_window_mouse_move = (evt) => {
246
- if (!drag_state.start || !drag_state.bounds)
247
- return;
638
+ ? generate_ticks(
639
+ ranges.current.y2,
640
+ y2_axis.scale_type ?? `linear`,
641
+ y2_axis.ticks,
642
+ scales.y2,
643
+ {
644
+ default_count: 6,
645
+ },
646
+ )
647
+ : [],
648
+ x2: width && height && x2_series.length > 0 && orientation === `vertical`
649
+ ? generate_ticks(
650
+ ranges.current.x2,
651
+ x2_axis.scale_type ?? `linear`,
652
+ x2_axis.ticks,
653
+ scales.x2,
654
+ {
655
+ default_count: 8,
656
+ },
657
+ )
658
+ : [],
659
+ })
660
+
661
+ // Cache measured tick-label widths so expensive canvas text measurement
662
+ // only runs when ticks/format change, not on every template rerender.
663
+ let tick_label_widths = $derived({
664
+ y_max: measure_max_tick_width(ticks.y, y_axis.format ?? ``),
665
+ y2_max: measure_max_tick_width(ticks.y2, y2_axis.format ?? ``),
666
+ x2_max: measure_max_tick_width(ticks.x2, x2_axis.format ?? ``),
667
+ })
668
+
669
+ // Zoom drag state
670
+ let drag_state = $state<{
671
+ start: { x: number; y: number } | null
672
+ current: { x: number; y: number } | null
673
+ bounds: DOMRect | null
674
+ }>({ start: null, current: null, bounds: null })
675
+
676
+ // Pan state
677
+ let is_focused = $state(false)
678
+ let shift_held = $state(false)
679
+ let pan_drag_state = $state<
680
+ InitialRanges & { start: { x: number; y: number } } | null
681
+ >(null)
682
+ let touch_state = $state<
683
+ InitialRanges & { start_touches: { x: number; y: number }[] } | null
684
+ >(null)
685
+ const on_window_mouse_move = (evt: MouseEvent) => {
686
+ if (!drag_state.start || !drag_state.bounds) return
248
687
  drag_state.current = {
249
- x: evt.clientX - drag_state.bounds.left,
250
- y: evt.clientY - drag_state.bounds.top,
251
- };
252
- };
253
- const on_window_mouse_up = () => {
688
+ x: evt.clientX - drag_state.bounds.left,
689
+ y: evt.clientY - drag_state.bounds.top,
690
+ }
691
+ }
692
+ const on_window_mouse_up = () => {
254
693
  if (drag_state.start && drag_state.current) {
255
- const x1_raw = scales.x.invert(drag_state.start.x);
256
- const x2_raw = scales.x.invert(drag_state.current.x);
257
- const y1 = scales.y.invert(drag_state.start.y);
258
- const y2 = scales.y.invert(drag_state.current.y);
259
- const y2_1 = scales.y2.invert(drag_state.start.y);
260
- const y2_2 = scales.y2.invert(drag_state.current.y);
261
- const dx = Math.abs(drag_state.start.x - drag_state.current.x);
262
- const dy = Math.abs(drag_state.start.y - drag_state.current.y);
263
- let xr1, xr2;
264
- if (x1_raw instanceof Date && x2_raw instanceof Date) {
265
- ;
266
- [xr1, xr2] = [x1_raw.getTime(), x2_raw.getTime()];
267
- }
268
- else if (typeof x1_raw === `number` && typeof x2_raw === `number`) {
269
- ;
270
- [xr1, xr2] = [x1_raw, x2_raw];
271
- }
272
- else
273
- [xr1, xr2] = [NaN, NaN]; // bail: mixed types
274
- if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
275
- // Update axis ranges to trigger reactivity and prevent effect from overriding
276
- x_axis = { ...x_axis, range: [Math.min(xr1, xr2), Math.max(xr1, xr2)] };
277
- y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] };
278
- y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] };
694
+ const x1_raw = scales.x.invert(drag_state.start.x) as number | Date
695
+ const x2_raw = scales.x.invert(drag_state.current.x) as number | Date
696
+ const y1 = scales.y.invert(drag_state.start.y)
697
+ const y2 = scales.y.invert(drag_state.current.y)
698
+ const y2_1 = scales.y2.invert(drag_state.start.y)
699
+ const y2_2 = scales.y2.invert(drag_state.current.y)
700
+ const x2a_1_raw = scales.x2.invert(drag_state.start.x) as number | Date
701
+ const x2a_2_raw = scales.x2.invert(drag_state.current.x) as number | Date
702
+ const dx = Math.abs(drag_state.start.x - drag_state.current.x)
703
+ const dy = Math.abs(drag_state.start.y - drag_state.current.y)
704
+
705
+ let xr1: number, xr2: number
706
+ if (x1_raw instanceof Date && x2_raw instanceof Date) {
707
+ ;[xr1, xr2] = [x1_raw.getTime(), x2_raw.getTime()]
708
+ } else if (typeof x1_raw === `number` && typeof x2_raw === `number`) {
709
+ ;[xr1, xr2] = [x1_raw, x2_raw]
710
+ } else [xr1, xr2] = [NaN, NaN] // bail: mixed types
711
+
712
+ let x2r1: number, x2r2: number
713
+ if (x2a_1_raw instanceof Date && x2a_2_raw instanceof Date) {
714
+ ;[x2r1, x2r2] = [x2a_1_raw.getTime(), x2a_2_raw.getTime()]
715
+ } else if (typeof x2a_1_raw === `number` && typeof x2a_2_raw === `number`) {
716
+ ;[x2r1, x2r2] = [x2a_1_raw, x2a_2_raw]
717
+ } else [x2r1, x2r2] = [NaN, NaN]
718
+
719
+ if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
720
+ // Update axis ranges to trigger reactivity and prevent effect from overriding
721
+ x_axis = { ...x_axis, range: [Math.min(xr1, xr2), Math.max(xr1, xr2)] }
722
+ if (x2_series.length > 0 && Number.isFinite(x2r1) && Number.isFinite(x2r2)) {
723
+ x2_axis = {
724
+ ...x2_axis,
725
+ range: [Math.min(x2r1, x2r2), Math.max(x2r1, x2r2)],
726
+ }
279
727
  }
728
+ y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] }
729
+ y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] }
730
+ }
280
731
  }
281
- drag_state = { start: null, current: null, bounds: null };
282
- window.removeEventListener(`mousemove`, on_window_mouse_move);
283
- window.removeEventListener(`mouseup`, on_window_mouse_up);
284
- document.body.style.cursor = `default`;
285
- };
286
- // Pan drag handlers
287
- const on_pan_move = (evt) => {
288
- if (!pan_drag_state)
289
- return;
290
- const dx = evt.clientX - pan_drag_state.start.x;
291
- const dy = evt.clientY - pan_drag_state.start.y;
732
+ drag_state = { start: null, current: null, bounds: null }
733
+ window.removeEventListener(`mousemove`, on_window_mouse_move)
734
+ window.removeEventListener(`mouseup`, on_window_mouse_up)
735
+ document.body.style.cursor = `default`
736
+ }
737
+
738
+ // Pan drag handlers
739
+ const on_pan_move = (evt: MouseEvent) => {
740
+ if (!pan_drag_state) return
741
+ const dx = evt.clientX - pan_drag_state.start.x
742
+ const dy = evt.clientY - pan_drag_state.start.y
743
+
292
744
  // Convert pixel delta to data delta (note: drag direction is inverted for natural pan feel)
293
- const sensitivity = pan?.drag_sensitivity ?? 1;
294
- const x_delta = pixels_to_data_delta(-dx * sensitivity, pan_drag_state.initial_x_range, chart_width);
295
- const y_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y_range, chart_height);
296
- const y2_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y2_range, chart_height);
297
- ranges.current.x = pan_range(pan_drag_state.initial_x_range, x_delta);
298
- ranges.current.y = pan_range(pan_drag_state.initial_y_range, y_delta);
299
- ranges.current.y2 = pan_range(pan_drag_state.initial_y2_range, y2_delta);
300
- };
301
- const on_pan_end = () => {
302
- pan_drag_state = null;
303
- document.body.style.cursor = ``;
304
- window.removeEventListener(`mousemove`, on_pan_move);
305
- window.removeEventListener(`mouseup`, on_pan_end);
306
- };
307
- function handle_mouse_down(evt) {
308
- const coords = get_relative_coords(evt);
309
- if (!coords || !svg_element)
310
- return;
745
+ const sensitivity = pan?.drag_sensitivity ?? 1
746
+
747
+ const x_delta = pixels_to_data_delta(
748
+ -dx * sensitivity,
749
+ pan_drag_state.initial_x_range,
750
+ chart_width,
751
+ )
752
+ const x2_delta = pixels_to_data_delta(
753
+ -dx * sensitivity,
754
+ pan_drag_state.initial_x2_range,
755
+ chart_width,
756
+ )
757
+ const y_delta = pixels_to_data_delta(
758
+ dy * sensitivity,
759
+ pan_drag_state.initial_y_range,
760
+ chart_height,
761
+ )
762
+ const y2_delta = pixels_to_data_delta(
763
+ dy * sensitivity,
764
+ pan_drag_state.initial_y2_range,
765
+ chart_height,
766
+ )
767
+
768
+ ranges.current.x = pan_range(pan_drag_state.initial_x_range, x_delta)
769
+ ranges.current.x2 = pan_range(pan_drag_state.initial_x2_range, x2_delta)
770
+ ranges.current.y = pan_range(pan_drag_state.initial_y_range, y_delta)
771
+ ranges.current.y2 = pan_range(pan_drag_state.initial_y2_range, y2_delta)
772
+ }
773
+
774
+ const on_pan_end = () => {
775
+ pan_drag_state = null
776
+ document.body.style.cursor = ``
777
+ window.removeEventListener(`mousemove`, on_pan_move)
778
+ window.removeEventListener(`mouseup`, on_pan_end)
779
+ }
780
+
781
+ function handle_mouse_down(evt: MouseEvent) {
782
+ const coords = get_relative_coords(evt)
783
+ if (!coords || !svg_element) return
784
+
311
785
  // Check if pan is enabled and shift is held for pan mode
312
- const pan_enabled = pan?.enabled !== false;
786
+ const pan_enabled = pan?.enabled !== false
313
787
  if (pan_enabled && evt.shiftKey) {
314
- evt.preventDefault();
315
- pan_drag_state = {
316
- start: { x: evt.clientX, y: evt.clientY },
317
- initial_x_range: [...ranges.current.x],
318
- initial_y_range: [...ranges.current.y],
319
- initial_y2_range: [...ranges.current.y2],
320
- };
321
- document.body.style.cursor = `grabbing`;
322
- window.addEventListener(`mousemove`, on_pan_move);
323
- window.addEventListener(`mouseup`, on_pan_end);
324
- return;
788
+ evt.preventDefault()
789
+ pan_drag_state = {
790
+ start: { x: evt.clientX, y: evt.clientY },
791
+ initial_x_range: [...ranges.current.x] as [number, number],
792
+ initial_x2_range: [...ranges.current.x2] as [number, number],
793
+ initial_y_range: [...ranges.current.y] as [number, number],
794
+ initial_y2_range: [...ranges.current.y2] as [number, number],
795
+ }
796
+ document.body.style.cursor = `grabbing`
797
+ window.addEventListener(`mousemove`, on_pan_move)
798
+ window.addEventListener(`mouseup`, on_pan_end)
799
+ return
325
800
  }
801
+
326
802
  drag_state = {
327
- start: coords,
328
- current: coords,
329
- bounds: svg_element.getBoundingClientRect(),
330
- };
331
- window.addEventListener(`mousemove`, on_window_mouse_move);
332
- window.addEventListener(`mouseup`, on_window_mouse_up);
333
- evt.preventDefault();
334
- }
335
- // Wheel handler for pan (requires focus and shift)
336
- function handle_wheel(evt) {
337
- const pan_enabled = pan?.enabled !== false;
803
+ start: coords,
804
+ current: coords,
805
+ bounds: svg_element.getBoundingClientRect(),
806
+ }
807
+ window.addEventListener(`mousemove`, on_window_mouse_move)
808
+ window.addEventListener(`mouseup`, on_window_mouse_up)
809
+ evt.preventDefault()
810
+ }
811
+
812
+ // Wheel handler for pan (requires focus and shift)
813
+ function handle_wheel(evt: WheelEvent) {
814
+ const pan_enabled = pan?.enabled !== false
338
815
  // Only capture wheel when focused AND Shift is held
339
816
  // Use shift_held state (tracked via keydown/keyup) for compatibility with synthetic events
340
- if (!pan_enabled || !is_focused || !shift_held)
341
- return;
342
- evt.preventDefault();
343
- const sensitivity = pan?.wheel_sensitivity ?? 1;
817
+ if (!pan_enabled || !is_focused || !shift_held) return
818
+
819
+ evt.preventDefault()
820
+
821
+ const sensitivity = pan?.wheel_sensitivity ?? 1
822
+
344
823
  // Determine pan direction based on wheel delta
345
- const x_delta = pixels_to_data_delta(evt.deltaX * sensitivity, ranges.current.x, chart_width);
346
- const y_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y, chart_height);
347
- const y2_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y2, chart_height);
824
+ const x_delta = pixels_to_data_delta(
825
+ evt.deltaX * sensitivity,
826
+ ranges.current.x,
827
+ chart_width,
828
+ )
829
+ const x2_delta = pixels_to_data_delta(
830
+ evt.deltaX * sensitivity,
831
+ ranges.current.x2,
832
+ chart_width,
833
+ )
834
+ const y_delta = pixels_to_data_delta(
835
+ evt.deltaY * sensitivity,
836
+ ranges.current.y,
837
+ chart_height,
838
+ )
839
+ const y2_delta = pixels_to_data_delta(
840
+ evt.deltaY * sensitivity,
841
+ ranges.current.y2,
842
+ chart_height,
843
+ )
844
+
348
845
  if (Math.abs(evt.deltaX) > Math.abs(evt.deltaY)) {
349
- ranges.current.x = pan_range(ranges.current.x, x_delta);
846
+ ranges.current.x = pan_range(ranges.current.x, x_delta)
847
+ ranges.current.x2 = pan_range(ranges.current.x2, x2_delta)
848
+ } else {
849
+ ranges.current.y = pan_range(ranges.current.y, y_delta)
850
+ ranges.current.y2 = pan_range(ranges.current.y2, y2_delta)
350
851
  }
351
- else {
352
- ranges.current.y = pan_range(ranges.current.y, y_delta);
353
- ranges.current.y2 = pan_range(ranges.current.y2, y2_delta);
354
- }
355
- }
356
- // Touch handlers for pinch-zoom and two-finger pan
357
- function handle_touch_start(evt) {
358
- const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false;
359
- if (!touch_enabled || evt.touches.length !== 2)
360
- return;
361
- evt.preventDefault();
362
- const touches = Array.from(evt.touches);
852
+ }
853
+
854
+ // Touch handlers for pinch-zoom and two-finger pan
855
+ function handle_touch_start(evt: TouchEvent) {
856
+ const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false
857
+ if (!touch_enabled || evt.touches.length !== 2) return
858
+
859
+ evt.preventDefault()
860
+ const touches = Array.from(evt.touches)
363
861
  touch_state = {
364
- start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
365
- initial_x_range: [...ranges.current.x],
366
- initial_y_range: [...ranges.current.y],
367
- initial_y2_range: [...ranges.current.y2],
368
- };
369
- }
370
- function handle_touch_move(evt) {
371
- if (!touch_state || evt.touches.length !== 2)
372
- return;
373
- evt.preventDefault();
374
- const [t1, t2] = Array.from(evt.touches);
375
- const [s1, s2] = touch_state.start_touches;
862
+ start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
863
+ initial_x_range: [...ranges.current.x] as [number, number],
864
+ initial_x2_range: [...ranges.current.x2] as [number, number],
865
+ initial_y_range: [...ranges.current.y] as [number, number],
866
+ initial_y2_range: [...ranges.current.y2] as [number, number],
867
+ }
868
+ }
869
+
870
+ function handle_touch_move(evt: TouchEvent) {
871
+ if (!touch_state || evt.touches.length !== 2) return
872
+ evt.preventDefault()
873
+
874
+ const [t1, t2] = Array.from(evt.touches)
875
+ const [s1, s2] = touch_state.start_touches
876
+
376
877
  // Calculate center movement for pan
377
- const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 };
878
+ const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 }
378
879
  const curr_center = {
379
- x: (t1.clientX + t2.clientX) / 2,
380
- y: (t1.clientY + t2.clientY) / 2,
381
- };
382
- const dx = curr_center.x - start_center.x;
383
- const dy = curr_center.y - start_center.y;
880
+ x: (t1.clientX + t2.clientX) / 2,
881
+ y: (t1.clientY + t2.clientY) / 2,
882
+ }
883
+ const dx = curr_center.x - start_center.x
884
+ const dy = curr_center.y - start_center.y
885
+
384
886
  // Calculate pinch scale (curr/start so spread = zoom out, pinch = zoom in)
385
- const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y);
887
+ const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y)
386
888
  // Guard against zero-distance pinch to avoid Infinity scale
387
- if (start_dist < Number.EPSILON)
388
- return;
389
- const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
390
- const scale = curr_dist / start_dist;
889
+ if (start_dist < Number.EPSILON) return
890
+ const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
891
+ const scale = curr_dist / start_dist
892
+
391
893
  // If scale changed significantly, treat as pinch-zoom
392
894
  // Also guard against scale being too small to avoid division by zero
393
895
  if (Math.abs(scale - 1) > PINCH_ZOOM_THRESHOLD && scale > Number.EPSILON) {
394
- // Pinch zoom centered on gesture center
395
- // Divide by scale so spread (scale > 1) = smaller span (zoom in)
396
- const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0];
397
- const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0];
398
- const y2_span = touch_state.initial_y2_range[1] -
399
- touch_state.initial_y2_range[0];
400
- const x_center = (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2;
401
- const y_center = (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2;
402
- const y2_center = (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2;
403
- ranges.current.x = [
404
- x_center - x_span / scale / 2,
405
- x_center + x_span / scale / 2,
406
- ];
407
- ranges.current.y = [
408
- y_center - y_span / scale / 2,
409
- y_center + y_span / scale / 2,
410
- ];
411
- ranges.current.y2 = [
412
- y2_center - y2_span / scale / 2,
413
- y2_center + y2_span / scale / 2,
414
- ];
415
- }
416
- else {
417
- // Pan
418
- const x_delta = pixels_to_data_delta(-dx, touch_state.initial_x_range, chart_width);
419
- const y_delta = pixels_to_data_delta(dy, touch_state.initial_y_range, chart_height);
420
- const y2_delta = pixels_to_data_delta(dy, touch_state.initial_y2_range, chart_height);
421
- ranges.current.x = pan_range(touch_state.initial_x_range, x_delta);
422
- ranges.current.y = pan_range(touch_state.initial_y_range, y_delta);
423
- ranges.current.y2 = pan_range(touch_state.initial_y2_range, y2_delta);
896
+ // Pinch zoom centered on gesture center
897
+ // Divide by scale so spread (scale > 1) = smaller span (zoom in)
898
+ const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0]
899
+ const x2_span = touch_state.initial_x2_range[1] -
900
+ touch_state.initial_x2_range[0]
901
+ const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0]
902
+ const y2_span = touch_state.initial_y2_range[1] -
903
+ touch_state.initial_y2_range[0]
904
+ const x_center =
905
+ (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2
906
+ const x2_center =
907
+ (touch_state.initial_x2_range[0] + touch_state.initial_x2_range[1]) / 2
908
+ const y_center =
909
+ (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2
910
+ const y2_center =
911
+ (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2
912
+
913
+ ranges.current.x = [
914
+ x_center - x_span / scale / 2,
915
+ x_center + x_span / scale / 2,
916
+ ]
917
+ ranges.current.x2 = [
918
+ x2_center - x2_span / scale / 2,
919
+ x2_center + x2_span / scale / 2,
920
+ ]
921
+ ranges.current.y = [
922
+ y_center - y_span / scale / 2,
923
+ y_center + y_span / scale / 2,
924
+ ]
925
+ ranges.current.y2 = [
926
+ y2_center - y2_span / scale / 2,
927
+ y2_center + y2_span / scale / 2,
928
+ ]
929
+ } else {
930
+ // Pan
931
+ const x_delta = pixels_to_data_delta(
932
+ -dx,
933
+ touch_state.initial_x_range,
934
+ chart_width,
935
+ )
936
+ const x2_delta = pixels_to_data_delta(
937
+ -dx,
938
+ touch_state.initial_x2_range,
939
+ chart_width,
940
+ )
941
+ const y_delta = pixels_to_data_delta(
942
+ dy,
943
+ touch_state.initial_y_range,
944
+ chart_height,
945
+ )
946
+ const y2_delta = pixels_to_data_delta(
947
+ dy,
948
+ touch_state.initial_y2_range,
949
+ chart_height,
950
+ )
951
+ ranges.current.x = pan_range(touch_state.initial_x_range, x_delta)
952
+ ranges.current.x2 = pan_range(touch_state.initial_x2_range, x2_delta)
953
+ ranges.current.y = pan_range(touch_state.initial_y_range, y_delta)
954
+ ranges.current.y2 = pan_range(touch_state.initial_y2_range, y2_delta)
424
955
  }
425
- }
426
- function handle_touch_end() {
427
- touch_state = null;
428
- }
429
- // Legend data and handlers
430
- let legend_data = $derived.by(() => series.map((srs, idx) => {
431
- const is_line = srs.render_mode === `line`;
432
- const series_markers = srs.markers ?? DEFAULT_MARKERS;
433
- const has_line = series_markers === `line` || series_markers === `line+points`;
434
- const has_points = series_markers === `points` ||
435
- series_markers === `line+points`;
436
- const series_color = srs.color ?? (is_line ? line_state.color : bar_state.color);
437
- // Get point style for symbol color (handle array or single object)
438
- const first_point_style = Array.isArray(srs.point_style)
956
+ }
957
+
958
+ function handle_touch_end() {
959
+ touch_state = null
960
+ }
961
+
962
+ // Legend data and handlers
963
+ let legend_data = $derived.by<LegendItem[]>(() =>
964
+ series.map((srs: BarSeries<Metadata>, idx: number) => {
965
+ const is_line = srs.render_mode === `line`
966
+ const series_markers = srs.markers ?? DEFAULT_MARKERS
967
+ const has_line = series_markers === `line` || series_markers === `line+points`
968
+ const has_points = series_markers === `points` ||
969
+ series_markers === `line+points`
970
+ const series_color = srs.color ?? (is_line ? line_state.color : bar_state.color)
971
+
972
+ // Get point style for symbol color (handle array or single object)
973
+ const first_point_style = Array.isArray(srs.point_style)
439
974
  ? srs.point_style[0]
440
- : srs.point_style;
441
- const first_color_value = srs.color_values?.[0];
442
- const point_color = first_color_value != null
975
+ : srs.point_style
976
+ const first_color_value = srs.color_values?.[0]
977
+ const point_color = first_color_value != null
443
978
  ? color_scale_fn(first_color_value)
444
- : first_point_style?.fill ?? series_color;
445
- if (is_line) {
979
+ : first_point_style?.fill ?? series_color
980
+
981
+ if (is_line) {
446
982
  // Line series: show line and/or symbol based on markers
447
983
  return {
448
- series_idx: idx,
449
- label: srs.label ?? `Series ${idx + 1}`,
450
- visible: srs.visible ?? true,
451
- legend_group: srs.legend_group,
452
- display_style: {
453
- ...(has_line
454
- ? {
455
- line_color: series_color,
456
- line_dash: srs.line_style?.line_dash,
457
- }
458
- : {}),
459
- ...(has_points
460
- ? {
461
- symbol_type: first_point_style?.symbol_type ??
462
- DEFAULTS.scatter.symbol_type,
463
- symbol_color: point_color,
464
- }
465
- : {}),
466
- },
467
- };
468
- }
469
- // Bar series: show square symbol
470
- return {
984
+ series_idx: idx,
985
+ label: srs.label ?? `Series ${idx + 1}`,
986
+ visible: srs.visible ?? true,
987
+ legend_group: srs.legend_group,
988
+ display_style: {
989
+ ...(has_line
990
+ ? {
991
+ line_color: series_color,
992
+ line_dash: srs.line_style?.line_dash,
993
+ }
994
+ : {}),
995
+ ...(has_points
996
+ ? {
997
+ symbol_type: first_point_style?.symbol_type ??
998
+ DEFAULTS.scatter.symbol_type,
999
+ symbol_color: point_color,
1000
+ }
1001
+ : {}),
1002
+ },
1003
+ }
1004
+ }
1005
+ // Bar series: show square symbol
1006
+ return {
471
1007
  series_idx: idx,
472
1008
  label: srs.label ?? `Series ${idx + 1}`,
473
1009
  visible: srs.visible ?? true,
474
1010
  legend_group: srs.legend_group,
475
1011
  display_style: {
476
- symbol_type: `Square`,
477
- symbol_color: series_color,
1012
+ symbol_type: `Square` as const,
1013
+ symbol_color: series_color,
478
1014
  },
479
- };
480
- }));
481
- function toggle_series_visibility(series_idx) {
1015
+ }
1016
+ })
1017
+ )
1018
+
1019
+ function toggle_series_visibility(series_idx: number) {
482
1020
  if (series_idx >= 0 && series_idx < series.length) {
483
- series = series.map((srs, idx) => idx === series_idx ? { ...srs, visible: !(srs.visible ?? true) } : srs);
1021
+ series = series.map((srs, idx) =>
1022
+ idx === series_idx ? { ...srs, visible: !(srs.visible ?? true) } : srs
1023
+ )
484
1024
  }
485
- }
486
- function toggle_group_visibility(_group_name, series_indices) {
1025
+ }
1026
+
1027
+ function toggle_group_visibility(_group_name: string, series_indices: number[]) {
487
1028
  // Filter to valid indices upfront (consistent with shared toggle_group_visibility)
488
- const valid_indices = series_indices.filter((idx) => idx >= 0 && idx < series.length);
489
- if (valid_indices.length === 0)
490
- return;
491
- const idx_set = new Set(valid_indices);
1029
+ const valid_indices = series_indices.filter((idx) =>
1030
+ idx >= 0 && idx < series.length
1031
+ )
1032
+ if (valid_indices.length === 0) return
1033
+
1034
+ const idx_set = new Set(valid_indices)
492
1035
  // Check if all series in the group are currently visible
493
- const all_visible = valid_indices.every((idx) => series[idx].visible ?? true);
1036
+ const all_visible = valid_indices.every((idx) => series[idx].visible ?? true)
494
1037
  // Toggle: if all visible, hide all; otherwise show all
495
- const new_visibility = !all_visible;
496
- series = series.map((srs, idx) => idx_set.has(idx) ? { ...srs, visible: new_visibility } : srs);
497
- }
498
- // Collect bar and line positions for legend placement
499
- let bar_points_for_placement = $derived.by(() => {
500
- if (!width || !height || !visible_series.length)
501
- return [];
502
- return visible_series.flatMap((srs) => {
503
- const is_line = srs.render_mode === `line`;
504
- // Use original series index to look up stacked_offsets
505
- const series_idx = series.indexOf(srs);
506
- const series_offsets = stacked_offsets[series_idx] ?? [];
507
- const use_y2 = srs.y_axis === `y2`;
508
- const y_scale = use_y2 ? scales.y2 : scales.y;
509
- return srs.x
510
- .map((x_val, bar_idx) => {
511
- const y_val = srs.y[bar_idx];
512
- const base = !is_line && mode === `stacked`
513
- ? (series_offsets[bar_idx] ?? 0)
514
- : 0;
515
- const [bar_x, bar_y] = orientation === `vertical`
516
- ? [scales.x(x_val), y_scale(base + y_val)]
517
- : [scales.x(base + y_val), scales.y(x_val)];
518
- return { x: bar_x, y: bar_y };
1038
+ const new_visibility = !all_visible
1039
+ series = series.map((srs, idx) =>
1040
+ idx_set.has(idx) ? { ...srs, visible: new_visibility } : srs
1041
+ )
1042
+ }
1043
+
1044
+ // Collect bar and line positions for legend placement
1045
+ let bar_points_for_placement = $derived.by(() => {
1046
+ if (!width || !height || !visible_series.length) return []
1047
+
1048
+ return internal_series.flatMap((srs, series_idx) => {
1049
+ if (!(srs?.visible ?? true)) return []
1050
+ const is_line = srs.render_mode === `line`
1051
+ const series_offsets = stacked_offsets[series_idx] ?? []
1052
+ const use_y2 = srs.y_axis === `y2`
1053
+ const y_scale = use_y2 ? scales.y2 : scales.y
1054
+ const use_x2_pl = srs.x_axis === `x2`
1055
+ const x_scale_pl = use_x2_pl ? scales.x2 : scales.x
1056
+ return srs.x
1057
+ .map((x_val, bar_idx) => {
1058
+ const y_val = srs.y[bar_idx]
1059
+ const base = !is_line && mode === `stacked`
1060
+ ? (series_offsets[bar_idx] ?? 0)
1061
+ : 0
1062
+ const [bar_x, bar_y] = orientation === `vertical`
1063
+ ? [x_scale_pl(x_val), y_scale(base + y_val)]
1064
+ : [x_scale_pl(base + y_val), scales.y(x_val)]
1065
+ return { x: bar_x, y: bar_y }
519
1066
  })
520
- .filter(({ x, y }) => isFinite(x) && isFinite(y));
521
- });
522
- });
523
- // Legend placement stability state
524
- let legend_element = $state();
525
- const legend_hover = create_hover_lock();
526
- const dim_tracker = create_dimension_tracker();
527
- let has_initial_legend_placement = $state(false);
528
- // Clear pending hover lock timeout on unmount
529
- $effect(() => () => legend_hover.cleanup());
530
- // Calculate best legend placement using continuous grid sampling
531
- let legend_placement = $derived.by(() => {
532
- const should_show = show_legend !== undefined ? show_legend : series.length > 1;
533
- if (!should_show || !width || !height)
534
- return null;
1067
+ .filter(({ x, y }) => isFinite(x) && isFinite(y))
1068
+ })
1069
+ })
1070
+
1071
+ // Legend placement stability state
1072
+ let legend_element = $state<HTMLDivElement | undefined>()
1073
+ const legend_hover = create_hover_lock()
1074
+ const dim_tracker = create_dimension_tracker()
1075
+ let has_initial_legend_placement = $state(false)
1076
+
1077
+ // Clear pending hover lock timeout on unmount
1078
+ $effect(() => () => legend_hover.cleanup())
1079
+
1080
+ // Calculate best legend placement using continuous grid sampling
1081
+ let legend_placement = $derived.by(() => {
1082
+ const should_show = show_legend !== undefined ? show_legend : series.length > 1
1083
+ if (!should_show || !width || !height) return null
1084
+
535
1085
  // Use measured size if available, otherwise estimate
536
1086
  const legend_size = legend_element
537
- ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
538
- : { width: 120, height: 60 };
1087
+ ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
1088
+ : { width: 120, height: 60 }
1089
+
539
1090
  const result = compute_element_placement({
540
- plot_bounds: { x: pad.l, y: pad.t, width: chart_width, height: chart_height },
541
- element_size: legend_size,
542
- axis_clearance: legend?.axis_clearance,
543
- exclude_rects: [],
544
- points: bar_points_for_placement,
545
- });
546
- return result;
547
- });
548
- // Tweened legend coordinates for smooth animation - create once, update target via effect
549
- // untrack() explicitly captures initial tween config (intentional - config set once at mount)
550
- const tweened_legend_coords = new Tween({ x: 0, y: 0 }, untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })));
551
- // Update legend position with stability checks
552
- $effect(() => {
553
- if (!width || !height || !legend_placement)
554
- return;
1091
+ plot_bounds: { x: pad.l, y: pad.t, width: chart_width, height: chart_height },
1092
+ element_size: legend_size,
1093
+ axis_clearance: legend?.axis_clearance,
1094
+ exclude_rects: [],
1095
+ points: bar_points_for_placement,
1096
+ })
1097
+
1098
+ return result
1099
+ })
1100
+
1101
+ // Tweened legend coordinates for smooth animation - create once, update target via effect
1102
+ // untrack() explicitly captures initial tween config (intentional - config set once at mount)
1103
+ const tweened_legend_coords = new Tween(
1104
+ { x: 0, y: 0 },
1105
+ untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })),
1106
+ )
1107
+
1108
+ // Update legend position with stability checks
1109
+ $effect(() => {
1110
+ if (!width || !height || !legend_placement) return
1111
+
555
1112
  // Track dimensions for resize detection
556
- const dims_changed = dim_tracker.has_changed(width, height);
557
- if (dims_changed)
558
- dim_tracker.update(width, height);
559
- const is_responsive = legend?.responsive ?? false;
1113
+ const dims_changed = dim_tracker.has_changed(width, height)
1114
+ if (dims_changed) dim_tracker.update(width, height)
1115
+
1116
+ const is_responsive = legend?.responsive ?? false
560
1117
  // Only update if: resize occurred, OR (not hover-locked AND (responsive OR not yet initially placed))
561
1118
  const should_update = dims_changed || (!legend_hover.is_locked.current &&
562
- (is_responsive || !has_initial_legend_placement));
1119
+ (is_responsive || !has_initial_legend_placement))
1120
+
563
1121
  if (should_update) {
564
- tweened_legend_coords.set({ x: legend_placement.x, y: legend_placement.y },
1122
+ tweened_legend_coords.set(
1123
+ { x: legend_placement.x, y: legend_placement.y },
565
1124
  // Skip animation on initial placement to avoid jump from (0, 0)
566
- has_initial_legend_placement ? undefined : { duration: 0 });
567
- // Only lock position after we have actual measured size
568
- if (legend_element) {
569
- has_initial_legend_placement = true;
570
- }
1125
+ has_initial_legend_placement ? undefined : { duration: 0 },
1126
+ )
1127
+ // Only lock position after we have actual measured size
1128
+ if (legend_element) {
1129
+ has_initial_legend_placement = true
1130
+ }
571
1131
  }
572
- });
573
- // Tooltip state
574
- let hover_info = $state(null);
575
- let tooltip_el = $state();
576
- function get_bar_data(series_idx, bar_idx, color) {
577
- const srs = series[series_idx];
578
- const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]];
579
- const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y];
1132
+ })
1133
+
1134
+ // Tooltip state
1135
+ let hover_info = $state<BarHandlerProps<Metadata> | null>(null)
1136
+ let tooltip_el = $state<HTMLDivElement | undefined>()
1137
+
1138
+ function get_bar_data(
1139
+ series_idx: number,
1140
+ bar_idx: number,
1141
+ color: string,
1142
+ ): BarHandlerProps<Metadata> {
1143
+ const srs = internal_series[series_idx]
1144
+ const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]]
1145
+ const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y]
580
1146
  const metadata = Array.isArray(srs.metadata)
581
- ? srs.metadata[bar_idx]
582
- : srs.metadata;
583
- const label = srs.labels?.[bar_idx] ?? null;
584
- const active_y_axis = srs.y_axis ?? `y1`;
585
- const coords = { x, y, orient_x, orient_y, x_axis, y_axis, y2_axis };
586
- return { ...coords, metadata, color, label, series_idx, bar_idx, active_y_axis };
587
- }
588
- const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
589
- hovered = true;
590
- hover_info = get_bar_data(series_idx, bar_idx, color);
591
- change(hover_info);
592
- on_bar_hover?.({ ...hover_info, event });
593
- };
594
- // Stack offsets (only for bar series in stacked mode, grouped by y-axis)
595
- let stacked_offsets = $derived.by(() => {
596
- if (mode !== `stacked`)
597
- return [];
598
- const max_len = Math.max(0, ...series.map((srs) => srs.y.length));
599
- const offsets = series.map(() => Array.from({ length: max_len }, () => 0));
1147
+ ? srs.metadata[bar_idx]
1148
+ : srs.metadata
1149
+ const label = srs.labels?.[bar_idx] ?? null
1150
+ const active_y_axis = srs.y_axis ?? `y1`
1151
+ const active_x_axis = srs.x_axis ?? `x1`
1152
+ const category_label = category_list[x]
1153
+ const coords = {
1154
+ x,
1155
+ y,
1156
+ orient_x,
1157
+ orient_y,
1158
+ x_axis: active_x_axis === `x2` ? x2_axis : x_axis,
1159
+ x2_axis,
1160
+ y_axis: active_y_axis === `y2` ? y2_axis : y_axis,
1161
+ y2_axis,
1162
+ }
1163
+ return {
1164
+ ...coords,
1165
+ metadata,
1166
+ color,
1167
+ label,
1168
+ series_idx,
1169
+ bar_idx,
1170
+ active_y_axis,
1171
+ active_x_axis,
1172
+ category_label,
1173
+ }
1174
+ }
1175
+
1176
+ // Find the point closest to the cursor on a polyline overlay (O(n) scan).
1177
+ function find_closest_point(
1178
+ evt: MouseEvent,
1179
+ points: LineSeriesPoint[],
1180
+ ): LineSeriesPoint | null {
1181
+ const svg_el = (evt.target as Element).closest(`svg`)
1182
+ if (!svg_el) return null
1183
+ const rect = svg_el.getBoundingClientRect()
1184
+ const mx = evt.clientX - rect.left
1185
+ const my = evt.clientY - rect.top
1186
+ let best: LineSeriesPoint | null = null
1187
+ let best_dist = Infinity
1188
+ for (const pt of points) {
1189
+ const dist = (pt.x - mx) ** 2 + (pt.y - my) ** 2
1190
+ if (dist < best_dist) {
1191
+ best_dist = dist
1192
+ best = pt
1193
+ }
1194
+ }
1195
+ return best
1196
+ }
1197
+
1198
+ const line_point_fill = (pt: LineSeriesPoint, series_color: string): string =>
1199
+ pt.color_value != null
1200
+ ? color_scale_fn(pt.color_value)
1201
+ : pt.point_style?.fill ?? series_color
1202
+
1203
+ const handle_bar_hover =
1204
+ (series_idx: number, bar_idx: number, color: string) => (event: MouseEvent) => {
1205
+ hovered = true
1206
+ hover_info = get_bar_data(series_idx, bar_idx, color)
1207
+ change(hover_info)
1208
+ on_bar_hover?.({ ...hover_info, event })
1209
+ }
1210
+
1211
+ // Stack offsets (only for bar series in stacked mode, grouped by y-axis)
1212
+ let stacked_offsets = $derived.by(() => {
1213
+ if (mode !== `stacked`) return [] as number[][]
1214
+ const max_len = Math.max(
1215
+ 0,
1216
+ ...internal_series.map((srs) => srs.y.length),
1217
+ )
1218
+ const offsets = internal_series.map(() =>
1219
+ Array.from({ length: max_len }, () => 0)
1220
+ )
1221
+
600
1222
  // Separate accumulators for y1 and y2 axes
601
- const y1_pos_acc = Array.from({ length: max_len }, () => 0);
602
- const y1_neg_acc = Array.from({ length: max_len }, () => 0);
603
- const y2_pos_acc = Array.from({ length: max_len }, () => 0);
604
- const y2_neg_acc = Array.from({ length: max_len }, () => 0);
605
- series.forEach((srs, series_idx) => {
606
- if (!(srs?.visible ?? true) || srs.render_mode === `line`)
607
- return;
608
- const use_y2 = srs.y_axis === `y2`;
609
- const pos_acc = use_y2 ? y2_pos_acc : y1_pos_acc;
610
- const neg_acc = use_y2 ? y2_neg_acc : y1_neg_acc;
611
- for (let bar_idx = 0; bar_idx < max_len; bar_idx++) {
612
- const y_val = srs.y[bar_idx] ?? 0;
613
- const acc = y_val >= 0 ? pos_acc : neg_acc;
614
- offsets[series_idx][bar_idx] = acc[bar_idx];
615
- acc[bar_idx] += y_val;
616
- }
617
- });
618
- return offsets;
619
- });
620
- // Calculate group positions for grouped mode (side-by-side bars)
621
- let group_info = $derived.by(() => {
622
- if (mode !== `grouped`)
623
- return { bar_series_count: 0, bar_series_indices: [] };
624
- const bar_series_indices = series
625
- .map((srs, idx) => (srs?.visible ?? true) && srs.render_mode !== `line` ? idx : -1)
626
- .filter((idx) => idx >= 0);
627
- return { bar_series_count: bar_series_indices.length, bar_series_indices };
628
- });
629
- // Set theme-aware background when entering fullscreen
630
- $effect(() => {
631
- set_fullscreen_bg(wrapper, fullscreen, `--barplot-fullscreen-bg`);
632
- });
633
- // State accessors for shared axis change handler
634
- const axis_state = {
635
- get_axis: (axis) => (axis === `x` ? x_axis : axis === `y` ? y_axis : y2_axis),
1223
+ const y1_pos_acc = Array.from({ length: max_len }, () => 0)
1224
+ const y1_neg_acc = Array.from({ length: max_len }, () => 0)
1225
+ const y2_pos_acc = Array.from({ length: max_len }, () => 0)
1226
+ const y2_neg_acc = Array.from({ length: max_len }, () => 0)
1227
+
1228
+ internal_series.forEach((srs, series_idx) => {
1229
+ if (!(srs?.visible ?? true) || srs.render_mode === `line`) return
1230
+
1231
+ const use_y2 = srs.y_axis === `y2`
1232
+ const pos_acc = use_y2 ? y2_pos_acc : y1_pos_acc
1233
+ const neg_acc = use_y2 ? y2_neg_acc : y1_neg_acc
1234
+
1235
+ for (let bar_idx = 0; bar_idx < max_len; bar_idx++) {
1236
+ const y_val = srs.y[bar_idx] ?? 0
1237
+ const acc = y_val >= 0 ? pos_acc : neg_acc
1238
+ offsets[series_idx][bar_idx] = acc[bar_idx]
1239
+ acc[bar_idx] += y_val
1240
+ }
1241
+ })
1242
+ return offsets
1243
+ })
1244
+
1245
+ // Calculate group positions for grouped mode (side-by-side bars)
1246
+ let group_info = $derived.by(() => {
1247
+ if (mode !== `grouped`) return { bar_series_count: 0, bar_series_indices: [] }
1248
+ const bar_series_indices = internal_series
1249
+ .map((srs, idx) =>
1250
+ (srs?.visible ?? true) && srs.render_mode !== `line` ? idx : -1
1251
+ )
1252
+ .filter((idx) => idx >= 0)
1253
+ return { bar_series_count: bar_series_indices.length, bar_series_indices }
1254
+ })
1255
+
1256
+ // Set theme-aware background when entering fullscreen
1257
+ $effect(() => {
1258
+ set_fullscreen_bg(wrapper, fullscreen, `--barplot-fullscreen-bg`)
1259
+ })
1260
+
1261
+ // State accessors for shared axis change handler
1262
+ const axis_state: AxisChangeState<BarSeries<Metadata>> = {
1263
+ get_axis: (axis) => {
1264
+ if (axis === `x`) return x_axis
1265
+ if (axis === `x2`) return x2_axis
1266
+ if (axis === `y`) return y_axis
1267
+ return y2_axis
1268
+ },
636
1269
  set_axis: (axis, config) => {
637
- // Spread into existing state to preserve merged type structure
638
- if (axis === `x`)
639
- x_axis = { ...x_axis, ...config };
640
- else if (axis === `y`)
641
- y_axis = { ...y_axis, ...config };
642
- else
643
- y2_axis = { ...y2_axis, ...config };
1270
+ // Spread into existing state to preserve merged type structure
1271
+ if (axis === `x`) x_axis = { ...x_axis, ...config }
1272
+ else if (axis === `x2`) x2_axis = { ...x2_axis, ...config }
1273
+ else if (axis === `y`) y_axis = { ...y_axis, ...config }
1274
+ else y2_axis = { ...y2_axis, ...config }
644
1275
  },
645
1276
  get_series: () => series,
646
1277
  set_series: (new_series) => (series = new_series),
647
1278
  get_loading: () => axis_loading,
648
1279
  set_loading: (axis) => (axis_loading = axis),
649
- };
650
- // Create shared handler bound to this component's state
651
- // Using $derived so handler updates when callback props change
652
- const handle_axis_change = $derived(create_axis_change_handler(axis_state, data_loader, on_axis_change, on_error));
653
- let auto_load_attempted = false; // prevent infinite retries on failure
654
- // Auto-load data if series is empty but options exist (runs once)
655
- $effect(() => {
1280
+ }
1281
+
1282
+ // Create shared handler bound to this component's state
1283
+ // Using $derived so handler updates when callback props change
1284
+ const handle_axis_change = $derived(create_axis_change_handler(
1285
+ axis_state,
1286
+ data_loader,
1287
+ on_axis_change,
1288
+ on_error,
1289
+ ))
1290
+
1291
+ let auto_load_attempted = false // prevent infinite retries on failure
1292
+
1293
+ // Auto-load data if series is empty but options exist (runs once)
1294
+ $effect(() => {
656
1295
  if (series.length === 0 && data_loader && !auto_load_attempted) {
657
- // Check x-axis first, then y-axis
658
- if (x_axis.options?.length) {
659
- auto_load_attempted = true;
660
- const first_key = x_axis.selected_key ?? x_axis.options[0].key;
661
- handle_axis_change(`x`, first_key).catch(() => { });
662
- }
663
- else if (y_axis.options?.length) {
664
- auto_load_attempted = true;
665
- const first_key = y_axis.selected_key ?? y_axis.options[0].key;
666
- handle_axis_change(`y`, first_key).catch(() => { });
667
- }
1296
+ // Check x-axis first, then y-axis
1297
+ if (x_axis.options?.length) {
1298
+ auto_load_attempted = true
1299
+ const first_key = x_axis.selected_key ?? x_axis.options[0].key
1300
+ handle_axis_change(`x`, first_key).catch(() => {})
1301
+ } else if (y_axis.options?.length) {
1302
+ auto_load_attempted = true
1303
+ const first_key = y_axis.selected_key ?? y_axis.options[0].key
1304
+ handle_axis_change(`y`, first_key).catch(() => {})
1305
+ }
668
1306
  }
669
- });
1307
+ })
670
1308
  </script>
671
1309
 
672
1310
  {#snippet ref_lines_layer(lines: IndexedRefLine[])}
@@ -674,11 +1312,12 @@ $effect(() => {
674
1312
  <ReferenceLine
675
1313
  ref_line={line}
676
1314
  line_idx={line.idx}
677
- x_min={ranges.current.x[0]}
678
- x_max={ranges.current.x[1]}
1315
+ x_min={line.x_axis === `x2` ? ranges.current.x2[0] : ranges.current.x[0]}
1316
+ x_max={line.x_axis === `x2` ? ranges.current.x2[1] : ranges.current.x[1]}
679
1317
  y_min={line.y_axis === `y2` ? ranges.current.y2[0] : ranges.current.y[0]}
680
1318
  y_max={line.y_axis === `y2` ? ranges.current.y2[1] : ranges.current.y[1]}
681
1319
  x_scale={scales.x}
1320
+ x2_scale={scales.x2}
682
1321
  y_scale={scales.y}
683
1322
  y2_scale={scales.y2}
684
1323
  {clip_path_id}
@@ -729,6 +1368,8 @@ $effect(() => {
729
1368
  <svg
730
1369
  bind:this={svg_element}
731
1370
  role="application"
1371
+ aria-label={rest[`aria-label`] ??
1372
+ ([x_axis.label, y_axis.label].filter(Boolean).join(` vs `) || `Bar chart`)}
732
1373
  tabindex="0"
733
1374
  onfocusin={() => (is_focused = true)}
734
1375
  onfocusout={() => (is_focused = false)}
@@ -736,10 +1377,12 @@ $effect(() => {
736
1377
  ondblclick={() => {
737
1378
  // Reset zoom to initial ranges (undo any pan/zoom)
738
1379
  ranges.current.x = [...ranges.initial.x] as [number, number]
1380
+ ranges.current.x2 = [...ranges.initial.x2] as [number, number]
739
1381
  ranges.current.y = [...ranges.initial.y] as [number, number]
740
1382
  ranges.current.y2 = [...ranges.initial.y2] as [number, number]
741
1383
  // Also reset axis props so future data changes recalculate auto ranges
742
1384
  x_axis = { ...x_axis, range: [null, null] }
1385
+ x2_axis = { ...x2_axis, range: [null, null] }
743
1386
  y_axis = { ...y_axis, range: [null, null] }
744
1387
  y2_axis = { ...y2_axis, range: [null, null] }
745
1388
  }}
@@ -759,26 +1402,19 @@ $effect(() => {
759
1402
  ? `grab`
760
1403
  : `crosshair`}
761
1404
  >
762
- <!-- Zoom rectangle -->
763
- {#if drag_state.start && drag_state.current && isFinite(drag_state.start.x) &&
764
- isFinite(drag_state.start.y) && isFinite(drag_state.current.x) &&
765
- isFinite(drag_state.current.y)}
766
- {@const x = Math.min(drag_state.start.x, drag_state.current.x)}
767
- {@const y = Math.min(drag_state.start.y, drag_state.current.y)}
768
- {@const rect_w = Math.abs(drag_state.start.x - drag_state.current.x)}
769
- {@const rect_h = Math.abs(drag_state.start.y - drag_state.current.y)}
770
- <rect class="zoom-rect" {x} {y} width={rect_w} height={rect_h} />
771
- {/if}
1405
+ <ZoomRect start={drag_state.start} current={drag_state.current} />
772
1406
 
773
1407
  <!-- User content (custom overlays, reference lines, etc.) -->
774
1408
  {@render user_content?.({
775
1409
  height,
776
1410
  width,
777
1411
  x_scale_fn: scales.x,
1412
+ x2_scale_fn: scales.x2,
778
1413
  y_scale_fn: scales.y,
779
1414
  y2_scale_fn: scales.y2,
780
1415
  pad,
781
1416
  x_range: ranges.current.x,
1417
+ x2_range: ranges.current.x2,
782
1418
  y_range: ranges.current.y,
783
1419
  y2_range: ranges.current.y2,
784
1420
  fullscreen,
@@ -833,37 +1469,106 @@ $effect(() => {
833
1469
  ? `rotate(${rotation}, ${shift_x}, ${text_y})`
834
1470
  : undefined}
835
1471
  >
836
- {format_value(tick, x_axis.format)}
1472
+ {
1473
+ get_tick_label(
1474
+ tick as number,
1475
+ cat_axis === `x` ? effective_cat_ticks : x_axis.ticks,
1476
+ ) ??
1477
+ format_value(tick, x_axis.format)
1478
+ }
837
1479
  </text>
838
1480
  </g>
839
1481
  {/if}
840
1482
  {/each}
841
1483
  {#if x_axis.label || x_axis.options?.length}
842
- {@const shift_x = x_axis.label_shift?.x ?? 0}
843
- {@const shift_y = x_axis.label_shift?.y ?? 0}
844
- <foreignObject
845
- x={pad.l + chart_width / 2 + shift_x - AXIS_LABEL_CONTAINER.x_offset}
846
- y={height - (pad.b / 3) + shift_y - AXIS_LABEL_CONTAINER.y_offset}
847
- width={AXIS_LABEL_CONTAINER.width}
848
- height={AXIS_LABEL_CONTAINER.height}
849
- style="overflow: visible; pointer-events: none"
850
- >
851
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
852
- <InteractiveAxisLabel
853
- label={x_axis.label ?? ``}
854
- options={x_axis.options}
855
- selected_key={x_axis.selected_key}
856
- loading={axis_loading === `x`}
857
- axis_type="x"
858
- color={x_axis.color}
859
- on_select={(key) => handle_axis_change(`x`, key)}
860
- class="axis-label x-label"
861
- />
862
- </div>
863
- </foreignObject>
1484
+ {@const { label_shift, label = ``, options, selected_key, color } = x_axis}
1485
+ <AxisLabel
1486
+ x={pad.l + chart_width / 2 + (label_shift?.x ?? 0)}
1487
+ y={height - (pad.b / 3) + (label_shift?.y ?? 0)}
1488
+ {label}
1489
+ {options}
1490
+ {selected_key}
1491
+ loading={axis_loading === `x`}
1492
+ axis_type="x"
1493
+ {color}
1494
+ on_select={(key) => handle_axis_change(`x`, key)}
1495
+ />
864
1496
  {/if}
865
1497
  </g>
866
1498
 
1499
+ <!-- X2-axis (Top) -->
1500
+ <!-- Note: x2 axis is only supported for vertical orientation -->
1501
+ {#if x2_series.length > 0 && orientation === `vertical`}
1502
+ <g class="x2-axis">
1503
+ <line
1504
+ x1={pad.l}
1505
+ x2={width - pad.r}
1506
+ y1={pad.t}
1507
+ y2={pad.t}
1508
+ stroke={x2_axis.color || `var(--border-color, gray)`}
1509
+ stroke-width="1"
1510
+ />
1511
+ {#each ticks.x2 as tick (tick)}
1512
+ {@const tick_x = scales.x2(tick as number)}
1513
+ {#if isFinite(tick_x)}
1514
+ {@const rotation = x2_axis.tick?.label?.rotation ?? 0}
1515
+ {@const shift_x = x2_axis.tick?.label?.shift?.x ?? 0}
1516
+ {@const shift_y = x2_axis.tick?.label?.shift?.y ?? 0}
1517
+ {@const inside = x2_axis.tick?.label?.inside ?? false}
1518
+ {@const base_y = inside ? 8 : (rotation !== 0 ? -8 : -18)}
1519
+ {@const text_y = base_y + shift_y}
1520
+ {@const text_anchor = rotation !== 0 ? (inside ? `start` : `end`) : `middle`}
1521
+ {@const dominant_baseline = inside ? `hanging` : `auto`}
1522
+ <g class="tick" transform="translate({tick_x}, {pad.t})">
1523
+ {#if display.x2_grid}
1524
+ <line
1525
+ y1="0"
1526
+ y2={height - pad.b - pad.t}
1527
+ {...DEFAULT_GRID_STYLE}
1528
+ {...(x2_axis.grid_style ?? {})}
1529
+ />
1530
+ {/if}
1531
+ <line
1532
+ y1={inside ? 5 : 0}
1533
+ y2={inside ? 0 : -5}
1534
+ stroke={x2_axis.color || `var(--border-color, gray)`}
1535
+ stroke-width="1"
1536
+ />
1537
+ <text
1538
+ x={shift_x}
1539
+ y={text_y}
1540
+ text-anchor={text_anchor}
1541
+ dominant-baseline={dominant_baseline}
1542
+ fill={x2_axis.color || `var(--text-color)`}
1543
+ transform={rotation !== 0
1544
+ ? `rotate(${rotation}, ${shift_x}, ${text_y})`
1545
+ : undefined}
1546
+ >
1547
+ {
1548
+ get_tick_label(tick as number, x2_axis.ticks) ??
1549
+ format_value(tick, x2_axis.format)
1550
+ }
1551
+ </text>
1552
+ </g>
1553
+ {/if}
1554
+ {/each}
1555
+ {#if x2_axis.label || x2_axis.options?.length}
1556
+ {@const { label_shift, label = ``, options, selected_key, color } = x2_axis}
1557
+ <AxisLabel
1558
+ x={pad.l + chart_width / 2 + (label_shift?.x ?? 0)}
1559
+ y={Math.max(12, pad.t - (label_shift?.y ?? 40))}
1560
+ {label}
1561
+ {options}
1562
+ {selected_key}
1563
+ loading={axis_loading === `x2`}
1564
+ axis_type="x2"
1565
+ {color}
1566
+ on_select={(key) => handle_axis_change(`x2`, key)}
1567
+ />
1568
+ {/if}
1569
+ </g>
1570
+ {/if}
1571
+
867
1572
  <!-- Y-axis -->
868
1573
  <g class="y-axis">
869
1574
  <line
@@ -909,47 +1614,36 @@ $effect(() => {
909
1614
  ? `rotate(${rotation}, ${text_x}, ${shift_y})`
910
1615
  : undefined}
911
1616
  >
912
- {format_value(tick, y_axis.format)}
1617
+ {
1618
+ get_tick_label(
1619
+ tick as number,
1620
+ cat_axis === `y` ? effective_cat_ticks : y_axis.ticks,
1621
+ ) ??
1622
+ format_value(tick, y_axis.format)
1623
+ }
913
1624
  </text>
914
1625
  </g>
915
1626
  {/if}
916
1627
  {/each}
917
1628
  {#if y_axis.label || y_axis.options?.length}
918
- {@const max_y_tick_width = Math.max(
919
- 0,
920
- ...ticks.y.map((tick) =>
921
- measure_text_width(
922
- format_value(tick, y_axis.format),
923
- `12px sans-serif`,
924
- )
925
- ),
926
- )}
927
- {@const shift_x = y_axis.label_shift?.x ?? 0}
928
- {@const shift_y = y_axis.label_shift?.y ?? 0}
929
- {@const y_label_x = Math.max(12, pad.l - max_y_tick_width - LABEL_GAP_DEFAULT) +
930
- shift_x}
931
- {@const y_label_y = pad.t + chart_height / 2 + shift_y}
932
- <foreignObject
933
- x={y_label_x - AXIS_LABEL_CONTAINER.x_offset}
934
- y={y_label_y - AXIS_LABEL_CONTAINER.y_offset}
935
- width={AXIS_LABEL_CONTAINER.width}
936
- height={AXIS_LABEL_CONTAINER.height}
937
- style="overflow: visible; pointer-events: none"
938
- transform="rotate(-90, {y_label_x}, {y_label_y})"
939
- >
940
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
941
- <InteractiveAxisLabel
942
- label={y_axis.label ?? ``}
943
- options={y_axis.options}
944
- selected_key={y_axis.selected_key}
945
- loading={axis_loading === `y`}
946
- axis_type="y"
947
- color={y_axis.color}
948
- on_select={(key) => handle_axis_change(`y`, key)}
949
- class="axis-label y-label"
950
- />
951
- </div>
952
- </foreignObject>
1629
+ {@const { label_shift, label = ``, options, selected_key, color, tick } = y_axis}
1630
+ {@const y_inside = tick?.label?.inside ?? false}
1631
+ <AxisLabel
1632
+ x={Math.max(
1633
+ 12,
1634
+ pad.l - (y_inside ? 0 : tick_label_widths.y_max) - LABEL_GAP_DEFAULT,
1635
+ ) +
1636
+ (label_shift?.x ?? 0)}
1637
+ y={pad.t + chart_height / 2 + (label_shift?.y ?? 0)}
1638
+ rotate
1639
+ {label}
1640
+ {options}
1641
+ {selected_key}
1642
+ loading={axis_loading === `y`}
1643
+ axis_type="y"
1644
+ {color}
1645
+ on_select={(key) => handle_axis_change(`y`, key)}
1646
+ />
953
1647
  {/if}
954
1648
  </g>
955
1649
 
@@ -999,51 +1693,33 @@ $effect(() => {
999
1693
  ? `rotate(${rotation}, ${shift_x}, ${shift_y})`
1000
1694
  : undefined}
1001
1695
  >
1002
- {format_value(tick, y2_axis.format)}
1696
+ {
1697
+ get_tick_label(tick as number, y2_axis.ticks) ??
1698
+ format_value(tick, y2_axis.format)
1699
+ }
1003
1700
  </text>
1004
1701
  </g>
1005
1702
  {/if}
1006
1703
  {/each}
1007
1704
  {#if y2_axis.label || y2_axis.options?.length}
1008
- {@const max_y2_tick_width = Math.max(
1009
- 0,
1010
- ...ticks.y2.map((tick) =>
1011
- measure_text_width(
1012
- format_value(tick, y2_axis.format),
1013
- `12px sans-serif`,
1014
- )
1015
- ),
1016
- )}
1017
- {@const shift_x = y2_axis.label_shift?.x ?? 0}
1018
- {@const shift_y = y2_axis.label_shift?.y ?? 0}
1019
- {@const inside = y2_axis.tick?.label?.inside ?? false}
1020
- {@const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8}
1021
- {@const tick_width_contribution = inside ? 0 : max_y2_tick_width}
1022
- {@const y2_label_x = width - pad.r + tick_shift + tick_width_contribution +
1023
- LABEL_GAP_DEFAULT +
1024
- shift_x}
1025
- {@const y2_label_y = pad.t + chart_height / 2 + shift_y}
1026
- <foreignObject
1027
- x={y2_label_x - AXIS_LABEL_CONTAINER.x_offset}
1028
- y={y2_label_y - AXIS_LABEL_CONTAINER.y_offset}
1029
- width={AXIS_LABEL_CONTAINER.width}
1030
- height={AXIS_LABEL_CONTAINER.height}
1031
- style="overflow: visible; pointer-events: none"
1032
- transform="rotate(-90, {y2_label_x}, {y2_label_y})"
1033
- >
1034
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
1035
- <InteractiveAxisLabel
1036
- label={y2_axis.label ?? ``}
1037
- options={y2_axis.options}
1038
- selected_key={y2_axis.selected_key}
1039
- loading={axis_loading === `y2`}
1040
- axis_type="y2"
1041
- color={y2_axis.color}
1042
- on_select={(key) => handle_axis_change(`y2`, key)}
1043
- class="axis-label y2-label"
1044
- />
1045
- </div>
1046
- </foreignObject>
1705
+ {@const { label_shift, label = ``, options, selected_key, color, tick } =
1706
+ y2_axis}
1707
+ {@const inside = tick?.label?.inside ?? false}
1708
+ {@const tick_shift = inside ? 0 : (tick?.label?.shift?.x ?? 0) + 8}
1709
+ {@const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max}
1710
+ <AxisLabel
1711
+ x={width - pad.r + tick_shift + tick_width_contribution +
1712
+ LABEL_GAP_DEFAULT + (label_shift?.x ?? 0)}
1713
+ y={pad.t + chart_height / 2 + (label_shift?.y ?? 0)}
1714
+ rotate
1715
+ {label}
1716
+ {options}
1717
+ {selected_key}
1718
+ loading={axis_loading === `y2`}
1719
+ axis_type="y2"
1720
+ {color}
1721
+ on_select={(key) => handle_axis_change(`y2`, key)}
1722
+ />
1047
1723
  {/if}
1048
1724
  </g>
1049
1725
  {/if}
@@ -1057,43 +1733,34 @@ $effect(() => {
1057
1733
 
1058
1734
  <!-- Clipped content: zero lines, bars, and lines -->
1059
1735
  <g clip-path="url(#{clip_path_id})">
1060
- <!-- Zero lines (shown for linear and arcsinh scales, not log) -->
1061
- {#if display.x_zero_line &&
1062
- get_scale_type_name(x_axis.scale_type) !== `log` &&
1063
- ranges.current.x[0] <= 0 && ranges.current.x[1] >= 0}
1064
- {@const zx = scales.x(0)}
1065
- {#if isFinite(zx)}
1066
- <line class="zero-line" x1={zx} x2={zx} y1={pad.t} y2={height - pad.b} />
1067
- {/if}
1068
- {/if}
1069
- {#if display.y_zero_line &&
1070
- get_scale_type_name(y_axis.scale_type) !== `log` &&
1071
- ranges.current.y[0] <= 0 && ranges.current.y[1] >= 0}
1072
- {@const zy = scales.y(0)}
1073
- {#if isFinite(zy)}
1074
- <line class="zero-line" x1={pad.l} x2={width - pad.r} y1={zy} y2={zy} />
1075
- {/if}
1076
- {/if}
1077
- {#if display.y_zero_line && y2_series.length > 0 &&
1078
- get_scale_type_name(y2_axis.scale_type) !== `log` &&
1079
- ranges.current.y2[0] <= 0 && ranges.current.y2[1] >= 0}
1080
- {@const zero_y2 = scales.y2(0)}
1081
- {#if isFinite(zero_y2)}
1082
- <line
1083
- class="zero-line"
1084
- x1={pad.l}
1085
- x2={width - pad.r}
1086
- y1={zero_y2}
1087
- y2={zero_y2}
1088
- />
1089
- {/if}
1090
- {/if}
1736
+ <ZeroLines
1737
+ {display}
1738
+ x_scale_fn={scales.x}
1739
+ x2_scale_fn={scales.x2}
1740
+ y_scale_fn={scales.y}
1741
+ y2_scale_fn={scales.y2}
1742
+ x_range={ranges.current.x}
1743
+ x2_range={ranges.current.x2}
1744
+ y_range={ranges.current.y}
1745
+ y2_range={ranges.current.y2}
1746
+ x_scale_type={x_axis.scale_type}
1747
+ x2_scale_type={x2_axis.scale_type}
1748
+ y_scale_type={y_axis.scale_type}
1749
+ y2_scale_type={y2_axis.scale_type}
1750
+ x_is_time={x_axis.format?.startsWith(`%`) ?? false}
1751
+ x2_is_time={x2_axis.format?.startsWith(`%`) ?? false}
1752
+ has_x2={x2_series.length > 0}
1753
+ has_y2={y2_series.length > 0}
1754
+ {width}
1755
+ {height}
1756
+ {pad}
1757
+ />
1091
1758
 
1092
1759
  <!-- Reference lines: below lines -->
1093
1760
  {@render ref_lines_layer(ref_lines_by_z.below_lines)}
1094
1761
 
1095
1762
  <!-- Bars and Lines -->
1096
- {#each series as srs, series_idx (srs?.id ?? series_idx)}
1763
+ {#each internal_series as srs, series_idx (srs?.id ?? series_idx)}
1097
1764
  {#if srs?.visible ?? true}
1098
1765
  {@const is_line = srs.render_mode === `line`}
1099
1766
  <g
@@ -1107,6 +1774,8 @@ $effect(() => {
1107
1774
  {@const line_dash = srs.line_style?.line_dash ?? `none`}
1108
1775
  {@const use_y2 = srs.y_axis === `y2`}
1109
1776
  {@const y_scale = use_y2 ? scales.y2 : scales.y}
1777
+ {@const use_x2 = srs.x_axis === `x2`}
1778
+ {@const x_scale = use_x2 ? scales.x2 : scales.x}
1110
1779
  {@const series_markers = srs.markers ?? DEFAULT_MARKERS}
1111
1780
  {@const show_line = series_markers === `line` ||
1112
1781
  series_markers === `line+points`}
@@ -1116,8 +1785,8 @@ $effect(() => {
1116
1785
  const y_val = srs.y[idx]
1117
1786
  // Lines don't stack - they show absolute values (useful for totals/trends)
1118
1787
  const plot_x = orientation === `vertical`
1119
- ? scales.x(x_val)
1120
- : scales.x(y_val)
1788
+ ? x_scale(x_val)
1789
+ : x_scale(y_val)
1121
1790
  const plot_y = orientation === `vertical`
1122
1791
  ? y_scale(y_val)
1123
1792
  : scales.y(x_val)
@@ -1148,9 +1817,12 @@ $effect(() => {
1148
1817
  point_idx: idx,
1149
1818
  } as LineSeriesPoint
1150
1819
  }).filter((pt) => isFinite(pt.x) && isFinite(pt.y))}
1151
- {#if show_line && points.length > 1}
1820
+ {@const polyline_str = show_line && points.length > 1
1821
+ ? points.map((pt) => `${pt.x},${pt.y}`).join(` `)
1822
+ : ``}
1823
+ {#if polyline_str}
1152
1824
  <polyline
1153
- points={points.map((pt) => `${pt.x},${pt.y}`).join(` `)}
1825
+ points={polyline_str}
1154
1826
  fill="none"
1155
1827
  stroke={color}
1156
1828
  stroke-width={stroke_width}
@@ -1159,41 +1831,58 @@ $effect(() => {
1159
1831
  stroke-linecap="round"
1160
1832
  />
1161
1833
  {/if}
1162
- <!-- Add invisible wider line for easier hovering (only if no points shown) -->
1163
- {#if show_line && !show_points && points.length > 1 &&
1164
- (on_bar_hover || on_bar_click)}
1834
+ {#if polyline_str && !show_points && (on_bar_hover || on_bar_click)}
1835
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1836
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1165
1837
  <polyline
1166
- points={points.map((pt) => `${pt.x},${pt.y}`).join(` `)}
1838
+ points={polyline_str}
1167
1839
  fill="none"
1168
1840
  stroke="transparent"
1169
1841
  stroke-width={Math.max(10, stroke_width * 3)}
1170
1842
  stroke-linejoin="round"
1171
1843
  stroke-linecap="round"
1172
1844
  style:cursor={on_bar_click ? `pointer` : undefined}
1845
+ onmousemove={(evt) => {
1846
+ const pt = find_closest_point(evt, points)
1847
+ if (!pt) return
1848
+ hovered = true
1849
+ const fill = line_point_fill(pt, color)
1850
+ hover_info = get_bar_data(series_idx, pt.idx, fill)
1851
+ change(hover_info)
1852
+ on_bar_hover?.({ ...hover_info!, event: evt })
1853
+ }}
1854
+ onmouseleave={() => {
1855
+ change(null)
1856
+ hover_info = null
1857
+ on_bar_hover?.(null)
1858
+ }}
1859
+ onclick={(evt) => {
1860
+ const pt = find_closest_point(evt, points)
1861
+ if (!pt) return
1862
+ const fill = line_point_fill(pt, color)
1863
+ const bar_data = get_bar_data(series_idx, pt.idx, fill)
1864
+ on_bar_click?.({ ...bar_data, event: evt })
1865
+ }}
1173
1866
  />
1174
1867
  {/if}
1175
1868
  {#if show_points}
1176
1869
  {@const clickable = on_bar_click || on_point_click}
1177
- {@const get_pt = (evt: Event) =>
1178
- points.find((pt) =>
1179
- pt.idx ===
1180
- parseInt(
1181
- (evt.target as Element)?.closest(`[data-bar-idx]`)
1182
- ?.getAttribute(`data-bar-idx`) ?? ``,
1183
- 10,
1184
- )
1185
- )}
1186
- {@const fill = (pt: LineSeriesPoint) =>
1187
- pt.color_value != null
1188
- ? color_scale_fn(pt.color_value)
1189
- : pt.point_style?.fill ?? color}
1870
+ {@const get_pt = (evt: Event) => {
1871
+ const attr = evt.target instanceof Element
1872
+ ? evt.target.closest(`[data-bar-idx]`)?.getAttribute(
1873
+ `data-bar-idx`,
1874
+ )
1875
+ : null
1876
+ return points.find((pt) => pt.idx === parseInt(attr ?? ``, 10))
1877
+ }}
1190
1878
  {@const set_hover = (
1191
1879
  pt: LineSeriesPoint | null,
1192
1880
  evt: MouseEvent | FocusEvent,
1193
1881
  ) => {
1194
1882
  if (pt) {
1195
1883
  hovered = true
1196
- hover_info = get_bar_data(series_idx, pt.idx, fill(pt))
1884
+ const fill = line_point_fill(pt, color)
1885
+ hover_info = get_bar_data(series_idx, pt.idx, fill)
1197
1886
  change(hover_info)
1198
1887
  } else {
1199
1888
  change(null)
@@ -1208,13 +1897,15 @@ $effect(() => {
1208
1897
  pt: LineSeriesPoint,
1209
1898
  evt: MouseEvent | KeyboardEvent,
1210
1899
  ) => {
1211
- const bar_data = get_bar_data(series_idx, pt.idx, fill(pt))
1900
+ const fill = line_point_fill(pt, color)
1901
+ const bar_data = get_bar_data(series_idx, pt.idx, fill)
1212
1902
  on_bar_click?.({ ...bar_data, event: evt })
1213
1903
  on_point_click?.({ ...bar_data, event: evt, point: pt })
1214
1904
  }}
1215
1905
  {@const leaving = (evt: MouseEvent | FocusEvent) =>
1216
- (evt.relatedTarget as Element)?.closest(`.line-points`) !==
1217
- evt.currentTarget}
1906
+ (evt.relatedTarget instanceof Element
1907
+ ? evt.relatedTarget.closest(`.line-points`)
1908
+ : null) !== evt.currentTarget}
1218
1909
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_mouse_events_have_key_events -->
1219
1910
  <g
1220
1911
  class="line-points"
@@ -1247,7 +1938,7 @@ $effect(() => {
1247
1938
  >
1248
1939
  {#each points as pt (pt.idx)}
1249
1940
  {@const sty = pt.point_style}
1250
- {@const fl = fill(pt)}
1941
+ {@const fl = line_point_fill(pt, color)}
1251
1942
  {@const rad = pt.size_value != null
1252
1943
  ? size_scale_fn(pt.size_value)
1253
1944
  : sty?.radius ?? 4}
@@ -1306,9 +1997,11 @@ $effect(() => {
1306
1997
  {@const val = y_val}
1307
1998
  {@const use_y2 = srs.y_axis === `y2`}
1308
1999
  {@const y_scale = use_y2 ? scales.y2 : scales.y}
2000
+ {@const use_x2_bar = srs.x_axis === `x2`}
2001
+ {@const x_scale_bar = use_x2_bar ? scales.x2 : scales.x}
1309
2002
  {@const [cat_scale, val_scale] = is_vertical
1310
- ? [scales.x, y_scale]
1311
- : [scales.y, scales.x]}
2003
+ ? [x_scale_bar, y_scale]
2004
+ : [scales.y, x_scale_bar]}
1312
2005
  {@const c0 = cat_scale(cat_val + group_offset - half)}
1313
2006
  {@const c1 = cat_scale(cat_val + group_offset + half)}
1314
2007
  {@const v0 = val_scale(base)}
@@ -1405,7 +2098,9 @@ $effect(() => {
1405
2098
  {/if}
1406
2099
 
1407
2100
  {#if hover_info && hovered}
1408
- {@const cx = scales.x(hover_info.orient_x)}
2101
+ {@const cx = (hover_info.active_x_axis === `x2` ? scales.x2 : scales.x)(
2102
+ hover_info.orient_x,
2103
+ )}
1409
2104
  {@const cy = (hover_info.active_y_axis === `y2` ? scales.y2 : scales.y)(
1410
2105
  hover_info.orient_y,
1411
2106
  )}
@@ -1418,7 +2113,6 @@ $effect(() => {
1418
2113
  height,
1419
2114
  { offset_x: 10, offset_y: 5 },
1420
2115
  )}
1421
- {@const active_y_config = hover_info.active_y_axis === `y2` ? y2_axis : y_axis}
1422
2116
  <PlotTooltip
1423
2117
  x={tooltip_pos.x}
1424
2118
  y={tooltip_pos.y}
@@ -1429,14 +2123,20 @@ $effect(() => {
1429
2123
  {#if tooltip}
1430
2124
  {@render tooltip({ ...hover_info, fullscreen })}
1431
2125
  {:else}
2126
+ {@const series_label = series[hover_info.series_idx]?.label}
2127
+ {#if series.length > 1 && series_label}
2128
+ <div><strong>{series_label}</strong></div>
2129
+ {/if}
1432
2130
  <div>
1433
- {@html x_axis.label || `x`}: {
1434
- format_value(hover_info.orient_x, x_axis.format || `.3~s`)
2131
+ {@html sanitize_html(hover_info.x_axis.label || `x`)}: {
2132
+ (cat_axis === `x` ? hover_info.category_label : undefined) ??
2133
+ format_value(hover_info.orient_x, hover_info.x_axis.format || `.3~s`)
1435
2134
  }
1436
2135
  </div>
1437
2136
  <div>
1438
- {@html active_y_config.label || `y`}: {
1439
- format_value(hover_info.orient_y, active_y_config.format || `.3~s`)
2137
+ {@html sanitize_html(hover_info.y_axis.label || `y`)}: {
2138
+ (cat_axis === `y` ? hover_info.category_label : undefined) ??
2139
+ format_value(hover_info.orient_y, hover_info.y_axis.format || `.3~s`)
1440
2140
  }
1441
2141
  </div>
1442
2142
  {/if}
@@ -1447,7 +2147,7 @@ $effect(() => {
1447
2147
  <BarPlotControls
1448
2148
  toggle_props={{
1449
2149
  ...controls_toggle_props,
1450
- style: `--ctrl-btn-right: var(--fullscreen-btn-offset, 36px); top: 4px; ${
2150
+ style: `--ctrl-btn-right: var(--fullscreen-btn-offset, 30px); ${
1451
2151
  controls_toggle_props?.style ?? ``
1452
2152
  }`,
1453
2153
  }}
@@ -1457,12 +2157,16 @@ $effect(() => {
1457
2157
  bind:orientation
1458
2158
  bind:mode
1459
2159
  bind:x_axis
2160
+ bind:x2_axis
1460
2161
  bind:y_axis
1461
2162
  bind:y2_axis
1462
2163
  bind:display
1463
2164
  auto_x_range={auto_ranges.x as Vec2}
2165
+ auto_x2_range={auto_ranges.x2 as Vec2}
1464
2166
  auto_y_range={auto_ranges.y as Vec2}
1465
2167
  auto_y2_range={auto_ranges.y2 as Vec2}
2168
+ has_x2_points={x2_series.length > 0}
2169
+ has_y2_points={y2_series.length > 0}
1466
2170
  children={controls_extra}
1467
2171
  />
1468
2172
  {/if}
@@ -1542,22 +2246,11 @@ $effect(() => {
1542
2246
  border: var(--barplot-dragover-border, var(--dragover-border));
1543
2247
  background-color: var(--barplot-dragover-bg, var(--dragover-bg));
1544
2248
  }
1545
- g:is(.x-axis, .y-axis, .y2-axis) .tick text {
2249
+ g:is(.x-axis, .x2-axis, .y-axis, .y2-axis) .tick text {
1546
2250
  font-size: var(--tick-font-size, 0.8em);
1547
2251
  }
1548
- .zoom-rect {
1549
- fill: var(--barplot-zoom-rect-fill, rgba(100, 100, 255, 0.2));
1550
- stroke: var(--barplot-zoom-rect-stroke, rgba(100, 100, 255, 0.8));
1551
- stroke-width: var(--barplot-zoom-rect-stroke-width, 1);
1552
- pointer-events: none;
1553
- }
1554
2252
  .bar-label {
1555
2253
  fill: var(--text-color);
1556
2254
  font-size: 11px;
1557
2255
  }
1558
- .zero-line {
1559
- stroke: var(--barplot-zero-line-color, light-dark(black, white));
1560
- stroke-width: var(--barplot-zero-line-width, 1);
1561
- opacity: var(--barplot-zero-line-opacity, 0.3);
1562
- }
1563
2256
  </style>