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,1161 +1,1869 @@
1
1
  <script
2
2
  lang="ts"
3
3
  generics="Metadata extends Record<string, unknown> = Record<string, unknown>"
4
- >import { format_value, symbol_names } from '../labels';
5
- import { FullscreenToggle, set_fullscreen_bg } from '../layout';
6
- import { ColorBar, compute_element_placement, FillArea, get_tick_label, InteractiveAxisLabel, Line, PlotLegend, PlotTooltip, ReferenceLine, ScatterPlotControls, ScatterPoint, } from './';
7
- import { AXIS_LABEL_CONTAINER, create_axis_change_handler, } from './axis-utils';
8
- import { get_series_color, get_series_symbol, process_prop, } from './data-transform';
9
- import { AXIS_DEFAULTS } from './defaults';
10
- import { untrack } from 'svelte';
11
- import { create_dimension_tracker, create_hover_lock, } from './hover-lock.svelte';
12
- import { DEFAULT_GRID_STYLE, DEFAULT_MARKERS, get_scale_type_name, } from './types';
13
- import { compute_label_positions } from './utils/label-placement';
14
- import { handle_legend_double_click, toggle_group_visibility, toggle_series_visibility, } from './utils/series-visibility';
15
- import { DEFAULTS } from '../settings';
16
- import { extent } from 'd3-array';
17
- import { scaleTime } from 'd3-scale';
18
- import { Tween } from 'svelte/motion';
19
- import { SvelteSet } from 'svelte/reactivity';
20
- import { apply_range_constraints, apply_where_condition, clamp_for_log_scale, convert_error_band_to_fill_region, generate_fill_path, is_fill_gradient, resolve_boundary, } from './fill-utils';
21
- import { expand_range_if_needed, get_relative_coords, normalize_y2_sync, pan_range, PINCH_ZOOM_THRESHOLD, pixels_to_data_delta, sync_y2_range, } from './interactions';
22
- import { calc_auto_padding, constrain_tooltip_position, filter_padding, } from './layout';
23
- import { group_ref_lines_by_z, index_ref_lines } from './reference-line';
24
- import { create_color_scale, create_scale, create_size_scale, generate_ticks, get_nice_data_range, } from './scales';
25
- let { series = $bindable([]), x_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.scatter.display), styles: styles_init = {}, controls: controls_init = {}, padding = {}, range_padding = 0.05, current_x_value = null, tooltip_point = $bindable(null), selected_point = null, hovered = $bindable(false), tooltip, user_content, change = () => { }, color_scale = {
26
- type: `linear`,
27
- scheme: `interpolateViridis`,
28
- value_range: undefined,
29
- }, color_bar = {}, size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, label_placement_config = {}, hover_config = {}, legend = {}, point_tween, line_tween, point_events, on_point_click, on_point_hover, fill_regions = $bindable([]), error_bands = [], on_fill_click, on_fill_hover, ref_lines = $bindable([]), on_ref_line_click, on_ref_line_hover, selected_series_idx = $bindable(0), wrapper = $bindable(), fullscreen = $bindable(false), fullscreen_toggle = true, children, header_controls, controls_extra, data_loader, on_axis_change, on_error, pan = {}, ...rest } = $props();
30
- // Merged axis/display values with defaults (use $derived to avoid breaking $bindable)
31
- const final_x_axis = $derived({
4
+ >
5
+ import type { D3ColorSchemeName, D3InterpolateName } from '../colors'
6
+ import type { D3SymbolName } from '../labels'
7
+ import { format_value, symbol_names } from '../labels'
8
+ import { sanitize_html } from '../sanitize'
9
+ import { FullscreenToggle, set_fullscreen_bg } from '../layout'
10
+ import type { Vec2 } from '../math'
11
+ import type {
12
+ AxisLoadError,
13
+ BasePlotProps,
14
+ ControlsConfig,
15
+ DataLoaderFn,
16
+ DataSeries,
17
+ ErrorBand,
18
+ FillHandlerEvent,
19
+ FillRegion,
20
+ HoverConfig,
21
+ InitialRanges,
22
+ InternalPoint,
23
+ LabelPlacementConfig,
24
+ LegendConfig,
25
+ PanConfig,
26
+ PlotConfig,
27
+ Point,
28
+ RefLine,
29
+ RefLineEvent,
30
+ ScaleType,
31
+ ScatterHandlerEvent,
32
+ ScatterHandlerProps,
33
+ Sides,
34
+ StyleOverrides,
35
+ TweenedOptions,
36
+ UserContentProps,
37
+ XyObj,
38
+ } from './'
39
+ import {
40
+ AxisLabel,
41
+ ColorBar,
42
+ compute_element_placement,
43
+ FillArea,
44
+ get_tick_label,
45
+ Line,
46
+ PlotLegend,
47
+ PlotTooltip,
48
+ ReferenceLine,
49
+ ScatterPlotControls,
50
+ ScatterPoint,
51
+ ZeroLines,
52
+ ZoomRect,
53
+ } from './'
54
+ import type { AxisChangeState } from './axis-utils'
55
+ import { create_axis_change_handler } from './axis-utils'
56
+ import {
57
+ get_series_color,
58
+ get_series_symbol,
59
+ process_prop,
60
+ } from './data-transform'
61
+ import { AXIS_DEFAULTS } from './defaults'
62
+ import {
63
+ create_dimension_tracker,
64
+ create_hover_lock,
65
+ } from './hover-lock.svelte'
66
+ import {
67
+ DEFAULT_GRID_STYLE,
68
+ DEFAULT_MARKERS,
69
+ get_scale_type_name,
70
+ is_time_scale,
71
+ } from './types'
72
+ import { compute_label_positions } from './utils/label-placement'
73
+ import {
74
+ handle_legend_double_click,
75
+ toggle_group_visibility,
76
+ toggle_series_visibility,
77
+ } from './utils/series-visibility'
78
+ import { DEFAULTS } from '../settings'
79
+ import { extent } from 'd3-array'
80
+ import { scaleTime } from 'd3-scale'
81
+ import type { ComponentProps, Snippet } from 'svelte'
82
+ import { untrack } from 'svelte'
83
+ import type { HTMLAttributes } from 'svelte/elements'
84
+ import { Tween } from 'svelte/motion'
85
+ import { SvelteSet } from 'svelte/reactivity'
86
+ import type { FillPathPoint } from './fill-utils'
87
+ import {
88
+ apply_range_constraints,
89
+ apply_where_condition,
90
+ clamp_for_log_scale,
91
+ convert_error_band_to_fill_region,
92
+ generate_fill_path,
93
+ is_fill_gradient,
94
+ resolve_boundary,
95
+ } from './fill-utils'
96
+ import {
97
+ expand_range_if_needed,
98
+ get_relative_coords,
99
+ normalize_y2_sync,
100
+ pan_range,
101
+ PINCH_ZOOM_THRESHOLD,
102
+ pixels_to_data_delta,
103
+ sync_y2_range,
104
+ } from './interactions'
105
+ import type { Rect } from './layout'
106
+ import {
107
+ calc_auto_padding,
108
+ constrain_tooltip_position,
109
+ filter_padding,
110
+ LABEL_GAP_DEFAULT,
111
+ measure_max_tick_width,
112
+ } from './layout'
113
+ import type { IndexedRefLine } from './reference-line'
114
+ import { group_ref_lines_by_z, index_ref_lines } from './reference-line'
115
+ import {
116
+ create_color_scale,
117
+ create_scale,
118
+ create_size_scale,
119
+ generate_ticks,
120
+ get_nice_data_range,
121
+ } from './scales'
122
+
123
+ let {
124
+ series = $bindable([]),
125
+ x_axis = $bindable({}),
126
+ x2_axis = $bindable({}),
127
+ y_axis = $bindable({}),
128
+ y2_axis = $bindable({}),
129
+ display = $bindable(DEFAULTS.scatter.display),
130
+ styles: styles_init = {},
131
+ controls: controls_init = {},
132
+ padding = {},
133
+ range_padding = 0.05,
134
+ current_x_value = null,
135
+ tooltip_point = $bindable(null),
136
+ selected_point = null,
137
+ hovered = $bindable(false),
138
+ tooltip,
139
+ user_content,
140
+ change = () => {},
141
+ color_scale = {
142
+ type: `linear`,
143
+ scheme: `interpolateViridis`,
144
+ value_range: undefined,
145
+ },
146
+ color_bar = {},
147
+ size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined },
148
+ label_placement_config = {},
149
+ hover_config = {},
150
+ legend = {},
151
+ point_tween,
152
+ line_tween,
153
+ point_events,
154
+ on_point_click,
155
+ on_point_hover,
156
+ fill_regions = $bindable([]),
157
+ error_bands = [],
158
+ on_fill_click,
159
+ on_fill_hover,
160
+ ref_lines = $bindable([]),
161
+ on_ref_line_click,
162
+ on_ref_line_hover,
163
+ selected_series_idx = $bindable(0),
164
+ wrapper = $bindable(),
165
+ fullscreen = $bindable(false),
166
+ fullscreen_toggle = true,
167
+ children,
168
+ header_controls,
169
+ controls_extra,
170
+ data_loader,
171
+ on_axis_change,
172
+ on_error,
173
+ pan = {},
174
+ ...rest
175
+ }: HTMLAttributes<HTMLDivElement> & Omit<BasePlotProps, `change`> & PlotConfig & {
176
+ series?: DataSeries<Metadata>[]
177
+ styles?: StyleOverrides
178
+ controls?: ControlsConfig
179
+ current_x_value?: number | null
180
+ tooltip_point?: InternalPoint<Metadata> | null
181
+ selected_point?: { series_idx: number; point_idx: number } | null
182
+ tooltip?: Snippet<[ScatterHandlerProps<Metadata>]>
183
+ user_content?: Snippet<[UserContentProps]>
184
+ header_controls?: Snippet<
185
+ [{ height: number; width: number; fullscreen: boolean }]
186
+ >
187
+ controls_extra?: Snippet<
188
+ [
189
+ & { styles: StyleOverrides; selected_series_idx: number }
190
+ & Required<PlotConfig>,
191
+ ]
192
+ >
193
+ change?: (
194
+ data: (Point<Metadata> & { series: DataSeries<Metadata> }) | null,
195
+ ) => void
196
+ color_scale?: {
197
+ type?: ScaleType
198
+ scheme?: D3ColorSchemeName | D3InterpolateName
199
+ value_range?: [number, number]
200
+ } | D3InterpolateName
201
+ size_scale?: {
202
+ type?: ScaleType
203
+ radius_range?: [number, number]
204
+ value_range?: [number, number]
205
+ }
206
+ color_bar?:
207
+ | (ComponentProps<typeof ColorBar> & {
208
+ margin?: number | Sides
209
+ tween?: TweenedOptions<XyObj>
210
+ responsive?: boolean // Allow colorbar to reposition if density changes (default: false)
211
+ })
212
+ | null
213
+ label_placement_config?: Partial<LabelPlacementConfig>
214
+ hover_config?: Partial<HoverConfig>
215
+ legend?: LegendConfig | null
216
+ point_tween?: TweenedOptions<XyObj>
217
+ line_tween?: TweenedOptions<string>
218
+ point_events?: Record<
219
+ string,
220
+ (payload: { point: InternalPoint<Metadata>; event: Event }) => void
221
+ >
222
+ on_point_click?: (data: ScatterHandlerEvent<Metadata>) => void
223
+ on_point_hover?: (data: ScatterHandlerEvent<Metadata> | null) => void
224
+ fill_regions?: FillRegion[] // Bindable for legend toggle support
225
+ error_bands?: ErrorBand[]
226
+ on_fill_click?: (event: FillHandlerEvent) => void
227
+ on_fill_hover?: (event: FillHandlerEvent | null) => void
228
+ ref_lines?: RefLine[] // Bindable for legend toggle support
229
+ on_ref_line_click?: (event: RefLineEvent) => void
230
+ on_ref_line_hover?: (event: RefLineEvent | null) => void
231
+ selected_series_idx?: number
232
+ wrapper?: HTMLDivElement
233
+ // Interactive axis props
234
+ data_loader?: DataLoaderFn<Metadata>
235
+ on_axis_change?: (
236
+ axis: `x` | `x2` | `y` | `y2`,
237
+ key: string,
238
+ new_series: DataSeries<Metadata>[],
239
+ ) => void
240
+ on_error?: (error: AxisLoadError) => void
241
+ pan?: PanConfig
242
+ } = $props()
243
+
244
+ // Merged axis/display values with defaults (use $derived to avoid breaking $bindable)
245
+ const final_x_axis = $derived({
32
246
  ...AXIS_DEFAULTS,
33
247
  label_shift: { x: 0, y: -40 }, // x-axis needs different label position
34
248
  ...(x_axis ?? {}),
35
- });
36
- const final_y_axis = $derived({ ...AXIS_DEFAULTS, ...(y_axis ?? {}) });
37
- const final_y2_axis = $derived({ ...AXIS_DEFAULTS, ...(y2_axis ?? {}) });
38
- const final_display = $derived({ ...DEFAULTS.scatter.display, ...(display ?? {}) });
39
- // Local state for styles (initialized from prop, owned by this component for controls)
40
- // Using $state because styles has bindings in ScatterPlotControls
41
- // untrack() explicitly captures initial prop value (intentional - props provide initial config)
42
- let styles = $state(untrack(() => ({
249
+ })
250
+ const final_y_axis = $derived({ ...AXIS_DEFAULTS, ...(y_axis ?? {}) })
251
+ const final_x2_axis = $derived({
252
+ ...AXIS_DEFAULTS,
253
+ label_shift: { x: 0, y: 40 }, // x2-axis label above top edge
254
+ ...(x2_axis ?? {}),
255
+ })
256
+ const final_y2_axis = $derived({ ...AXIS_DEFAULTS, ...(y2_axis ?? {}) })
257
+ // Cache time-axis check — used in ~10 places for scale/tick/tooltip logic
258
+ let is_time_x = $derived(
259
+ is_time_scale(final_x_axis.scale_type, final_x_axis.format),
260
+ )
261
+ let is_time_x2 = $derived(
262
+ is_time_scale(final_x2_axis.scale_type, final_x2_axis.format),
263
+ )
264
+ const final_display = $derived({ ...DEFAULTS.scatter.display, ...(display ?? {}) })
265
+ // Local state for styles (initialized from prop, owned by this component for controls)
266
+ // Using $state because styles has bindings in ScatterPlotControls
267
+ // untrack() explicitly captures initial prop value (intentional - props provide initial config)
268
+ let styles = $state(untrack(() => ({
43
269
  show_points: DEFAULTS.scatter.show_points,
44
270
  show_lines: DEFAULTS.scatter.show_lines,
45
271
  point: { ...DEFAULTS.scatter.point, ...(styles_init?.point ?? {}) },
46
272
  line: { ...DEFAULTS.scatter.line, ...(styles_init?.line ?? {}) },
47
273
  ...(styles_init ?? {}),
48
- })));
49
- let controls = $derived({ show: true, open: false, ...controls_init });
50
- let [width, height] = $state([0, 0]);
51
- let svg_element = $state(null); // Bind the SVG element
52
- let svg_bounding_box = $state(null); // Store SVG bounds during drag
53
- // Track which specific control properties user has modified
54
- let touched = new SvelteSet();
55
- // Unique component ID to avoid clipPath conflicts between multiple instances
56
- let component_id = $state(`scatter-${crypto.randomUUID()}`);
57
- let clip_path_id = $derived(`plot-area-clip-${component_id}`);
58
- // Assign stable IDs to series for keying
59
- let series_with_ids = $derived(series.map((srs, idx) => {
60
- if (!srs || typeof srs !== `object`)
61
- return srs;
62
- // Use series.id if provided, otherwise fall back to index
63
- // prevents re-mounts when series are reordered if stable IDs are provided
64
- return { ...srs, _id: srs.id ?? idx };
65
- }));
66
- // State for rectangle zoom selection
67
- let drag_start_coords = $state(null);
68
- let drag_current_coords = $state(null);
69
- // Zoom/pan state - track both initial (data-driven) and current (after pan/zoom) ranges
70
- let initial_x_range = $state([0, 1]);
71
- let initial_y_range = $state([0, 1]);
72
- let initial_y2_range = $state([0, 1]);
73
- let zoom_x_range = $state([0, 1]);
74
- let zoom_y_range = $state([0, 1]);
75
- let zoom_y2_range = $state([0, 1]);
76
- let previous_series_visibility = $state(null);
77
- // Y2 axis sync configuration
78
- let y2_sync_config = $derived(normalize_y2_sync(y2_axis?.sync));
79
- // Track previous sync mode to detect changes (updated in $effect.pre to avoid race conditions)
80
- let prev_sync_mode = $state(`none`);
81
- // Helper to compute synced y2 range or return fallback when sync disabled
82
- const get_synced_y2 = (y1_range, fallback) => y2_sync_config.mode !== `none`
83
- ? sync_y2_range(y1_range, initial_y2_range, y2_sync_config)
84
- : fallback;
85
- // Effect to update y2 range when sync mode changes - use $effect.pre to capture
86
- // mode change before the main range-update effect runs, ensuring sync is applied
87
- // immediately when toggled (not delayed until next data change)
88
- $effect.pre(() => {
89
- const mode = y2_sync_config.mode;
274
+ })))
275
+ let controls = $derived({ show: true, open: false, ...controls_init })
276
+
277
+ let [width, height] = $state([0, 0])
278
+ let svg_element: SVGElement | null = $state(null) // Bind the SVG element
279
+ let svg_bounding_box: DOMRect | null = $state(null) // Store SVG bounds during drag
280
+
281
+ // Track which specific control properties user has modified
282
+ let touched = new SvelteSet<string>()
283
+
284
+ // Unique component ID to avoid clipPath conflicts between multiple instances
285
+ let component_id = $state(`scatter-${crypto.randomUUID()}`)
286
+ let clip_path_id = $derived(`plot-area-clip-${component_id}`)
287
+
288
+ // Assign stable IDs to series for keying
289
+ let series_with_ids = $derived(
290
+ series.map((srs: DataSeries<Metadata>, idx: number) => {
291
+ if (!srs || typeof srs !== `object`) return srs
292
+ // Use series.id if provided, otherwise fall back to index
293
+ // prevents re-mounts when series are reordered if stable IDs are provided
294
+ return { ...srs, _id: srs.id ?? idx }
295
+ }),
296
+ )
297
+
298
+ // State for rectangle zoom selection
299
+ let drag_start_coords = $state<XyObj | null>(null)
300
+ let drag_current_coords = $state<XyObj | null>(null)
301
+
302
+ // Zoom/pan state - track both initial (data-driven) and current (after pan/zoom) ranges
303
+ let initial_x_range = $state<[number, number]>([0, 1])
304
+ let initial_x2_range = $state<[number, number]>([0, 1])
305
+ let initial_y_range = $state<[number, number]>([0, 1])
306
+ let initial_y2_range = $state<[number, number]>([0, 1])
307
+ let zoom_x_range = $state<[number, number]>([0, 1])
308
+ let zoom_x2_range = $state<[number, number]>([0, 1])
309
+ let zoom_y_range = $state<[number, number]>([0, 1])
310
+ let zoom_y2_range = $state<[number, number]>([0, 1])
311
+ let previous_series_visibility: boolean[] | null = $state(null)
312
+
313
+ // Y2 axis sync configuration
314
+ let y2_sync_config = $derived(normalize_y2_sync(y2_axis?.sync))
315
+ // Track previous sync mode to detect changes (updated in $effect.pre to avoid race conditions)
316
+ let prev_sync_mode = $state<string>(`none`)
317
+
318
+ // Helper to compute synced y2 range or return fallback when sync disabled
319
+ const get_synced_y2 = (y1_range: Vec2, fallback: Vec2): Vec2 =>
320
+ y2_sync_config.mode !== `none`
321
+ ? sync_y2_range(y1_range, initial_y2_range, y2_sync_config)
322
+ : fallback
323
+
324
+ // Effect to update y2 range when sync mode changes - use $effect.pre to capture
325
+ // mode change before the main range-update effect runs, ensuring sync is applied
326
+ // immediately when toggled (not delayed until next data change)
327
+ $effect.pre(() => {
328
+ const mode = y2_sync_config.mode
90
329
  if (mode !== prev_sync_mode) {
91
- // When sync mode becomes enabled (or changes), apply sync immediately
92
- if (mode !== `none`) {
93
- zoom_y2_range = sync_y2_range(zoom_y_range, initial_y2_range, y2_sync_config);
94
- }
95
- else {
96
- // When switching to independent mode, reset Y2 to its data range
97
- zoom_y2_range = [...initial_y2_range];
98
- }
99
- prev_sync_mode = mode;
330
+ // When sync mode becomes enabled (or changes), apply sync immediately
331
+ if (mode !== `none`) {
332
+ zoom_y2_range = sync_y2_range(zoom_y_range, initial_y2_range, y2_sync_config)
333
+ } else {
334
+ // When switching to independent mode, reset Y2 to its data range
335
+ zoom_y2_range = [...initial_y2_range] as [number, number]
336
+ }
337
+ prev_sync_mode = mode
100
338
  }
101
- });
102
- // Pan state
103
- let is_focused = $state(false);
104
- let shift_held = $state(false);
105
- let pan_drag_state = $state(null);
106
- let touch_state = $state(null);
107
- // Fill region hover state
108
- let hovered_fill_idx = $state(null);
109
- // Reference line hover state
110
- let hovered_ref_line_idx = $state(null);
111
- // Interactive axis loading state
112
- let axis_loading = $state(null);
113
- // State to hold the calculated label positions after simulation
114
- let label_positions = $state({});
115
- // State for legend dragging
116
- let legend_is_dragging = $state(false);
117
- let legend_drag_offset = $state({ x: 0, y: 0 });
118
- let legend_manual_position = $state(null);
119
- // State for legend/colorbar placement stability
120
- let legend_element = $state();
121
- let colorbar_element = $state();
122
- const legend_hover = create_hover_lock();
123
- const colorbar_hover = create_hover_lock();
124
- const dim_tracker = create_dimension_tracker();
125
- let has_initial_legend_placement = $state(false);
126
- let has_initial_colorbar_placement = $state(false);
127
- // Clear pending hover lock timeouts on unmount
128
- $effect(() => () => {
129
- legend_hover.cleanup();
130
- colorbar_hover.cleanup();
131
- });
132
- // Tooltip element reference for dynamic sizing
133
- let tooltip_el = $state();
134
- let points_by_axis = $derived.by(() => {
135
- const all = [];
136
- const y1 = [];
137
- const y2 = [];
339
+ })
340
+
341
+ // Pan state
342
+ let is_focused = $state(false)
343
+ let shift_held = $state(false)
344
+ let pan_drag_state = $state<
345
+ InitialRanges & { start: { x: number; y: number } } | null
346
+ >(null)
347
+ let touch_state = $state<
348
+ InitialRanges & { start_touches: { x: number; y: number }[] } | null
349
+ >(null)
350
+
351
+ // Fill region hover state
352
+ let hovered_fill_idx = $state<number | null>(null)
353
+
354
+ // Reference line hover state
355
+ let hovered_ref_line_idx = $state<number | null>(null)
356
+
357
+ // Interactive axis loading state
358
+ let axis_loading = $state<`x` | `x2` | `y` | `y2` | null>(null)
359
+
360
+ // State to hold the calculated label positions after simulation
361
+ let label_positions = $state<Record<string, XyObj>>({})
362
+
363
+ // State for legend dragging
364
+ let legend_is_dragging = $state(false)
365
+ let legend_drag_offset = $state<{ x: number; y: number }>({ x: 0, y: 0 })
366
+ let legend_manual_position = $state<{ x: number; y: number } | null>(null)
367
+
368
+ // State for legend/colorbar placement stability
369
+ let legend_element = $state<HTMLDivElement | undefined>()
370
+ let colorbar_element = $state<HTMLDivElement | undefined>()
371
+ const legend_hover = create_hover_lock()
372
+ const colorbar_hover = create_hover_lock()
373
+ const dim_tracker = create_dimension_tracker()
374
+ let has_initial_legend_placement = $state(false)
375
+ let has_initial_colorbar_placement = $state(false)
376
+
377
+ // Clear pending hover lock timeouts on unmount
378
+ $effect(() => () => {
379
+ legend_hover.cleanup()
380
+ colorbar_hover.cleanup()
381
+ })
382
+
383
+ // Tooltip element reference for dynamic sizing
384
+ let tooltip_el = $state<HTMLDivElement | undefined>()
385
+
386
+ // Module-level constants to avoid repeated allocations
387
+ // Create and categorize points in a single pass (instead of 3 separate iterations)
388
+ type SimplePoint = { x: number; y: number }
389
+ let points_by_axis = $derived.by(() => {
390
+ const all: SimplePoint[] = []
391
+ const y1: SimplePoint[] = []
392
+ const y2: SimplePoint[] = []
393
+ const x2: SimplePoint[] = []
394
+
138
395
  for (const srs of series_with_ids) {
139
- if (!srs)
140
- continue;
141
- const { x: xs, y: ys, visible = true, y_axis = `y1` } = srs;
142
- for (let idx = 0; idx < xs.length; idx++) {
143
- const point = { x: xs[idx], y: ys[idx] };
144
- all.push(point);
145
- if (visible) {
146
- if (y_axis === `y2`)
147
- y2.push(point);
148
- else
149
- y1.push(point);
150
- }
396
+ if (!srs) continue
397
+ const { x: xs, y: ys, visible = true, y_axis = `y1`, x_axis: x_ax = `x1` } =
398
+ srs as DataSeries
399
+ for (let idx = 0; idx < xs.length; idx++) {
400
+ const point = { x: xs[idx], y: ys[idx] }
401
+ all.push(point)
402
+ if (visible) {
403
+ if (y_axis === `y2`) y2.push(point)
404
+ else y1.push(point)
405
+ if (x_ax === `x2`) x2.push(point)
151
406
  }
407
+ }
152
408
  }
153
- return { all, y1, y2 };
154
- });
155
- let all_points = $derived(points_by_axis.all);
156
- let y1_points = $derived(points_by_axis.y1);
157
- let y2_points = $derived(points_by_axis.y2);
158
- // Layout: dynamic padding based on tick label widths
159
- const default_padding = { t: 5, b: 50, l: 50, r: 20 };
160
- let pad = $derived(filter_padding(padding, default_padding));
161
- // Update padding when format or ticks change
162
- $effect(() => {
163
- const new_pad = width && height && (y_tick_values.length || y2_tick_values.length)
164
- ? calc_auto_padding({
165
- padding,
166
- default_padding,
167
- y_axis: { ...final_y_axis, tick_values: y_tick_values },
168
- y2_axis: { ...final_y2_axis, tick_values: y2_tick_values },
169
- })
170
- : filter_padding(padding, default_padding);
171
- if (JSON.stringify(pad) !== JSON.stringify(new_pad))
172
- pad = new_pad;
173
- });
174
- // Reactive clip area dimensions to ensure proper responsiveness
175
- let clip_area = $derived({
409
+ return { all, y1, y2, x2 }
410
+ })
411
+
412
+ let all_points = $derived(points_by_axis.all)
413
+ let y1_points = $derived(points_by_axis.y1)
414
+ let y2_points = $derived(points_by_axis.y2)
415
+ let x2_points = $derived(points_by_axis.x2)
416
+
417
+ // Layout: dynamic padding based on tick label widths
418
+ const default_padding = { t: 5, b: 50, l: 50, r: 20 }
419
+ let pad = $state(untrack(() => filter_padding(padding, default_padding)))
420
+
421
+ // Update padding when format or ticks change
422
+ $effect(() => {
423
+ const new_pad = width && height &&
424
+ (y_tick_values.length || y2_tick_values.length || x2_tick_values.length)
425
+ ? calc_auto_padding({
426
+ padding,
427
+ default_padding,
428
+ x2_axis: { ...final_x2_axis, tick_values: x2_tick_values },
429
+ y_axis: { ...final_y_axis, tick_values: y_tick_values },
430
+ y2_axis: { ...final_y2_axis, tick_values: y2_tick_values },
431
+ })
432
+ : filter_padding(padding, default_padding)
433
+
434
+ if (
435
+ pad.t !== new_pad.t ||
436
+ pad.b !== new_pad.b ||
437
+ pad.l !== new_pad.l ||
438
+ pad.r !== new_pad.r
439
+ ) pad = new_pad
440
+ })
441
+
442
+ // Reactive clip area dimensions to ensure proper responsiveness
443
+ let clip_area = $derived({
176
444
  x: pad.l || 0,
177
445
  y: pad.t || 0,
178
446
  width: isFinite(width - pad.l - pad.r) ? Math.max(1, width - pad.l - pad.r) : 1,
179
447
  height: isFinite(height - pad.t - pad.b)
180
- ? Math.max(1, height - pad.t - pad.b)
181
- : 1,
182
- });
183
- // Calculate plot area center coordinates
184
- let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2);
185
- let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2);
186
- // Extract color and size values in single pass (used for scale computations)
187
- let series_value_arrays = $derived.by(() => {
188
- const color_values = [];
189
- const size_values = [];
448
+ ? Math.max(1, height - pad.t - pad.b)
449
+ : 1,
450
+ })
451
+
452
+ // Calculate plot area center coordinates
453
+ let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2)
454
+ let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2)
455
+
456
+ // Extract color and size values in single pass (used for scale computations)
457
+ let series_value_arrays = $derived.by(() => {
458
+ const color_values: number[] = []
459
+ const size_values: number[] = []
190
460
  for (const srs of series_with_ids) {
191
- if (!srs)
192
- continue;
193
- const { color_values: cvs, size_values: svs } = srs;
194
- if (cvs) {
195
- for (const val of cvs)
196
- if (val != null)
197
- color_values.push(val);
198
- }
199
- if (svs) {
200
- for (const val of svs)
201
- if (val != null)
202
- size_values.push(val);
203
- }
461
+ if (!srs) continue
462
+ const { color_values: cvs, size_values: svs } = srs as DataSeries
463
+ if (cvs) { for (const val of cvs) if (val != null) color_values.push(val) }
464
+ if (svs) { for (const val of svs) if (val != null) size_values.push(val) }
204
465
  }
205
- return { color_values, size_values };
206
- });
207
- let all_color_values = $derived(series_value_arrays.color_values);
208
- // Compute auto ranges based on data and limits
209
- let auto_x_range = $derived(get_nice_data_range(all_points, ({ x }) => x, (final_x_axis.range ?? [null, null]), final_x_axis.scale_type, range_padding, final_x_axis.format?.startsWith(`%`) || false));
210
- let auto_y_range = $derived(get_nice_data_range(y1_points, ({ y }) => y, (final_y_axis.range ?? [null, null]), final_y_axis.scale_type, range_padding, false));
211
- let auto_y2_range = $derived(get_nice_data_range(y2_points, ({ y }) => y, (final_y2_axis.range ?? [null, null]), final_y2_axis.scale_type, range_padding, false));
212
- // Update zoom ranges when auto ranges or explicit ranges change
213
- // - Explicit ranges (from zoom/pan): apply directly
214
- // - Auto ranges (from data changes): use lazy expansion to preserve view context
215
- $effect(() => {
466
+ return { color_values, size_values }
467
+ })
468
+ let all_color_values = $derived(series_value_arrays.color_values)
469
+
470
+ // Compute auto ranges based on data and limits
471
+ let auto_x_range = $derived(
472
+ get_nice_data_range(
473
+ all_points,
474
+ ({ x }) => x,
475
+ final_x_axis.range ?? [null, null],
476
+ final_x_axis.scale_type ?? `linear`,
477
+ range_padding,
478
+ is_time_x,
479
+ ),
480
+ )
481
+
482
+ let auto_y_range = $derived(
483
+ get_nice_data_range(
484
+ y1_points,
485
+ ({ y }) => y,
486
+ final_y_axis.range ?? [null, null],
487
+ final_y_axis.scale_type ?? `linear`,
488
+ range_padding,
489
+ false,
490
+ ),
491
+ )
492
+
493
+ let auto_x2_range = $derived(
494
+ get_nice_data_range(
495
+ x2_points,
496
+ ({ x }) => x,
497
+ final_x2_axis.range ?? [null, null],
498
+ final_x2_axis.scale_type ?? `linear`,
499
+ range_padding,
500
+ is_time_x2,
501
+ ),
502
+ )
503
+
504
+ let auto_y2_range = $derived(
505
+ get_nice_data_range(
506
+ y2_points,
507
+ ({ y }) => y,
508
+ final_y2_axis.range ?? [null, null],
509
+ final_y2_axis.scale_type ?? `linear`,
510
+ range_padding,
511
+ false,
512
+ ),
513
+ )
514
+
515
+ // Update zoom ranges when auto ranges or explicit ranges change
516
+ // - Explicit ranges (from zoom/pan): apply directly
517
+ // - Auto ranges (from data changes): use lazy expansion to preserve view context
518
+ $effect(() => {
216
519
  // Helper to get effective range (explicit ?? auto) and check if explicit
217
- const get_range = (axis, auto) => {
218
- const explicit = axis.range?.[0] != null && axis.range?.[1] != null;
219
- return {
220
- explicit,
221
- range: [axis.range?.[0] ?? auto[0], axis.range?.[1] ?? auto[1]],
222
- };
223
- };
224
- const x = get_range(final_x_axis, auto_x_range);
225
- const y = get_range(final_y_axis, auto_y_range);
226
- const y2 = get_range(final_y2_axis, auto_y2_range);
520
+ const get_range = (
521
+ axis: { range?: [number | null, number | null] },
522
+ auto: Vec2,
523
+ ): { explicit: boolean; range: Vec2 } => {
524
+ const explicit = axis.range?.[0] != null && axis.range?.[1] != null
525
+ const range = [axis.range?.[0] ?? auto[0], axis.range?.[1] ?? auto[1]] as Vec2
526
+ return { explicit, range }
527
+ }
528
+
529
+ const x = get_range(final_x_axis, auto_x_range)
530
+ const x2 = get_range(final_x2_axis, auto_x2_range)
531
+ const y = get_range(final_y_axis, auto_y_range)
532
+ const y2 = get_range(final_y2_axis, auto_y2_range)
533
+
227
534
  // X axis: explicit → direct, auto → lazy expand
228
535
  if (x.explicit) {
229
- zoom_x_range = x.range;
536
+ zoom_x_range = x.range
537
+ } else {
538
+ const result = expand_range_if_needed(initial_x_range, x.range)
539
+ if (result.changed) {
540
+ ;[initial_x_range, zoom_x_range] = [result.range, result.range]
541
+ }
230
542
  }
231
- else {
232
- const result = expand_range_if_needed(initial_x_range, x.range);
233
- if (result.changed) {
234
- ;
235
- [initial_x_range, zoom_x_range] = [result.range, result.range];
236
- }
543
+
544
+ // X2 axis: explicit → direct, auto → lazy expand
545
+ if (x2.explicit) {
546
+ zoom_x2_range = x2.range
547
+ } else {
548
+ const result = expand_range_if_needed(initial_x2_range, x2.range)
549
+ if (result.changed) {
550
+ ;[initial_x2_range, zoom_x2_range] = [result.range, result.range]
551
+ }
237
552
  }
553
+
238
554
  // Y axis: explicit → direct, auto → lazy expand
239
555
  if (y.explicit) {
240
- zoom_y_range = y.range;
241
- }
242
- else {
243
- const result = expand_range_if_needed(initial_y_range, y.range);
244
- if (result.changed) {
245
- ;
246
- [initial_y_range, zoom_y_range] = [result.range, result.range];
247
- }
556
+ zoom_y_range = y.range
557
+ } else {
558
+ const result = expand_range_if_needed(initial_y_range, y.range)
559
+ if (result.changed) {
560
+ ;[initial_y_range, zoom_y_range] = [result.range, result.range]
561
+ }
248
562
  }
563
+
249
564
  // Y2 axis: explicit → direct, else expand initial range then optionally sync
250
565
  if (y2.explicit) {
251
- zoom_y2_range = y2.range;
252
- }
253
- else {
254
- const result = expand_range_if_needed(initial_y2_range, y2.range);
255
- if (result.changed)
256
- initial_y2_range = result.range;
257
- // Apply sync if enabled, otherwise use expanded range (or keep current if unchanged)
258
- if (y2_sync_config.mode !== `none`) {
259
- zoom_y2_range = sync_y2_range(zoom_y_range, initial_y2_range, y2_sync_config);
260
- }
261
- else if (result.changed) {
262
- zoom_y2_range = result.range;
263
- }
566
+ zoom_y2_range = y2.range
567
+ } else {
568
+ const result = expand_range_if_needed(initial_y2_range, y2.range)
569
+ if (result.changed) initial_y2_range = result.range
570
+ // Apply sync if enabled, otherwise use expanded range (or keep current if unchanged)
571
+ if (y2_sync_config.mode !== `none`) {
572
+ zoom_y2_range = sync_y2_range(zoom_y_range, initial_y2_range, y2_sync_config)
573
+ } else if (result.changed) {
574
+ zoom_y2_range = result.range
575
+ }
264
576
  }
265
- });
266
- let [x_min, x_max] = $derived(zoom_x_range);
267
- let [y_min, y_max] = $derived(zoom_y_range);
268
- let [y2_min, y2_max] = $derived(zoom_y2_range);
269
- // Create auto color range
270
- let auto_color_range = $derived(
271
- // Ensure we only calculate extent on actual numbers, filtering out nulls/undefined
272
- all_color_values.length > 0
273
- ? extent(all_color_values.filter((color_val) => typeof color_val === `number`))
274
- : [0, 1]);
275
- // Create scale functions
276
- // For time scales, use scaleTime directly; otherwise use create_scale (supports linear/log/arcsinh)
277
- let x_scale_fn = $derived(final_x_axis.format?.startsWith(`%`)
278
- ? scaleTime()
577
+ })
578
+
579
+ let [x_min, x_max] = $derived(zoom_x_range)
580
+ let [x2_min, x2_max] = $derived(zoom_x2_range)
581
+ let [y_min, y_max] = $derived(zoom_y_range)
582
+ let [y2_min, y2_max] = $derived(zoom_y2_range)
583
+
584
+ // Create auto color range
585
+ let auto_color_range = $derived(
586
+ // Ensure we only calculate extent on actual numbers, filtering out nulls/undefined
587
+ all_color_values.length > 0
588
+ ? extent(
589
+ all_color_values.filter((color_val: number | null): color_val is number =>
590
+ typeof color_val === `number`
591
+ ),
592
+ )
593
+ : [0, 1],
594
+ ) as Vec2
595
+
596
+ // Create scale functions
597
+ // For time scales, use scaleTime directly; otherwise use create_scale (supports linear/log/arcsinh)
598
+ let x_scale_fn = $derived(
599
+ is_time_x
600
+ ? scaleTime()
279
601
  .domain([new Date(x_min), new Date(x_max)])
280
602
  .range([pad.l, width - pad.r])
281
- : create_scale(final_x_axis.scale_type ?? `linear`, [x_min, x_max], [
603
+ : create_scale(final_x_axis.scale_type ?? `linear`, [x_min, x_max], [
282
604
  pad.l,
283
605
  width - pad.r,
284
- ]));
285
- let y_scale_fn = $derived(create_scale(final_y_axis.scale_type ?? `linear`, [y_min, y_max], [
286
- height - pad.b,
287
- pad.t,
288
- ]));
289
- let y2_scale_fn = $derived(create_scale(final_y2_axis.scale_type ?? `linear`, [y2_min, y2_max], [
290
- height - pad.b,
291
- pad.t,
292
- ]));
293
- // All size values from series (for size scale) - extracted in series_value_arrays
294
- let all_size_values = $derived(series_value_arrays.size_values);
295
- // Size scale function (using shared utility)
296
- let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values));
297
- // Color scale function (using shared utility)
298
- let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range));
299
- // Filter series data to only include points within bounds and augment with internal data
300
- let filtered_series = $derived(series_with_ids
301
- .map((data_series, series_idx) => {
302
- // Handle null/undefined series first
303
- if (!data_series) {
304
- return {
606
+ ]),
607
+ )
608
+
609
+ let x2_scale_fn = $derived(
610
+ is_time_x2
611
+ ? scaleTime()
612
+ .domain([new Date(x2_min), new Date(x2_max)])
613
+ .range([pad.l, width - pad.r])
614
+ : create_scale(final_x2_axis.scale_type ?? `linear`, [x2_min, x2_max], [
615
+ pad.l,
616
+ width - pad.r,
617
+ ]),
618
+ )
619
+
620
+ let y_scale_fn = $derived(
621
+ create_scale(final_y_axis.scale_type ?? `linear`, [y_min, y_max], [
622
+ height - pad.b,
623
+ pad.t,
624
+ ]),
625
+ )
626
+
627
+ let y2_scale_fn = $derived(
628
+ create_scale(final_y2_axis.scale_type ?? `linear`, [y2_min, y2_max], [
629
+ height - pad.b,
630
+ pad.t,
631
+ ]),
632
+ )
633
+
634
+ // All size values from series (for size scale) - extracted in series_value_arrays
635
+ let all_size_values = $derived(series_value_arrays.size_values)
636
+
637
+ // Size scale function (using shared utility)
638
+ let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values))
639
+
640
+ // Color scale function (using shared utility)
641
+ let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range))
642
+
643
+ // Filter series data to only include points within bounds and augment with internal data
644
+ let filtered_series = $derived(
645
+ series_with_ids
646
+ .map((data_series: DataSeries<Metadata>, series_idx): DataSeries<Metadata> => {
647
+ // Handle null/undefined series first
648
+ if (!data_series) {
649
+ return {
305
650
  x: [],
306
651
  y: [],
307
652
  visible: true,
308
653
  filtered_data: [],
309
654
  _id: series_idx,
310
655
  orig_series_idx: series_idx,
311
- };
312
- }
313
- // Handle explicitly hidden series
314
- if (!(data_series.visible ?? true)) {
315
- return {
656
+ }
657
+ }
658
+
659
+ // Handle explicitly hidden series
660
+ if (!(data_series.visible ?? true)) {
661
+ return {
316
662
  ...data_series,
317
663
  visible: false,
318
664
  filtered_data: [],
319
665
  orig_series_idx: series_idx,
320
- };
321
- }
322
- const { x: xs, y: ys, color_values, size_values, ...rest } = data_series;
323
- // Process points internally, adding properties beyond the base Point type
324
- const processed_points = xs.map((x_val, point_idx) => ({
325
- x: x_val,
326
- y: ys[point_idx],
327
- color_value: color_values?.[point_idx],
328
- metadata: process_prop(rest.metadata, point_idx),
329
- point_style: process_prop(rest.point_style, point_idx),
330
- point_hover: process_prop(rest.point_hover, point_idx),
331
- point_label: process_prop(rest.point_label, point_idx),
332
- point_offset: process_prop(rest.point_offset, point_idx),
333
- series_idx,
334
- point_idx,
335
- size_value: size_values?.[point_idx],
336
- }));
337
- // Filter to points within the plot bounds
338
- const is_valid_dim = (val, min, max) => val !== null && val !== undefined && !isNaN(val) && val >= min && val <= max;
339
- // Determine which y-range to use based on series y_axis property
340
- const [series_y_min, series_y_max] = (data_series.y_axis ?? `y1`) === `y2`
341
- ? [y2_min, y2_max]
342
- : [y_min, y_max];
343
- const filtered_data_with_extras = processed_points.filter(({ x, y }) => is_valid_dim(x, x_min, x_max) &&
344
- is_valid_dim(y, series_y_min, series_y_max));
345
- // Return structure consistent with DataSeries but acknowledge internal data structure (filtered_data)
346
- return {
347
- ...data_series,
348
- visible: true, // Mark series as visible here
349
- filtered_data: filtered_data_with_extras,
350
- orig_series_idx: series_idx, // Store original index for auto-cycling colors/symbols
351
- };
352
- })
353
- // Filter series end up completely empty after point filtering
354
- .filter((srs) => !!srs.filtered_data && srs.filtered_data.length > 0));
355
- // Collect all plot points for legend placement calculation
356
- let plot_points_for_placement = $derived.by(() => {
357
- if (!width || !height || !filtered_series)
358
- return [];
359
- const points = [];
666
+ }
667
+ }
668
+
669
+ const { x: xs, y: ys, color_values, size_values, ...rest } = data_series
670
+
671
+ // Process points internally, adding properties beyond the base Point type
672
+ const processed_points: InternalPoint<Metadata>[] = xs.map(
673
+ (x_val: number, point_idx: number) => ({
674
+ x: x_val,
675
+ y: ys[point_idx],
676
+ color_value: color_values?.[point_idx],
677
+ metadata: process_prop(rest.metadata, point_idx) as Metadata | undefined,
678
+ point_style: process_prop(rest.point_style, point_idx),
679
+ point_hover: process_prop(rest.point_hover, point_idx),
680
+ point_label: process_prop(rest.point_label, point_idx),
681
+ point_offset: process_prop(rest.point_offset, point_idx),
682
+ series_idx,
683
+ point_idx,
684
+ size_value: size_values?.[point_idx],
685
+ }),
686
+ )
687
+
688
+ // Filter to points within the plot bounds (handles inverted ranges like [3.5, 1.4])
689
+ const in_range = (val: number | null | undefined, lo: number, hi: number) =>
690
+ val != null && !isNaN(val) && val >= Math.min(lo, hi) &&
691
+ val <= Math.max(lo, hi)
692
+
693
+ // Determine which ranges to use based on series axis properties
694
+ const [series_x_min, series_x_max] = (data_series.x_axis ?? `x1`) === `x2`
695
+ ? [x2_min, x2_max]
696
+ : [x_min, x_max]
697
+ const [series_y_min, series_y_max] = (data_series.y_axis ?? `y1`) === `y2`
698
+ ? [y2_min, y2_max]
699
+ : [y_min, y_max]
700
+
701
+ const filtered_data_with_extras = processed_points.filter(
702
+ ({ x, y }) =>
703
+ in_range(x, series_x_min, series_x_max) &&
704
+ in_range(y, series_y_min, series_y_max),
705
+ )
706
+
707
+ // Return structure consistent with DataSeries but acknowledge internal data structure (filtered_data)
708
+ return {
709
+ ...data_series,
710
+ visible: true, // Mark series as visible here
711
+ filtered_data: filtered_data_with_extras,
712
+ orig_series_idx: series_idx, // Store original index for auto-cycling colors/symbols
713
+ }
714
+ })
715
+ // Filter series end up completely empty after point filtering
716
+ .filter((
717
+ srs,
718
+ ): srs is DataSeries<Metadata> & { filtered_data: InternalPoint<Metadata>[] } =>
719
+ !!srs.filtered_data && srs.filtered_data.length > 0
720
+ ),
721
+ )
722
+
723
+ // Collect all plot points for legend placement calculation
724
+ let plot_points_for_placement = $derived.by(() => {
725
+ if (!width || !height || !filtered_series) return []
726
+
727
+ const points: { x: number; y: number }[] = []
728
+
360
729
  for (const series_data of filtered_series) {
361
- if (!series_data?.filtered_data)
362
- continue;
363
- for (const point of series_data.filtered_data) {
364
- const point_x_coord = final_x_axis.format?.startsWith(`%`)
365
- ? x_scale_fn(new Date(point.x))
366
- : x_scale_fn(point.x);
367
- const point_y_coord = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
368
- if (isFinite(point_x_coord) && isFinite(point_y_coord)) {
369
- points.push({ x: point_x_coord, y: point_y_coord });
370
- }
730
+ if (!series_data?.filtered_data) continue
731
+ const use_x2_scale = series_data.x_axis === `x2`
732
+ for (const point of series_data.filtered_data) {
733
+ const active_x_scale = use_x2_scale ? x2_scale_fn : x_scale_fn
734
+ const active_is_time_x = use_x2_scale ? is_time_x2 : is_time_x
735
+ const point_x_coord = active_is_time_x
736
+ ? active_x_scale(new Date(point.x))
737
+ : active_x_scale(point.x)
738
+ const point_y_coord =
739
+ (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(
740
+ point.y,
741
+ )
742
+
743
+ if (isFinite(point_x_coord) && isFinite(point_y_coord)) {
744
+ points.push({ x: point_x_coord, y: point_y_coord })
371
745
  }
746
+ }
372
747
  }
373
- return points;
374
- });
375
- let computed_fills = $derived.by(() => {
748
+ return points
749
+ })
750
+
751
+ // Explicitly define the type for display_style matching PlotLegend expectations
752
+ type LegendDisplayStyle = {
753
+ symbol_type?: D3SymbolName
754
+ symbol_color?: string
755
+ line_color?: string
756
+ line_dash?: string
757
+ }
758
+
759
+ // Computed fill regions: merge fill_regions and converted error_bands, resolve boundaries
760
+ type ComputedFill = FillRegion & {
761
+ idx: number
762
+ source_type: `fill_region` | `error_band`
763
+ source_idx: number
764
+ path_segments: string[]
765
+ }
766
+ let computed_fills = $derived.by((): ComputedFill[] => {
376
767
  // Early exit: skip expensive computation if no fills to render
377
- const has_fill_regions = fill_regions && fill_regions.length > 0;
378
- const has_error_bands = error_bands && error_bands.length > 0;
379
- if (!has_fill_regions && !has_error_bands)
380
- return [];
768
+ const has_fill_regions = fill_regions && fill_regions.length > 0
769
+ const has_error_bands = error_bands && error_bands.length > 0
770
+ if (!has_fill_regions && !has_error_bands) return []
771
+
381
772
  // Merge fill_regions and converted error_bands, tracking source
382
- const all_regions = [
383
- ...(fill_regions ?? []).map((region, source_idx) => ({
384
- region,
385
- source_type: `fill_region`,
386
- source_idx,
387
- })),
388
- ...(error_bands ?? []).map((band, source_idx) => ({
389
- region: convert_error_band_to_fill_region(band, series_with_ids),
390
- source_type: `error_band`,
391
- source_idx,
392
- })),
393
- ];
773
+ const all_regions: {
774
+ region: FillRegion | null
775
+ source_type: `fill_region` | `error_band`
776
+ source_idx: number
777
+ }[] = [
778
+ ...(fill_regions ?? []).map((region, source_idx) => ({
779
+ region,
780
+ source_type: `fill_region` as const,
781
+ source_idx,
782
+ })),
783
+ ...(error_bands ?? []).map((band, source_idx) => ({
784
+ region: convert_error_band_to_fill_region(band, series_with_ids),
785
+ source_type: `error_band` as const,
786
+ source_idx,
787
+ })),
788
+ ]
789
+
394
790
  // Compute unique x-values once for all fills
395
791
  // Optimization: deduplicate first (O(n)), then sort only unique values (O(k log k))
396
792
  // This is faster for datasets with many duplicate x-values across series
397
- const x_set = new SvelteSet();
793
+ const x_set = new SvelteSet<number>()
398
794
  for (const data_series of series_with_ids) {
399
- if (!data_series)
400
- continue;
401
- for (const val of data_series.x) {
402
- if (typeof val === `number` && isFinite(val))
403
- x_set.add(val);
404
- }
795
+ if (!data_series) continue
796
+ for (const val of data_series.x) {
797
+ if (typeof val === `number` && isFinite(val)) x_set.add(val)
798
+ }
405
799
  }
406
- const unique_x = [...x_set].sort((val_a, val_b) => val_a - val_b);
407
- if (unique_x.length === 0)
408
- return [];
800
+ const unique_x = [...x_set].sort((val_a, val_b) => val_a - val_b)
801
+
802
+ if (unique_x.length === 0) return []
803
+
409
804
  return all_regions
410
- .filter((entry) => entry.region !== null)
411
- .map(({ region, source_type, source_idx }, idx) => {
412
- if (region.visible === false)
413
- return null;
805
+ .filter((
806
+ entry,
807
+ ): entry is {
808
+ region: FillRegion
809
+ source_type: `fill_region` | `error_band`
810
+ source_idx: number
811
+ } => entry.region !== null)
812
+ .map(({ region, source_type, source_idx }, idx) => {
813
+ if (region.visible === false) return null
814
+
414
815
  // Domain context for boundary resolution
415
816
  const domains = {
416
- y_domain: [y_min, y_max],
417
- y2_domain: [y2_min, y2_max],
418
- };
817
+ y_domain: [y_min, y_max] as Vec2,
818
+ y2_domain: [y2_min, y2_max] as Vec2,
819
+ }
820
+
419
821
  // Resolve upper and lower boundaries
420
- const upper_values = resolve_boundary(region.upper, series_with_ids, unique_x, domains);
421
- const lower_values = resolve_boundary(region.lower, series_with_ids, unique_x, domains);
422
- if (!upper_values || !lower_values)
423
- return null;
822
+ const upper_values = resolve_boundary(
823
+ region.upper,
824
+ series_with_ids,
825
+ unique_x,
826
+ domains,
827
+ )
828
+ const lower_values = resolve_boundary(
829
+ region.lower,
830
+ series_with_ids,
831
+ unique_x,
832
+ domains,
833
+ )
834
+
835
+ if (!upper_values || !lower_values) return null
836
+
424
837
  // Apply range constraints
425
- const range_filtered = apply_range_constraints(unique_x, lower_values, upper_values, region);
838
+ const range_filtered = apply_range_constraints(
839
+ unique_x,
840
+ lower_values,
841
+ upper_values,
842
+ region,
843
+ )
844
+
426
845
  // Clamp for log scale if needed
427
- const y_scale_type = final_y_axis.scale_type ?? `linear`;
428
- const x_scale_type = final_x_axis.scale_type ?? `linear`;
429
- const clamped = clamp_for_log_scale(range_filtered.x, range_filtered.y1, range_filtered.y2, y_scale_type, x_scale_type);
846
+ const y_scale_type = final_y_axis.scale_type ?? `linear`
847
+ const x_scale_type = final_x_axis.scale_type ?? `linear`
848
+ const clamped = clamp_for_log_scale(
849
+ range_filtered.x,
850
+ range_filtered.y1,
851
+ range_filtered.y2,
852
+ y_scale_type,
853
+ x_scale_type,
854
+ )
855
+
430
856
  // Apply where condition (splits into segments)
431
- const conditioned = apply_where_condition(clamped.x, clamped.y1, clamped.y2, region);
857
+ const conditioned = apply_where_condition(
858
+ clamped.x,
859
+ clamped.y1,
860
+ clamped.y2,
861
+ region,
862
+ )
863
+
432
864
  // Generate paths for each segment (convert to pixel coordinates)
433
865
  const path_segments = conditioned.segments
434
- .filter((segment) => segment.length > 1)
435
- .map((segment) => {
436
- const pixel_data = segment.map((point) => ({
437
- x: x_scale_fn(point.x),
438
- y1: y_scale_fn(point.y1),
439
- y2: y_scale_fn(point.y2),
440
- }));
441
- return generate_fill_path(pixel_data, region.curve ?? `monotoneX`);
442
- })
443
- .filter((path) => path.length > 0);
444
- if (path_segments.length === 0)
445
- return null;
446
- return { ...region, idx, source_type, source_idx, path_segments };
447
- })
448
- .filter((fill) => fill !== null);
449
- });
450
- // Prepare data needed for the legend component
451
- let legend_data = $derived.by(() => {
452
- const items = series_with_ids.map((data_series, series_idx) => {
453
- const is_visible = data_series?.visible ?? true;
866
+ .filter((segment) => segment.length > 1)
867
+ .map((segment) => {
868
+ const pixel_data: FillPathPoint[] = segment.map((point) => ({
869
+ x: x_scale_fn(point.x),
870
+ y1: y_scale_fn(point.y1),
871
+ y2: y_scale_fn(point.y2),
872
+ }))
873
+ return generate_fill_path(pixel_data, region.curve ?? `monotoneX`)
874
+ })
875
+ .filter((path) => path.length > 0)
876
+
877
+ if (path_segments.length === 0) return null
878
+
879
+ return { ...region, idx, source_type, source_idx, path_segments }
880
+ })
881
+ .filter((fill): fill is ComputedFill => fill !== null)
882
+ })
883
+
884
+ // Prepare data needed for the legend component
885
+ let legend_data = $derived.by(() => {
886
+ const items = series_with_ids.map(
887
+ (data_series: DataSeries & { _id?: string | number }, series_idx: number) => {
888
+ const is_visible = data_series?.visible ?? true
454
889
  // Prefer top-level label, fallback to metadata label
455
890
  const explicit_label = data_series?.label ??
456
- (typeof data_series?.metadata === `object` &&
457
- data_series.metadata !== null &&
458
- `label` in data_series.metadata &&
459
- typeof data_series.metadata.label === `string`
460
- ? data_series.metadata.label
461
- : null);
891
+ (typeof data_series?.metadata === `object` &&
892
+ data_series.metadata !== null &&
893
+ `label` in data_series.metadata &&
894
+ typeof data_series.metadata.label === `string`
895
+ ? data_series.metadata.label
896
+ : null)
462
897
  // Use explicit label or generate default
463
- const label = explicit_label ?? `Series ${series_idx + 1}`;
464
- const has_explicit_label = explicit_label != null;
898
+ const label = explicit_label ?? `Series ${series_idx + 1}`
899
+ const has_explicit_label = explicit_label != null
900
+
465
901
  // Use series-specific defaults for auto-differentiation
466
- const series_default_color = get_series_color(series_idx);
467
- const series_default_symbol = get_series_symbol(series_idx);
468
- const display_style = {
469
- symbol_type: series_default_symbol,
470
- symbol_color: series_default_color,
471
- line_color: series_default_color,
472
- };
473
- const series_markers = data_series?.markers ?? DEFAULT_MARKERS;
902
+ const series_default_color = get_series_color(series_idx)
903
+ const series_default_symbol = get_series_symbol(series_idx)
904
+
905
+ const display_style: LegendDisplayStyle = {
906
+ symbol_type: series_default_symbol,
907
+ symbol_color: series_default_color,
908
+ line_color: series_default_color,
909
+ }
910
+ const series_markers = data_series?.markers ?? DEFAULT_MARKERS
911
+
474
912
  // Check point_style (could be object or array)
475
913
  const first_point_style = Array.isArray(data_series?.point_style)
476
- ? data_series.point_style[0]
477
- : data_series?.point_style;
914
+ ? data_series.point_style[0]
915
+ : data_series?.point_style
916
+
478
917
  if (series_markers?.includes(`points`)) {
479
- if (first_point_style) {
480
- // Use explicit symbol_type if provided and valid, otherwise keep series default
481
- if (typeof first_point_style.symbol_type === `string` &&
482
- symbol_names.includes(first_point_style.symbol_type)) {
483
- display_style.symbol_type = first_point_style
484
- .symbol_type;
485
- }
486
- // Use explicit fill color if provided
487
- if (first_point_style.fill) {
488
- display_style.symbol_color = first_point_style.fill;
489
- }
490
- if (first_point_style.stroke) {
491
- // Use stroke color if fill is none or transparent
492
- if (!display_style.symbol_color ||
493
- display_style.symbol_color === `none` ||
494
- display_style.symbol_color.startsWith(`rgba(`, 0) // Check if transparent
495
- )
496
- display_style.symbol_color = first_point_style.stroke;
497
- }
918
+ if (first_point_style) {
919
+ // Use explicit symbol_type if provided and valid, otherwise keep series default
920
+ if (
921
+ typeof first_point_style.symbol_type === `string` &&
922
+ symbol_names.includes(first_point_style.symbol_type as D3SymbolName)
923
+ ) {
924
+ display_style.symbol_type = first_point_style
925
+ .symbol_type as D3SymbolName
498
926
  }
499
- // else: keep series-specific defaults for symbol_type and symbol_color
500
- }
501
- else {
502
- // If no points marker, explicitly remove marker style for legend
503
- display_style.symbol_type = undefined;
504
- display_style.symbol_color = undefined;
927
+
928
+ // Use explicit fill color if provided
929
+ if (first_point_style.fill) {
930
+ display_style.symbol_color = first_point_style.fill
931
+ }
932
+ if (first_point_style.stroke) {
933
+ // Use stroke color if fill is none or transparent
934
+ if (
935
+ !display_style.symbol_color ||
936
+ display_style.symbol_color === `none` ||
937
+ display_style.symbol_color.startsWith(`rgba(`, 0) // Check if transparent
938
+ ) display_style.symbol_color = first_point_style.stroke
939
+ }
940
+ }
941
+ // else: keep series-specific defaults for symbol_type and symbol_color
942
+ } else {
943
+ // If no points marker, explicitly remove marker style for legend
944
+ display_style.symbol_type = undefined
945
+ display_style.symbol_color = undefined
505
946
  }
947
+
506
948
  // Check line_style
507
949
  if (series_markers?.includes(`line`)) {
508
- // Prefer explicit line stroke, then other explicit colors, then series default
509
- let legend_line_color = data_series?.line_style?.stroke;
510
- if (!legend_line_color) {
511
- // Try color scale if available
512
- const first_cv = Array.isArray(data_series?.color_values)
513
- ? data_series.color_values.find((color_val) => color_val != null)
514
- : undefined;
515
- legend_line_color =
516
- (first_cv != null ? color_scale_fn(first_cv) : undefined) ||
517
- first_point_style?.fill ||
518
- first_point_style?.stroke ||
519
- series_default_color;
520
- }
521
- display_style.line_color = legend_line_color;
522
- display_style.line_dash = data_series?.line_style?.line_dash;
523
- }
524
- else {
525
- // If no line marker, explicitly remove line style for legend
526
- display_style.line_dash = undefined;
527
- display_style.line_color = undefined;
950
+ // Prefer explicit line stroke, then other explicit colors, then series default
951
+ let legend_line_color = data_series?.line_style?.stroke
952
+ if (!legend_line_color) {
953
+ // Try color scale if available
954
+ const first_cv = Array.isArray(data_series?.color_values)
955
+ ? data_series?.color_values?.find((color_val: number | null) =>
956
+ color_val != null
957
+ )
958
+ : undefined
959
+ legend_line_color =
960
+ (first_cv != null ? color_scale_fn(first_cv) : undefined) ||
961
+ first_point_style?.fill ||
962
+ first_point_style?.stroke ||
963
+ series_default_color
964
+ }
965
+ display_style.line_color = legend_line_color
966
+ display_style.line_dash = data_series?.line_style?.line_dash
967
+ } else {
968
+ // If no line marker, explicitly remove line style for legend
969
+ display_style.line_dash = undefined
970
+ display_style.line_color = undefined
528
971
  }
972
+
529
973
  return {
530
- series_idx,
531
- label,
532
- visible: is_visible,
533
- display_style,
534
- has_explicit_label,
535
- legend_group: data_series?.legend_group,
536
- };
537
- });
974
+ series_idx,
975
+ label,
976
+ visible: is_visible,
977
+ display_style,
978
+ has_explicit_label,
979
+ legend_group: data_series?.legend_group,
980
+ }
981
+ },
982
+ )
983
+
538
984
  // Deduplicate by label+legend_group - keep first occurrence of each unique combination
539
- const seen_labels = new SvelteSet();
540
- const series_items = items.filter((legend_item) => {
985
+ const seen_labels = new SvelteSet<string>()
986
+ const series_items = items.filter(
987
+ (
988
+ legend_item: {
989
+ label: string
990
+ series_idx: number
991
+ visible: boolean
992
+ display_style: LegendDisplayStyle
993
+ has_explicit_label: boolean
994
+ legend_group?: string
995
+ },
996
+ ) => {
541
997
  // Use label+group as unique key (group may be undefined)
542
- const unique_key = `${legend_item.legend_group ?? ``}::${legend_item.label}`;
543
- if (seen_labels.has(unique_key))
544
- return false;
545
- seen_labels.add(unique_key);
546
- return true;
547
- });
998
+ const unique_key = `${legend_item.legend_group ?? ``}::${legend_item.label}`
999
+ if (seen_labels.has(unique_key)) return false
1000
+ seen_labels.add(unique_key)
1001
+ return true
1002
+ },
1003
+ )
1004
+
548
1005
  // Add fill region items to legend (deduplicated using same key format as series)
549
1006
  const fill_items = computed_fills
550
- .filter((fill) => fill.show_in_legend !== false && fill.label)
551
- .filter((fill) => {
1007
+ .filter((fill) => fill.show_in_legend !== false && fill.label)
1008
+ .filter((fill) => {
552
1009
  // Use same composite key as series: legend_group::label
553
- const unique_key = `${fill.legend_group ?? ``}::${fill.label}`;
554
- if (seen_labels.has(unique_key))
555
- return false;
556
- seen_labels.add(unique_key);
557
- return true;
558
- })
559
- .map((fill) => {
1010
+ const unique_key = `${fill.legend_group ?? ``}::${fill.label ?? ``}`
1011
+ if (seen_labels.has(unique_key)) return false
1012
+ seen_labels.add(unique_key)
1013
+ return true
1014
+ })
1015
+ .map((fill) => {
560
1016
  // Pass gradient for swatch rendering, or solid color as fallback
561
- const fill_gradient = is_fill_gradient(fill.fill) ? fill.fill : undefined;
562
- const fill_color = typeof fill.fill === `string` ? fill.fill : undefined;
1017
+ const fill_gradient = is_fill_gradient(fill.fill) ? fill.fill : undefined
1018
+ const fill_color = typeof fill.fill === `string` ? fill.fill : undefined
1019
+
563
1020
  return {
564
- series_idx: -1, // Not a series
565
- fill_idx: fill.idx,
566
- fill_source_type: fill.source_type,
567
- fill_source_idx: fill.source_idx,
568
- item_type: `fill`,
569
- label: fill.label,
570
- visible: fill.visible !== false,
571
- legend_group: fill.legend_group,
572
- display_style: {
573
- fill_color,
574
- fill_opacity: fill.fill_opacity ?? 0.3,
575
- edge_color: fill.edge_upper?.color,
576
- fill_gradient,
577
- },
578
- };
579
- });
580
- return [...series_items, ...fill_items];
581
- });
582
- // Group fills by z-index for ordered rendering (single pass instead of 4 filters)
583
- let fills_by_z = $derived.by(() => {
584
- const groups = { below_grid: [], below_lines: [], below_points: [], above_all: [] };
1021
+ series_idx: -1, // Not a series
1022
+ fill_idx: fill.idx,
1023
+ fill_source_type: fill.source_type,
1024
+ fill_source_idx: fill.source_idx,
1025
+ item_type: `fill` as const,
1026
+ label: fill.label ?? ``,
1027
+ visible: fill.visible !== false,
1028
+ legend_group: fill.legend_group,
1029
+ display_style: {
1030
+ fill_color,
1031
+ fill_opacity: fill.fill_opacity ?? 0.3,
1032
+ edge_color: fill.edge_upper?.color,
1033
+ fill_gradient,
1034
+ },
1035
+ }
1036
+ })
1037
+
1038
+ return [...series_items, ...fill_items]
1039
+ })
1040
+
1041
+ // Group fills by z-index for ordered rendering (single pass instead of 4 filters)
1042
+ let fills_by_z = $derived.by(() => {
1043
+ const groups: {
1044
+ below_grid: typeof computed_fills
1045
+ below_lines: typeof computed_fills
1046
+ below_points: typeof computed_fills
1047
+ above_all: typeof computed_fills
1048
+ } = { below_grid: [], below_lines: [], below_points: [], above_all: [] }
1049
+
585
1050
  for (const fill of computed_fills) {
586
- if (fill.z_index === `below-grid`)
587
- groups.below_grid.push(fill);
588
- else if (fill.z_index === `below-points`)
589
- groups.below_points.push(fill);
590
- else if (fill.z_index === `above-all`)
591
- groups.above_all.push(fill);
592
- else
593
- groups.below_lines.push(fill); // default: no z_index or 'below-lines'
1051
+ if (fill.z_index === `below-grid`) groups.below_grid.push(fill)
1052
+ else if (fill.z_index === `below-points`) groups.below_points.push(fill)
1053
+ else if (fill.z_index === `above-all`) groups.above_all.push(fill)
1054
+ else groups.below_lines.push(fill) // default: no z_index or 'below-lines'
594
1055
  }
595
- return groups;
596
- });
597
- // Compute ref_lines with index and group by z-index (using shared utilities)
598
- let indexed_ref_lines = $derived(index_ref_lines(ref_lines));
599
- let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines));
600
- // Calculate best legend placement using continuous grid sampling
601
- let legend_placement = $derived.by(() => {
1056
+ return groups
1057
+ })
1058
+
1059
+ // Compute ref_lines with index and group by z-index (using shared utilities)
1060
+ let indexed_ref_lines = $derived(index_ref_lines(ref_lines))
1061
+ let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines))
1062
+
1063
+ // Calculate best legend placement using continuous grid sampling
1064
+ let legend_placement = $derived.by(() => {
602
1065
  const should_place = legend != null &&
603
- (legend_data.length > 1 || Object.keys(legend).length > 0);
604
- if (!should_place || !width || !height)
605
- return null;
606
- const plot_width = width - pad.l - pad.r;
607
- const plot_height = height - pad.t - pad.b;
1066
+ (legend_data.length > 1 || Object.keys(legend).length > 0)
1067
+
1068
+ if (!should_place || !width || !height) return null
1069
+
1070
+ const plot_width = width - pad.l - pad.r
1071
+ const plot_height = height - pad.t - pad.b
1072
+
608
1073
  // Use measured size if available, otherwise estimate
609
1074
  const legend_size = legend_element
610
- ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
611
- : { width: 120, height: 80 };
1075
+ ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
1076
+ : { width: 120, height: 80 }
1077
+
612
1078
  const placement_config = {
613
- plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
614
- element_size: legend_size,
615
- axis_clearance: legend?.axis_clearance,
616
- exclude_rects: [],
617
- points: plot_points_for_placement,
618
- };
619
- return compute_element_placement(placement_config);
620
- });
621
- // Calculate color bar placement (coordinates with legend to avoid overlap)
622
- let color_bar_placement = $derived.by(() => {
623
- if (!color_bar || !all_color_values.length || !width || !height)
624
- return null;
625
- const plot_width = width - pad.l - pad.r;
626
- const plot_height = height - pad.t - pad.b;
1079
+ plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
1080
+ element_size: legend_size,
1081
+ axis_clearance: legend?.axis_clearance,
1082
+ exclude_rects: [],
1083
+ points: plot_points_for_placement,
1084
+ }
1085
+
1086
+ return compute_element_placement(placement_config)
1087
+ })
1088
+
1089
+ // Calculate color bar placement (coordinates with legend to avoid overlap)
1090
+ let color_bar_placement = $derived.by(() => {
1091
+ if (!color_bar || !all_color_values.length || !width || !height) return null
1092
+
1093
+ const plot_width = width - pad.l - pad.r
1094
+ const plot_height = height - pad.t - pad.b
1095
+
627
1096
  // Use measured size if available, otherwise estimate based on orientation
628
- const is_horizontal = color_bar.orientation === `horizontal`;
1097
+ const is_horizontal = color_bar.orientation === `horizontal`
629
1098
  const colorbar_size = colorbar_element
630
- ? { width: colorbar_element.offsetWidth, height: colorbar_element.offsetHeight }
631
- : is_horizontal
632
- ? { width: 220, height: 40 }
633
- : { width: 40, height: 100 };
1099
+ ? { width: colorbar_element.offsetWidth, height: colorbar_element.offsetHeight }
1100
+ : is_horizontal
1101
+ ? { width: 220, height: 40 }
1102
+ : { width: 40, height: 100 }
1103
+
634
1104
  // Build exclusion rects (avoid legend if it's placed)
635
- const exclude_rects = [];
1105
+ const exclude_rects: Rect[] = []
636
1106
  if (legend_element && legend_placement) {
637
- exclude_rects.push({
638
- x: legend_placement.x,
639
- y: legend_placement.y,
640
- width: legend_element.offsetWidth || 120,
641
- height: legend_element.offsetHeight || 80,
642
- });
1107
+ exclude_rects.push({
1108
+ x: legend_placement.x,
1109
+ y: legend_placement.y,
1110
+ width: legend_element.offsetWidth || 120,
1111
+ height: legend_element.offsetHeight || 80,
1112
+ })
643
1113
  }
1114
+
644
1115
  return compute_element_placement({
645
- plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
646
- element_size: colorbar_size,
647
- // Colorbar needs slightly more clearance than legend to avoid axis labels
648
- axis_clearance: 15,
649
- exclude_rects,
650
- points: plot_points_for_placement,
651
- });
652
- });
653
- // Active legend placement (null if user set explicit position)
654
- let active_legend_placement = $derived.by(() => {
655
- if (!legend_placement)
656
- return null;
1116
+ plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
1117
+ element_size: colorbar_size,
1118
+ // Colorbar needs slightly more clearance than legend to avoid axis labels
1119
+ axis_clearance: 15,
1120
+ exclude_rects,
1121
+ points: plot_points_for_placement,
1122
+ })
1123
+ })
1124
+
1125
+ // Active legend placement (null if user set explicit position)
1126
+ let active_legend_placement = $derived.by(() => {
1127
+ if (!legend_placement) return null
1128
+
657
1129
  // Skip auto-placement if user set explicit position in style
658
- const legend_style = legend?.style ?? ``;
659
- if (/(^|[;{]\s*)(top|bottom|left|right)\s*:|position\s*:\s*absolute/.test(legend_style))
660
- return null;
661
- return legend_placement;
662
- });
663
- // Initialize tweened values for color bar position - create once, update target via effect
664
- // untrack() explicitly captures initial tween config (intentional - config set once at mount)
665
- const tweened_colorbar_coords = new Tween({ x: 0, y: 0 }, untrack(() => ({ duration: 400, ...(color_bar?.tween ?? {}) })));
666
- // Initialize tweened values for legend position - create once, update target via effect
667
- const tweened_legend_coords = new Tween({ x: 0, y: 0 }, untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })));
668
- // Update placement positions (with animation and stability checks)
669
- $effect(() => {
670
- if (!width || !height)
671
- return;
1130
+ const legend_style = legend?.style ?? ``
1131
+ if (
1132
+ /(^|[;{]\s*)(top|bottom|left|right)\s*:|position\s*:\s*absolute/.test(
1133
+ legend_style,
1134
+ )
1135
+ ) return null
1136
+
1137
+ return legend_placement
1138
+ })
1139
+
1140
+ // Initialize tweened values for color bar position - create once, update target via effect
1141
+ // untrack() explicitly captures initial tween config (intentional - config set once at mount)
1142
+ const tweened_colorbar_coords = new Tween(
1143
+ { x: 0, y: 0 },
1144
+ untrack(() => ({ duration: 400, ...(color_bar?.tween ?? {}) })),
1145
+ )
1146
+ // Initialize tweened values for legend position - create once, update target via effect
1147
+ const tweened_legend_coords = new Tween(
1148
+ { x: 0, y: 0 },
1149
+ untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })),
1150
+ )
1151
+
1152
+ // Update placement positions (with animation and stability checks)
1153
+ $effect(() => {
1154
+ if (!width || !height) return
1155
+
672
1156
  // Track dimensions for resize detection
673
- const dims_changed = dim_tracker.has_changed(width, height);
674
- if (dims_changed)
675
- dim_tracker.update(width, height);
1157
+ const dims_changed = dim_tracker.has_changed(width, height)
1158
+ if (dims_changed) dim_tracker.update(width, height)
1159
+
676
1160
  // Update colorbar position (stable after initial placement unless responsive)
677
1161
  if (color_bar_placement) {
678
- const is_responsive = color_bar?.responsive ?? false;
679
- const should_update = dims_changed || (!colorbar_hover.is_locked.current &&
680
- (is_responsive || !has_initial_colorbar_placement));
681
- if (should_update) {
682
- tweened_colorbar_coords.set({ x: color_bar_placement.x, y: color_bar_placement.y }, has_initial_colorbar_placement ? undefined : { duration: 0 });
683
- if (colorbar_element && !has_initial_colorbar_placement) {
684
- has_initial_colorbar_placement = true;
685
- }
1162
+ const is_responsive = color_bar?.responsive ?? false
1163
+ const should_update = dims_changed || (!colorbar_hover.is_locked.current &&
1164
+ (is_responsive || !has_initial_colorbar_placement))
1165
+
1166
+ if (should_update) {
1167
+ tweened_colorbar_coords.set(
1168
+ { x: color_bar_placement.x, y: color_bar_placement.y },
1169
+ has_initial_colorbar_placement ? undefined : { duration: 0 },
1170
+ )
1171
+ if (colorbar_element && !has_initial_colorbar_placement) {
1172
+ has_initial_colorbar_placement = true
686
1173
  }
1174
+ }
687
1175
  }
1176
+
688
1177
  // Update legend position (stable after initial placement unless responsive)
689
1178
  if (legend_manual_position && !legend_is_dragging) {
690
- // Immediate update (no animation) for manually dragged positions
691
- tweened_legend_coords.set(legend_manual_position, { duration: 0 });
692
- }
693
- else if (active_legend_placement && !legend_is_dragging) {
694
- const is_responsive = legend?.responsive ?? false;
695
- const should_update = dims_changed || (!legend_hover.is_locked.current &&
696
- (is_responsive || !has_initial_legend_placement));
697
- if (should_update) {
698
- tweened_legend_coords.set({ x: active_legend_placement.x, y: active_legend_placement.y }, has_initial_legend_placement ? undefined : { duration: 0 });
699
- if (legend_element)
700
- has_initial_legend_placement = true;
701
- }
1179
+ // Immediate update (no animation) for manually dragged positions
1180
+ tweened_legend_coords.set(legend_manual_position, { duration: 0 })
1181
+ } else if (active_legend_placement && !legend_is_dragging) {
1182
+ const is_responsive = legend?.responsive ?? false
1183
+ const should_update = dims_changed || (!legend_hover.is_locked.current &&
1184
+ (is_responsive || !has_initial_legend_placement))
1185
+
1186
+ if (should_update) {
1187
+ tweened_legend_coords.set(
1188
+ { x: active_legend_placement.x, y: active_legend_placement.y },
1189
+ has_initial_legend_placement ? undefined : { duration: 0 },
1190
+ )
1191
+ if (legend_element) has_initial_legend_placement = true
1192
+ }
702
1193
  }
703
- });
704
- // Generate axis ticks - consolidated into single derived for efficiency
705
- let axis_ticks = $derived.by(() => {
706
- if (!width || !height)
707
- return { x: [], y: [], y2: [] };
1194
+ })
1195
+
1196
+ // Generate axis ticks - consolidated into single derived for efficiency
1197
+ let axis_ticks = $derived.by(() => {
1198
+ if (!width || !height) return { x: [], x2: [], y: [], y2: [] }
1199
+
708
1200
  // X-axis ticks: choose appropriate scale for tick generation
709
1201
  // Time scales (format starts with %) use scaleTime for better tick placement
710
- const x_scale_for_ticks = final_x_axis.format?.startsWith(`%`)
711
- ? scaleTime().domain([new Date(x_min), new Date(x_max)])
712
- : create_scale(final_x_axis.scale_type ?? `linear`, [x_min, x_max], [0, 1]);
1202
+ const x_scale_for_ticks = is_time_x
1203
+ ? scaleTime().domain([new Date(x_min), new Date(x_max)])
1204
+ : create_scale(final_x_axis.scale_type ?? `linear`, [x_min, x_max], [0, 1])
1205
+
1206
+ const x2_scale_for_ticks = is_time_x2
1207
+ ? scaleTime().domain([new Date(x2_min), new Date(x2_max)])
1208
+ : create_scale(final_x2_axis.scale_type ?? `linear`, [x2_min, x2_max], [0, 1])
1209
+
713
1210
  return {
714
- x: generate_ticks([x_min, x_max], final_x_axis.scale_type ?? `linear`, final_x_axis.ticks, x_scale_for_ticks, { format: final_x_axis.format }),
715
- y: generate_ticks([y_min, y_max], final_y_axis.scale_type ?? `linear`, final_y_axis.ticks, y_scale_fn, { default_count: 5 }),
716
- y2: y2_points.length > 0
717
- ? generate_ticks([y2_min, y2_max], final_y2_axis.scale_type ?? `linear`, final_y2_axis.ticks, y2_scale_fn, { default_count: 5 })
718
- : [],
719
- };
720
- });
721
- let x_tick_values = $derived(axis_ticks.x);
722
- let y_tick_values = $derived(axis_ticks.y);
723
- let y2_tick_values = $derived(axis_ticks.y2);
724
- // Define global handlers reference for adding/removing listeners
725
- const on_window_mouse_move = (evt) => {
726
- if (!drag_start_coords || !svg_bounding_box)
727
- return; // Exit if not dragging or no bounds
1211
+ x: generate_ticks(
1212
+ [x_min, x_max],
1213
+ final_x_axis.scale_type ?? `linear`,
1214
+ final_x_axis.ticks,
1215
+ x_scale_for_ticks,
1216
+ { format: final_x_axis.format },
1217
+ ),
1218
+ x2: x2_points.length > 0
1219
+ ? generate_ticks(
1220
+ [x2_min, x2_max],
1221
+ final_x2_axis.scale_type ?? `linear`,
1222
+ final_x2_axis.ticks,
1223
+ x2_scale_for_ticks,
1224
+ { format: final_x2_axis.format },
1225
+ )
1226
+ : [],
1227
+ y: generate_ticks(
1228
+ [y_min, y_max],
1229
+ final_y_axis.scale_type ?? `linear`,
1230
+ final_y_axis.ticks,
1231
+ y_scale_fn,
1232
+ { default_count: 5 },
1233
+ ),
1234
+ y2: y2_points.length > 0
1235
+ ? generate_ticks(
1236
+ [y2_min, y2_max],
1237
+ final_y2_axis.scale_type ?? `linear`,
1238
+ final_y2_axis.ticks,
1239
+ y2_scale_fn,
1240
+ { default_count: 5 },
1241
+ )
1242
+ : [],
1243
+ }
1244
+ })
1245
+
1246
+ let x_tick_values = $derived(axis_ticks.x)
1247
+ let x2_tick_values = $derived(axis_ticks.x2)
1248
+ let y_tick_values = $derived(axis_ticks.y)
1249
+ let y2_tick_values = $derived(axis_ticks.y2)
1250
+
1251
+ // Cache measured tick-label widths so expensive text measurement only runs
1252
+ // when tick values/format change, not on every template rerender.
1253
+ let tick_label_widths = $derived({
1254
+ x2_max: measure_max_tick_width(x2_tick_values, final_x2_axis.format ?? ``),
1255
+ y_max: measure_max_tick_width(y_tick_values, final_y_axis.format ?? ``),
1256
+ y2_max: measure_max_tick_width(y2_tick_values, final_y2_axis.format ?? ``),
1257
+ })
1258
+
1259
+ // Define global handlers reference for adding/removing listeners
1260
+ const on_window_mouse_move = (evt: MouseEvent) => {
1261
+ if (!drag_start_coords || !svg_bounding_box) return // Exit if not dragging or no bounds
1262
+
728
1263
  // Calculate mouse position relative to the stored SVG bounding box
729
- const current_x = evt.clientX - svg_bounding_box.left;
730
- const current_y = evt.clientY - svg_bounding_box.top;
731
- drag_current_coords = { x: current_x, y: current_y };
1264
+ const current_x = evt.clientX - svg_bounding_box.left
1265
+ const current_y = evt.clientY - svg_bounding_box.top
1266
+ drag_current_coords = { x: current_x, y: current_y }
1267
+
732
1268
  // Optional: update tooltip only if inside SVG bounds
733
1269
  const is_inside_svg = current_x >= 0 &&
734
- current_x <= svg_bounding_box.width &&
735
- current_y >= 0 &&
736
- current_y <= svg_bounding_box.height;
1270
+ current_x <= svg_bounding_box.width &&
1271
+ current_y >= 0 &&
1272
+ current_y <= svg_bounding_box.height
1273
+
737
1274
  if (is_inside_svg) {
738
- // Use the already calculated relative coordinates
739
- update_tooltip_point(current_x, current_y);
740
- }
741
- else
742
- tooltip_point = null; // Clear tooltip if outside
743
- };
744
- const on_window_mouse_up = (_evt) => {
1275
+ // Use the already calculated relative coordinates
1276
+ update_tooltip_point(current_x, current_y)
1277
+ } else tooltip_point = null // Clear tooltip if outside
1278
+ }
1279
+
1280
+ const on_window_mouse_up = (_evt: MouseEvent) => {
745
1281
  if (drag_start_coords && drag_current_coords) {
746
- // Use current scales to invert screen coords to data coords
747
- const start_data_x_val = x_scale_fn.invert(drag_start_coords.x);
748
- const end_data_x_val = x_scale_fn.invert(drag_current_coords.x);
749
- const start_data_y_val = y_scale_fn.invert(drag_start_coords.y);
750
- const end_data_y_val = y_scale_fn.invert(drag_current_coords.y);
751
- // Ensure range is not zero and order is correct
752
- let x1, x2;
753
- if (start_data_x_val instanceof Date && end_data_x_val instanceof Date) {
754
- x1 = start_data_x_val.getTime();
755
- x2 = end_data_x_val.getTime();
756
- }
757
- else if (typeof start_data_x_val === `number` &&
758
- typeof end_data_x_val === `number`) {
759
- x1 = start_data_x_val;
760
- x2 = end_data_x_val;
761
- }
762
- else {
763
- console.error(`Mismatched types for x-axis zoom calculation`);
764
- // Reset states without zooming if types are wrong
765
- drag_start_coords = null;
766
- drag_current_coords = null;
767
- window.removeEventListener(`mousemove`, on_window_mouse_move);
768
- window.removeEventListener(`mouseup`, on_window_mouse_up);
769
- return;
770
- }
771
- const next_x_range = [Math.min(x1, x2), Math.max(x1, x2)];
772
- // Y axis is always number
773
- const next_y_range = [
774
- Math.min(start_data_y_val, end_data_y_val),
775
- Math.max(start_data_y_val, end_data_y_val),
776
- ];
777
- // Check for minuscule zoom box (e.g. accidental click)
778
- const min_zoom_size = 5; // Minimum pixels to trigger zoom
779
- const dx = Math.abs(drag_start_coords.x - drag_current_coords.x);
780
- const dy = Math.abs(drag_start_coords.y - drag_current_coords.y);
781
- if (dx > min_zoom_size &&
782
- dy > min_zoom_size &&
783
- next_x_range[0] !== next_x_range[1] &&
784
- next_y_range[0] !== next_y_range[1]) {
785
- // Update axis ranges to trigger reactivity (like BarPlot/Histogram do)
786
- // Y2 sync is handled by the effect that reacts to y_axis changes
787
- x_axis = { ...x_axis, range: next_x_range };
788
- y_axis = { ...y_axis, range: next_y_range };
1282
+ // Use current scales to invert screen coords to data coords
1283
+ const start_data_x_val = x_scale_fn.invert(drag_start_coords.x)
1284
+ const end_data_x_val = x_scale_fn.invert(drag_current_coords.x)
1285
+ const start_data_y_val = y_scale_fn.invert(drag_start_coords.y)
1286
+ const end_data_y_val = y_scale_fn.invert(drag_current_coords.y)
1287
+
1288
+ // Ensure range is not zero and order is correct
1289
+ let x1: number, x2: number
1290
+ if (start_data_x_val instanceof Date && end_data_x_val instanceof Date) {
1291
+ x1 = start_data_x_val.getTime()
1292
+ x2 = end_data_x_val.getTime()
1293
+ } else if (
1294
+ typeof start_data_x_val === `number` &&
1295
+ typeof end_data_x_val === `number`
1296
+ ) {
1297
+ x1 = start_data_x_val
1298
+ x2 = end_data_x_val
1299
+ } else {
1300
+ console.error(`Mismatched types for x-axis zoom calculation`)
1301
+ // Reset states without zooming if types are wrong
1302
+ drag_start_coords = null
1303
+ drag_current_coords = null
1304
+ window.removeEventListener(`mousemove`, on_window_mouse_move)
1305
+ window.removeEventListener(`mouseup`, on_window_mouse_up)
1306
+ return
1307
+ }
1308
+
1309
+ const next_x_range: [number, number] = [Math.min(x1, x2), Math.max(x1, x2)]
1310
+ // Y axis is always number
1311
+ const next_y_range: [number, number] = [
1312
+ Math.min(start_data_y_val, end_data_y_val),
1313
+ Math.max(start_data_y_val, end_data_y_val),
1314
+ ]
1315
+
1316
+ // Check for minuscule zoom box (e.g. accidental click)
1317
+ const min_zoom_size = 5 // Minimum pixels to trigger zoom
1318
+ const dx = Math.abs(drag_start_coords.x - drag_current_coords.x)
1319
+ const dy = Math.abs(drag_start_coords.y - drag_current_coords.y)
1320
+
1321
+ if (
1322
+ dx > min_zoom_size &&
1323
+ dy > min_zoom_size &&
1324
+ next_x_range[0] !== next_x_range[1] &&
1325
+ next_y_range[0] !== next_y_range[1]
1326
+ ) {
1327
+ // Update axis ranges to trigger reactivity (like BarPlot/Histogram do)
1328
+ // Y2 sync is handled by the effect that reacts to y_axis changes
1329
+ x_axis = { ...x_axis, range: next_x_range }
1330
+ y_axis = { ...y_axis, range: next_y_range }
1331
+
1332
+ // X2 axis: invert screen coords using x2 scale
1333
+ if (x2_points.length > 0) {
1334
+ const start_x2_val = x2_scale_fn.invert(drag_start_coords.x)
1335
+ const end_x2_val = x2_scale_fn.invert(drag_current_coords.x)
1336
+ const x2_a = start_x2_val instanceof Date
1337
+ ? start_x2_val.getTime()
1338
+ : start_x2_val as number
1339
+ const x2_b = end_x2_val instanceof Date
1340
+ ? end_x2_val.getTime()
1341
+ : end_x2_val as number
1342
+ x2_axis = {
1343
+ ...x2_axis,
1344
+ range: [Math.min(x2_a, x2_b), Math.max(x2_a, x2_b)],
1345
+ }
789
1346
  }
1347
+ }
790
1348
  }
1349
+
791
1350
  // Reset states and remove listeners
792
- drag_start_coords = null;
793
- drag_current_coords = null;
794
- svg_bounding_box = null;
795
- window.removeEventListener(`mousemove`, on_window_mouse_move);
796
- window.removeEventListener(`mouseup`, on_window_mouse_up);
797
- document.body.style.cursor = `default`;
798
- };
799
- // Pan drag handlers
800
- const on_pan_move = (evt) => {
801
- if (!pan_drag_state)
802
- return;
803
- const dx = evt.clientX - pan_drag_state.start.x;
804
- const dy = evt.clientY - pan_drag_state.start.y;
1351
+ drag_start_coords = null
1352
+ drag_current_coords = null
1353
+ svg_bounding_box = null
1354
+ window.removeEventListener(`mousemove`, on_window_mouse_move)
1355
+ window.removeEventListener(`mouseup`, on_window_mouse_up)
1356
+ document.body.style.cursor = `default`
1357
+ }
1358
+
1359
+ // Pan drag handlers
1360
+ const on_pan_move = (evt: MouseEvent) => {
1361
+ if (!pan_drag_state) return
1362
+ const dx = evt.clientX - pan_drag_state.start.x
1363
+ const dy = evt.clientY - pan_drag_state.start.y
1364
+
805
1365
  // Convert pixel delta to data delta (note: drag direction is inverted for natural pan feel)
806
1366
  // Clamp to at least 1 to avoid Infinity deltas when padding equals container size
807
- const plot_width = Math.max(1, width - pad.l - pad.r);
808
- const plot_height = Math.max(1, height - pad.t - pad.b);
809
- const sensitivity = pan?.drag_sensitivity ?? 1;
810
- const x_delta = pixels_to_data_delta(-dx * sensitivity, pan_drag_state.initial_x_range, plot_width);
811
- const y_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y_range, plot_height);
812
- const y2_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y2_range, plot_height);
813
- zoom_x_range = pan_range(pan_drag_state.initial_x_range, x_delta);
814
- zoom_y_range = pan_range(pan_drag_state.initial_y_range, y_delta);
815
- zoom_y2_range = get_synced_y2(zoom_y_range, pan_range(pan_drag_state.initial_y2_range, y2_delta));
816
- };
817
- const on_pan_end = () => {
818
- pan_drag_state = null;
819
- document.body.style.cursor = ``;
820
- window.removeEventListener(`mousemove`, on_pan_move);
821
- window.removeEventListener(`mouseup`, on_pan_end);
822
- };
823
- function handle_mouse_down(evt) {
824
- if (!svg_element)
825
- return;
1367
+ const plot_width = Math.max(1, width - pad.l - pad.r)
1368
+ const plot_height = Math.max(1, height - pad.t - pad.b)
1369
+ const sensitivity = pan?.drag_sensitivity ?? 1
1370
+
1371
+ const x_delta = pixels_to_data_delta(
1372
+ -dx * sensitivity,
1373
+ pan_drag_state.initial_x_range,
1374
+ plot_width,
1375
+ )
1376
+ const x2_delta = pixels_to_data_delta(
1377
+ -dx * sensitivity,
1378
+ pan_drag_state.initial_x2_range,
1379
+ plot_width,
1380
+ )
1381
+ const y_delta = pixels_to_data_delta(
1382
+ dy * sensitivity,
1383
+ pan_drag_state.initial_y_range,
1384
+ plot_height,
1385
+ )
1386
+ const y2_delta = pixels_to_data_delta(
1387
+ dy * sensitivity,
1388
+ pan_drag_state.initial_y2_range,
1389
+ plot_height,
1390
+ )
1391
+
1392
+ zoom_x_range = pan_range(pan_drag_state.initial_x_range, x_delta)
1393
+ zoom_x2_range = pan_range(pan_drag_state.initial_x2_range, x2_delta)
1394
+ zoom_y_range = pan_range(pan_drag_state.initial_y_range, y_delta)
1395
+ zoom_y2_range = get_synced_y2(
1396
+ zoom_y_range,
1397
+ pan_range(pan_drag_state.initial_y2_range, y2_delta),
1398
+ )
1399
+ }
1400
+
1401
+ const on_pan_end = () => {
1402
+ pan_drag_state = null
1403
+ document.body.style.cursor = ``
1404
+ window.removeEventListener(`mousemove`, on_pan_move)
1405
+ window.removeEventListener(`mouseup`, on_pan_end)
1406
+ }
1407
+
1408
+ function handle_mouse_down(evt: MouseEvent) {
1409
+ if (!svg_element) return
1410
+
826
1411
  // Check if pan is enabled and shift is held for pan mode
827
- const pan_enabled = pan?.enabled !== false;
1412
+ const pan_enabled = pan?.enabled !== false
828
1413
  if (pan_enabled && evt.shiftKey) {
829
- evt.preventDefault();
830
- pan_drag_state = {
831
- start: { x: evt.clientX, y: evt.clientY },
832
- initial_x_range: [...zoom_x_range],
833
- initial_y_range: [...zoom_y_range],
834
- initial_y2_range: [...zoom_y2_range],
835
- };
836
- document.body.style.cursor = `grabbing`;
837
- window.addEventListener(`mousemove`, on_pan_move);
838
- window.addEventListener(`mouseup`, on_pan_end);
839
- return;
1414
+ evt.preventDefault()
1415
+ pan_drag_state = {
1416
+ start: { x: evt.clientX, y: evt.clientY },
1417
+ initial_x_range: [...zoom_x_range] as [number, number],
1418
+ initial_x2_range: [...zoom_x2_range] as [number, number],
1419
+ initial_y_range: [...zoom_y_range] as [number, number],
1420
+ initial_y2_range: [...zoom_y2_range] as [number, number],
1421
+ }
1422
+ document.body.style.cursor = `grabbing`
1423
+ window.addEventListener(`mousemove`, on_pan_move)
1424
+ window.addEventListener(`mouseup`, on_pan_end)
1425
+ return
840
1426
  }
1427
+
841
1428
  // Store bounding box first, then calculate coords using it
842
- svg_bounding_box = svg_element.getBoundingClientRect();
1429
+ svg_bounding_box = svg_element.getBoundingClientRect()
1430
+
843
1431
  // Calculate initial coords using the same bounding box that will be used during drag
844
- const initial_x = evt.clientX - svg_bounding_box.left;
845
- const initial_y = evt.clientY - svg_bounding_box.top;
846
- const coords = { x: initial_x, y: initial_y };
847
- drag_start_coords = coords;
848
- drag_current_coords = coords;
849
- window.addEventListener(`mousemove`, on_window_mouse_move);
850
- window.addEventListener(`mouseup`, on_window_mouse_up);
851
- document.body.style.cursor = `crosshair`;
852
- evt.preventDefault();
853
- }
854
- // Wheel handler for pan (requires focus and shift)
855
- function handle_wheel(evt) {
856
- const pan_enabled = pan?.enabled !== false;
1432
+ const initial_x = evt.clientX - svg_bounding_box.left
1433
+ const initial_y = evt.clientY - svg_bounding_box.top
1434
+ const coords = { x: initial_x, y: initial_y }
1435
+
1436
+ drag_start_coords = coords
1437
+ drag_current_coords = coords
1438
+
1439
+ window.addEventListener(`mousemove`, on_window_mouse_move)
1440
+ window.addEventListener(`mouseup`, on_window_mouse_up)
1441
+ document.body.style.cursor = `crosshair`
1442
+ evt.preventDefault()
1443
+ }
1444
+
1445
+ // Wheel handler for pan (requires focus and shift)
1446
+ function handle_wheel(evt: WheelEvent) {
1447
+ const pan_enabled = pan?.enabled !== false
857
1448
  // Only capture wheel when focused AND Shift is held
858
1449
  // Use shift_held state (tracked via keydown/keyup) for compatibility with synthetic events
859
- if (!pan_enabled || !is_focused || !shift_held)
860
- return;
861
- evt.preventDefault();
1450
+ if (!pan_enabled || !is_focused || !shift_held) return
1451
+
1452
+ evt.preventDefault()
1453
+
862
1454
  // Clamp to at least 1 to avoid Infinity deltas when padding equals container size
863
- const plot_width = Math.max(1, width - pad.l - pad.r);
864
- const plot_height = Math.max(1, height - pad.t - pad.b);
865
- const sensitivity = pan?.wheel_sensitivity ?? 1;
1455
+ const plot_width = Math.max(1, width - pad.l - pad.r)
1456
+ const plot_height = Math.max(1, height - pad.t - pad.b)
1457
+ const sensitivity = pan?.wheel_sensitivity ?? 1
1458
+
866
1459
  // Determine pan direction based on wheel delta
867
1460
  // deltaX for horizontal scroll (trackpad), deltaY for vertical
868
- const x_delta = pixels_to_data_delta(evt.deltaX * sensitivity, zoom_x_range, plot_width);
869
- const y_delta = pixels_to_data_delta(evt.deltaY * sensitivity, zoom_y_range, plot_height);
870
- const y2_delta = pixels_to_data_delta(evt.deltaY * sensitivity, zoom_y2_range, plot_height);
1461
+ const x_delta = pixels_to_data_delta(
1462
+ evt.deltaX * sensitivity,
1463
+ zoom_x_range,
1464
+ plot_width,
1465
+ )
1466
+ const x2_delta = pixels_to_data_delta(
1467
+ evt.deltaX * sensitivity,
1468
+ zoom_x2_range,
1469
+ plot_width,
1470
+ )
1471
+ const y_delta = pixels_to_data_delta(
1472
+ evt.deltaY * sensitivity,
1473
+ zoom_y_range,
1474
+ plot_height,
1475
+ )
1476
+ const y2_delta = pixels_to_data_delta(
1477
+ evt.deltaY * sensitivity,
1478
+ zoom_y2_range,
1479
+ plot_height,
1480
+ )
1481
+
871
1482
  if (Math.abs(evt.deltaX) > Math.abs(evt.deltaY)) {
872
- zoom_x_range = pan_range(zoom_x_range, x_delta);
873
- }
874
- else {
875
- zoom_y_range = pan_range(zoom_y_range, y_delta);
876
- zoom_y2_range = get_synced_y2(zoom_y_range, pan_range(zoom_y2_range, y2_delta));
1483
+ zoom_x_range = pan_range(zoom_x_range, x_delta)
1484
+ zoom_x2_range = pan_range(zoom_x2_range, x2_delta)
1485
+ } else {
1486
+ zoom_y_range = pan_range(zoom_y_range, y_delta)
1487
+ zoom_y2_range = get_synced_y2(zoom_y_range, pan_range(zoom_y2_range, y2_delta))
877
1488
  }
878
- }
879
- // Touch handlers for pinch-zoom and two-finger pan
880
- function handle_touch_start(evt) {
881
- const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false;
882
- if (!touch_enabled || evt.touches.length !== 2)
883
- return;
884
- evt.preventDefault();
885
- const touches = Array.from(evt.touches);
1489
+ }
1490
+
1491
+ // Touch handlers for pinch-zoom and two-finger pan
1492
+ function handle_touch_start(evt: TouchEvent) {
1493
+ const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false
1494
+ if (!touch_enabled || evt.touches.length !== 2) return
1495
+
1496
+ evt.preventDefault()
1497
+ const touches = Array.from(evt.touches)
886
1498
  touch_state = {
887
- start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
888
- initial_x_range: [...zoom_x_range],
889
- initial_y_range: [...zoom_y_range],
890
- initial_y2_range: [...zoom_y2_range],
891
- };
892
- }
893
- function handle_touch_move(evt) {
894
- if (!touch_state || evt.touches.length !== 2)
895
- return;
896
- evt.preventDefault();
897
- const [t1, t2] = Array.from(evt.touches);
898
- const [s1, s2] = touch_state.start_touches;
1499
+ start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
1500
+ initial_x_range: [...zoom_x_range] as [number, number],
1501
+ initial_x2_range: [...zoom_x2_range] as [number, number],
1502
+ initial_y_range: [...zoom_y_range] as [number, number],
1503
+ initial_y2_range: [...zoom_y2_range] as [number, number],
1504
+ }
1505
+ }
1506
+
1507
+ function handle_touch_move(evt: TouchEvent) {
1508
+ if (!touch_state || evt.touches.length !== 2) return
1509
+ evt.preventDefault()
1510
+
1511
+ const [t1, t2] = Array.from(evt.touches)
1512
+ const [s1, s2] = touch_state.start_touches
1513
+
899
1514
  // Calculate center movement for pan
900
- const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 };
1515
+ const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 }
901
1516
  const curr_center = {
902
- x: (t1.clientX + t2.clientX) / 2,
903
- y: (t1.clientY + t2.clientY) / 2,
904
- };
905
- const dx = curr_center.x - start_center.x;
906
- const dy = curr_center.y - start_center.y;
1517
+ x: (t1.clientX + t2.clientX) / 2,
1518
+ y: (t1.clientY + t2.clientY) / 2,
1519
+ }
1520
+ const dx = curr_center.x - start_center.x
1521
+ const dy = curr_center.y - start_center.y
1522
+
907
1523
  // Calculate pinch scale (curr/start so spread = zoom out, pinch = zoom in)
908
- const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y);
1524
+ const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y)
909
1525
  // Guard against zero-distance pinch to avoid Infinity scale
910
- if (start_dist < Number.EPSILON)
911
- return;
912
- const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
913
- const scale = curr_dist / start_dist;
1526
+ if (start_dist < Number.EPSILON) return
1527
+ const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
1528
+ const scale = curr_dist / start_dist
1529
+
914
1530
  // Clamp to at least 1 to avoid Infinity deltas when padding equals container size
915
- const plot_width = Math.max(1, width - pad.l - pad.r);
916
- const plot_height = Math.max(1, height - pad.t - pad.b);
1531
+ const plot_width = Math.max(1, width - pad.l - pad.r)
1532
+ const plot_height = Math.max(1, height - pad.t - pad.b)
1533
+
917
1534
  // If scale changed significantly, treat as pinch-zoom
918
1535
  // Also guard against scale being too small to avoid division by zero
919
1536
  if (Math.abs(scale - 1) > PINCH_ZOOM_THRESHOLD && scale > Number.EPSILON) {
920
- // Pinch zoom centered on gesture center
921
- // Divide by scale so spread (scale > 1) = smaller span (zoom in)
922
- const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0];
923
- const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0];
924
- const y2_span = touch_state.initial_y2_range[1] -
925
- touch_state.initial_y2_range[0];
926
- const x_center = (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2;
927
- const y_center = (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2;
928
- const y2_center = (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2;
929
- zoom_x_range = [x_center - x_span / scale / 2, x_center + x_span / scale / 2];
930
- zoom_y_range = [y_center - y_span / scale / 2, y_center + y_span / scale / 2];
931
- zoom_y2_range = get_synced_y2(zoom_y_range, [
932
- y2_center - y2_span / scale / 2,
933
- y2_center + y2_span / scale / 2,
934
- ]);
935
- }
936
- else {
937
- // Pan
938
- const x_delta = pixels_to_data_delta(-dx, touch_state.initial_x_range, plot_width);
939
- const y_delta = pixels_to_data_delta(dy, touch_state.initial_y_range, plot_height);
940
- const y2_delta = pixels_to_data_delta(dy, touch_state.initial_y2_range, plot_height);
941
- zoom_x_range = pan_range(touch_state.initial_x_range, x_delta);
942
- zoom_y_range = pan_range(touch_state.initial_y_range, y_delta);
943
- zoom_y2_range = get_synced_y2(zoom_y_range, pan_range(touch_state.initial_y2_range, y2_delta));
1537
+ // Pinch zoom centered on gesture center
1538
+ // Divide by scale so spread (scale > 1) = smaller span (zoom in)
1539
+ const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0]
1540
+ const x2_span = touch_state.initial_x2_range[1] -
1541
+ touch_state.initial_x2_range[0]
1542
+ const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0]
1543
+ const y2_span = touch_state.initial_y2_range[1] -
1544
+ touch_state.initial_y2_range[0]
1545
+ const x_center =
1546
+ (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2
1547
+ const x2_center =
1548
+ (touch_state.initial_x2_range[0] + touch_state.initial_x2_range[1]) / 2
1549
+ const y_center =
1550
+ (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2
1551
+ const y2_center =
1552
+ (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2
1553
+
1554
+ zoom_x_range = [x_center - x_span / scale / 2, x_center + x_span / scale / 2]
1555
+ zoom_x2_range = [
1556
+ x2_center - x2_span / scale / 2,
1557
+ x2_center + x2_span / scale / 2,
1558
+ ]
1559
+ zoom_y_range = [y_center - y_span / scale / 2, y_center + y_span / scale / 2]
1560
+ zoom_y2_range = get_synced_y2(zoom_y_range, [
1561
+ y2_center - y2_span / scale / 2,
1562
+ y2_center + y2_span / scale / 2,
1563
+ ])
1564
+ } else {
1565
+ // Pan
1566
+ const x_delta = pixels_to_data_delta(
1567
+ -dx,
1568
+ touch_state.initial_x_range,
1569
+ plot_width,
1570
+ )
1571
+ const x2_delta = pixels_to_data_delta(
1572
+ -dx,
1573
+ touch_state.initial_x2_range,
1574
+ plot_width,
1575
+ )
1576
+ const y_delta = pixels_to_data_delta(
1577
+ dy,
1578
+ touch_state.initial_y_range,
1579
+ plot_height,
1580
+ )
1581
+ const y2_delta = pixels_to_data_delta(
1582
+ dy,
1583
+ touch_state.initial_y2_range,
1584
+ plot_height,
1585
+ )
1586
+ zoom_x_range = pan_range(touch_state.initial_x_range, x_delta)
1587
+ zoom_x2_range = pan_range(touch_state.initial_x2_range, x2_delta)
1588
+ zoom_y_range = pan_range(touch_state.initial_y_range, y_delta)
1589
+ zoom_y2_range = get_synced_y2(
1590
+ zoom_y_range,
1591
+ pan_range(touch_state.initial_y2_range, y2_delta),
1592
+ )
944
1593
  }
945
- }
946
- function handle_touch_end() {
947
- touch_state = null;
948
- }
949
- // tooltip logic: find closest point and update tooltip state
950
- function update_tooltip_point(x_rel, y_rel, evt) {
951
- if (!width || !height)
952
- return;
953
- let closest_point = null;
954
- let closest_series = null;
955
- let min_screen_dist_sq = Infinity;
956
- const { threshold_px = 20 } = hover_config; // Use configured threshold
957
- const hover_threshold_px_sq = threshold_px * threshold_px;
1594
+ }
1595
+
1596
+ function handle_touch_end() {
1597
+ touch_state = null
1598
+ }
1599
+
1600
+ // tooltip logic: find closest point and update tooltip state
1601
+ function update_tooltip_point(x_rel: number, y_rel: number, evt?: MouseEvent) {
1602
+ if (!width || !height) return
1603
+
1604
+ let closest_point: InternalPoint<Metadata> | null = null
1605
+ let closest_series: DataSeries<Metadata> | null = null
1606
+ let min_screen_dist_sq = Infinity
1607
+ const { threshold_px = 20 } = hover_config // Use configured threshold
1608
+ const hover_threshold_px_sq = threshold_px * threshold_px
1609
+
958
1610
  // Iterate through points to find the closest one in screen coordinates
959
1611
  for (const series_data of filtered_series) {
960
- if (!series_data?.filtered_data)
961
- continue;
962
- for (const point of series_data.filtered_data) {
963
- // Calculate screen coordinates of the point
964
- const point_cx = final_x_axis.format?.startsWith(`%`)
965
- ? x_scale_fn(new Date(point.x))
966
- : x_scale_fn(point.x);
967
- const point_cy = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
968
- // Calculate squared screen distance between mouse and point
969
- const screen_dx = x_rel - point_cx;
970
- const screen_dy = y_rel - point_cy;
971
- const screen_distance_sq = screen_dx * screen_dx + screen_dy * screen_dy;
972
- // Update if this point is closer
973
- if (screen_distance_sq < min_screen_dist_sq) {
974
- min_screen_dist_sq = screen_distance_sq;
975
- closest_point = point;
976
- closest_series = series_data;
977
- }
1612
+ if (!series_data?.filtered_data) continue
1613
+
1614
+ const tooltip_use_x2 = series_data.x_axis === `x2`
1615
+ const tooltip_x_scale = tooltip_use_x2 ? x2_scale_fn : x_scale_fn
1616
+ const tooltip_is_time_x = tooltip_use_x2 ? is_time_x2 : is_time_x
1617
+ for (const point of series_data.filtered_data) {
1618
+ // Calculate screen coordinates of the point
1619
+ const point_cx = tooltip_is_time_x
1620
+ ? tooltip_x_scale(new Date(point.x))
1621
+ : tooltip_x_scale(point.x)
1622
+ const point_cy = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(
1623
+ point.y,
1624
+ )
1625
+
1626
+ // Calculate squared screen distance between mouse and point
1627
+ const screen_dx = x_rel - point_cx
1628
+ const screen_dy = y_rel - point_cy
1629
+ const screen_distance_sq = screen_dx * screen_dx + screen_dy * screen_dy
1630
+
1631
+ // Update if this point is closer
1632
+ if (screen_distance_sq < min_screen_dist_sq) {
1633
+ min_screen_dist_sq = screen_distance_sq
1634
+ closest_point = point
1635
+ closest_series = series_data
978
1636
  }
1637
+ }
979
1638
  }
1639
+
980
1640
  // Check if the closest point is within the hover threshold
981
- if (closest_point &&
982
- closest_series &&
983
- min_screen_dist_sq <= hover_threshold_px_sq) {
984
- // Construct handler props synchronously to avoid stale derived reads
985
- const props = construct_handler_props(closest_point);
986
- tooltip_point = closest_point;
987
- // Construct object matching change signature
988
- const { x, y, metadata } = closest_point;
989
- change({ x, y, metadata, series: closest_series });
990
- // Call hover handler with synchronously constructed props
991
- if (evt && props) {
992
- on_point_hover?.({ ...props, event: evt, point: closest_point });
993
- }
994
- }
995
- else {
996
- tooltip_point = null;
997
- change(null);
998
- on_point_hover?.(null);
1641
+ if (
1642
+ closest_point &&
1643
+ closest_series &&
1644
+ min_screen_dist_sq <= hover_threshold_px_sq
1645
+ ) {
1646
+ // Construct handler props synchronously to avoid stale derived reads
1647
+ const props = construct_handler_props(closest_point)
1648
+ tooltip_point = closest_point
1649
+ // Construct object matching change signature
1650
+ const { x, y, metadata } = closest_point
1651
+ change({ x, y, metadata, series: closest_series })
1652
+ // Call hover handler with synchronously constructed props
1653
+ if (evt && props) {
1654
+ on_point_hover?.({ ...props, event: evt, point: closest_point })
1655
+ }
1656
+ } else {
1657
+ tooltip_point = null
1658
+ change(null)
1659
+ on_point_hover?.(null)
999
1660
  }
1000
- }
1001
- function on_mouse_move(evt) {
1002
- hovered = true;
1003
- const coords = get_relative_coords(evt);
1004
- if (!coords)
1005
- return;
1006
- update_tooltip_point(coords.x, coords.y, evt);
1007
- }
1008
- // Merge user config with defaults before the effect that uses it
1009
- let actual_label_config = $derived({
1010
- collision_strength: 1.5, // Increased from 1.1 for stronger overlap prevention
1011
- link_strength: 0.8,
1012
- link_distance: 10,
1013
- placement_ticks: 200, // Increased from 120 for better settling
1014
- link_distance_range: [5, 20], // Default min and max distance (replacing max_link_distance)
1015
- max_labels: 300, // Maximum labels before falling back to simple offsets
1016
- charge_strength: 50, // Repulsion strength for markers
1017
- charge_distance_max: 30, // Limit range of repulsion
1661
+ }
1662
+
1663
+ function on_mouse_move(evt: MouseEvent) {
1664
+ hovered = true
1665
+
1666
+ const coords = get_relative_coords(evt)
1667
+ if (!coords) return
1668
+
1669
+ update_tooltip_point(coords.x, coords.y, evt)
1670
+ }
1671
+
1672
+ // Merge user config with defaults before the effect that uses it
1673
+ let actual_label_config = $derived({
1674
+ sa_iterations: 2000,
1675
+ max_labels: 300,
1676
+ leader_line_threshold: 15,
1018
1677
  ...label_placement_config,
1019
- });
1020
- $effect(() => {
1678
+ })
1679
+
1680
+ $effect(() => {
1021
1681
  if (!width || !height) {
1022
- label_positions = {};
1023
- return;
1682
+ label_positions = {}
1683
+ return
1024
1684
  }
1025
- label_positions = compute_label_positions(filtered_series, actual_label_config, { x_scale_fn, y_scale_fn, y2_scale_fn, x_axis: final_x_axis }, { width, height, pad });
1026
- });
1027
- // Legend drag handlers
1028
- function handle_legend_drag_start(event) {
1029
- if (!svg_element)
1030
- return;
1031
- legend_is_dragging = true;
1685
+
1686
+ label_positions = compute_label_positions(
1687
+ filtered_series,
1688
+ actual_label_config,
1689
+ { x_scale_fn, y_scale_fn, y2_scale_fn, x_axis: final_x_axis },
1690
+ { width, height, pad },
1691
+ )
1692
+ })
1693
+
1694
+ // Legend drag handlers
1695
+ function handle_legend_drag_start(event: MouseEvent) {
1696
+ if (!svg_element) return
1697
+
1698
+ legend_is_dragging = true
1699
+
1032
1700
  // Get the actual rendered position of the legend element (accounts for transforms)
1033
- const legend_el = event.currentTarget;
1034
- const legend_rect = legend_el.getBoundingClientRect();
1701
+ const legend_el = event.currentTarget
1702
+ if (!(legend_el instanceof HTMLElement)) return
1703
+ const legend_rect = legend_el.getBoundingClientRect()
1704
+
1035
1705
  // Calculate offset from mouse to legend's actual rendered position relative to SVG
1036
- const [x, y] = [event.clientX - legend_rect.left, event.clientY - legend_rect.top];
1037
- legend_drag_offset = { x, y };
1038
- }
1039
- function handle_legend_drag(event) {
1040
- if (!legend_is_dragging || !svg_element || !legend_element)
1041
- return;
1042
- const svg_rect = svg_element.getBoundingClientRect();
1706
+ const [x, y] = [event.clientX - legend_rect.left, event.clientY - legend_rect.top]
1707
+ legend_drag_offset = { x, y }
1708
+ }
1709
+
1710
+ function handle_legend_drag(event: MouseEvent) {
1711
+ if (!legend_is_dragging || !svg_element || !legend_element) return
1712
+
1713
+ const svg_rect = svg_element.getBoundingClientRect()
1714
+
1043
1715
  // Calculate new position: mouse position relative to SVG, minus the offset within the legend
1044
- const new_x = event.clientX - svg_rect.left - legend_drag_offset.x;
1045
- const new_y = event.clientY - svg_rect.top - legend_drag_offset.y;
1716
+ const new_x = event.clientX - svg_rect.left - legend_drag_offset.x
1717
+ const new_y = event.clientY - svg_rect.top - legend_drag_offset.y
1718
+
1046
1719
  // Get actual legend dimensions for accurate bounds checking using the bound element reference
1047
1720
  const { width: legend_width, height: legend_height } = legend_element
1048
- .getBoundingClientRect();
1721
+ .getBoundingClientRect()
1722
+
1049
1723
  // Constrain to plot bounds using measured legend size
1050
- const constrained_x = Math.max(0, Math.min(width - legend_width, new_x));
1051
- const constrained_y = Math.max(0, Math.min(height - legend_height, new_y));
1052
- legend_manual_position = { x: constrained_x, y: constrained_y };
1053
- }
1054
- function get_screen_coords(point, series) {
1724
+ const constrained_x = Math.max(0, Math.min(width - legend_width, new_x))
1725
+ const constrained_y = Math.max(0, Math.min(height - legend_height, new_y))
1726
+
1727
+ legend_manual_position = { x: constrained_x, y: constrained_y }
1728
+ }
1729
+
1730
+ function get_screen_coords(point: Point, series?: DataSeries): [number, number] {
1055
1731
  // convert data coordinates to potentially non-finite screen coordinates
1056
- const screen_x = final_x_axis.format?.startsWith(`%`)
1057
- ? x_scale_fn(new Date(point.x))
1058
- : x_scale_fn(point.x);
1059
- const y_val = point.y;
1732
+ const use_x2 = series?.x_axis === `x2`
1733
+ const active_x_scale = use_x2 ? x2_scale_fn : x_scale_fn
1734
+ const active_is_time_x = use_x2 ? is_time_x2 : is_time_x
1735
+ const screen_x = active_is_time_x
1736
+ ? active_x_scale(new Date(point.x))
1737
+ : active_x_scale(point.x)
1738
+
1739
+ const y_val = point.y
1060
1740
  // Determine which y-scale to use based on series y_axis property
1061
- const use_y2 = series?.y_axis === `y2`;
1062
- const y_scale = use_y2 ? y2_scale_fn : y_scale_fn;
1741
+ const use_y2 = series?.y_axis === `y2`
1742
+ const y_scale = use_y2 ? y2_scale_fn : y_scale_fn
1063
1743
  const y_scale_type = use_y2
1064
- ? get_scale_type_name(final_y2_axis.scale_type)
1065
- : get_scale_type_name(final_y_axis.scale_type);
1744
+ ? get_scale_type_name(final_y2_axis.scale_type)
1745
+ : get_scale_type_name(final_y_axis.scale_type)
1066
1746
  // Only log scale needs domain clamping; linear and arcsinh can handle any value
1067
- const min_domain_y = y_scale_type === `log` ? y_scale.domain()[0] : -Infinity;
1068
- const safe_y_val = y_scale_type === `log` ? Math.max(y_val, min_domain_y) : y_val;
1069
- const screen_y = y_scale(safe_y_val); // This might be non-finite
1070
- return [screen_x, screen_y];
1071
- }
1072
- // Helper function to construct ScatterHandlerProps synchronously from InternalPoint
1073
- function construct_handler_props(point) {
1074
- const hovered_series = series_with_ids[point.series_idx];
1075
- if (!hovered_series)
1076
- return null;
1077
- const { x, y, color_value, metadata, series_idx } = point;
1078
- const cx = final_x_axis.format?.startsWith(`%`)
1079
- ? x_scale_fn(new Date(x))
1080
- : x_scale_fn(x);
1081
- const cy = (hovered_series.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(y);
1747
+ const min_domain_y = y_scale_type === `log` ? y_scale.domain()[0] : -Infinity
1748
+ const safe_y_val = y_scale_type === `log` ? Math.max(y_val, min_domain_y) : y_val
1749
+ const screen_y = y_scale(safe_y_val) // This might be non-finite
1750
+
1751
+ return [screen_x, screen_y]
1752
+ }
1753
+
1754
+ // Helper function to construct ScatterHandlerProps synchronously from InternalPoint
1755
+ function construct_handler_props(
1756
+ point: InternalPoint<Metadata>,
1757
+ ): ScatterHandlerProps<Metadata> | null {
1758
+ const hovered_series = series_with_ids[point.series_idx]
1759
+ if (!hovered_series) return null
1760
+ const { x, y, color_value, metadata, series_idx } = point
1761
+ const handler_use_x2 = hovered_series.x_axis === `x2`
1762
+ const handler_x_scale = handler_use_x2 ? x2_scale_fn : x_scale_fn
1763
+ const handler_is_time_x = handler_use_x2 ? is_time_x2 : is_time_x
1764
+ const cx = handler_is_time_x ? handler_x_scale(new Date(x)) : handler_x_scale(x)
1765
+ const cy = (hovered_series.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(y)
1766
+ const active_x_config = handler_use_x2 ? final_x2_axis : final_x_axis
1767
+ const active_y_config = hovered_series.y_axis === `y2`
1768
+ ? final_y2_axis
1769
+ : final_y_axis
1082
1770
  const coords = {
1083
- x,
1084
- y,
1085
- cx,
1086
- cy,
1087
- x_axis: final_x_axis,
1088
- y_axis: final_y_axis,
1089
- y2_axis: final_y2_axis,
1090
- };
1771
+ x,
1772
+ y,
1773
+ cx,
1774
+ cy,
1775
+ x_axis: active_x_config,
1776
+ x2_axis: final_x2_axis,
1777
+ y_axis: active_y_config,
1778
+ y2_axis: final_y2_axis,
1779
+ }
1091
1780
  return {
1092
- ...coords,
1093
- fullscreen,
1094
- metadata,
1095
- label: hovered_series.label ?? null,
1096
- series_idx,
1097
- x_formatted: format_value(x, final_x_axis.format || `.3~s`),
1098
- y_formatted: format_value(y, (hovered_series.y_axis === `y2`
1099
- ? final_y2_axis.format
1100
- : final_y_axis.format) || `.3~s`),
1101
- color_value: color_value ?? null,
1102
- colorbar: {
1103
- value: color_value ?? null,
1104
- title: color_bar?.title ?? null,
1105
- scale: color_scale,
1106
- tick_format: color_bar?.tick_format ?? null,
1107
- },
1108
- };
1109
- }
1110
- // Derive handler props from hovered point for both tooltip and event handlers
1111
- let handler_props = $derived.by(() => {
1112
- if (!tooltip_point)
1113
- return null;
1114
- return construct_handler_props(tooltip_point);
1115
- });
1116
- let using_controls = $derived(controls.show);
1117
- let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1118
- // Set theme-aware background when entering fullscreen
1119
- $effect(() => {
1120
- set_fullscreen_bg(wrapper, fullscreen, `--scatter-fullscreen-bg`);
1121
- });
1122
- // State accessors for shared axis change handler
1123
- const axis_state = {
1124
- get_axis: (axis) => (axis === `x` ? x_axis : axis === `y` ? y_axis : y2_axis),
1781
+ ...coords,
1782
+ fullscreen,
1783
+ metadata,
1784
+ label: hovered_series.label ?? null,
1785
+ series_idx,
1786
+ x_formatted: format_value(x, active_x_config.format || `.3~s`),
1787
+ y_formatted: format_value(y, active_y_config.format || `.3~s`),
1788
+ color_value: color_value ?? null,
1789
+ colorbar: {
1790
+ value: color_value ?? null,
1791
+ title: color_bar?.title ?? null,
1792
+ scale: color_scale,
1793
+ tick_format: color_bar?.tick_format ?? null,
1794
+ },
1795
+ }
1796
+ }
1797
+
1798
+ // Derive handler props from hovered point for both tooltip and event handlers
1799
+ let handler_props = $derived.by((): ScatterHandlerProps<Metadata> | null => {
1800
+ if (!tooltip_point) return null
1801
+ return construct_handler_props(tooltip_point)
1802
+ })
1803
+
1804
+ let using_controls = $derived(controls.show)
1805
+ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1)
1806
+
1807
+ // Precompute non-click event names from point_events so we don't rebuild
1808
+ // the entries array on every point render.
1809
+ let point_event_names = $derived(
1810
+ point_events
1811
+ ? Object.keys(point_events).filter((name) => name !== `onclick`)
1812
+ : [],
1813
+ )
1814
+
1815
+ // Set theme-aware background when entering fullscreen
1816
+ $effect(() => {
1817
+ set_fullscreen_bg(wrapper, fullscreen, `--scatter-fullscreen-bg`)
1818
+ })
1819
+
1820
+ // State accessors for shared axis change handler
1821
+ const axis_state: AxisChangeState<DataSeries<Metadata>> = {
1822
+ get_axis: (axis) => {
1823
+ if (axis === `x`) return x_axis
1824
+ if (axis === `x2`) return x2_axis
1825
+ if (axis === `y`) return y_axis
1826
+ return y2_axis
1827
+ },
1125
1828
  set_axis: (axis, config) => {
1126
- // Spread into existing state to preserve merged type structure
1127
- if (axis === `x`)
1128
- x_axis = { ...x_axis, ...config };
1129
- else if (axis === `y`)
1130
- y_axis = { ...y_axis, ...config };
1131
- else
1132
- y2_axis = { ...y2_axis, ...config };
1829
+ // Spread into existing state to preserve merged type structure
1830
+ if (axis === `x`) x_axis = { ...x_axis, ...config }
1831
+ else if (axis === `x2`) x2_axis = { ...x2_axis, ...config }
1832
+ else if (axis === `y`) y_axis = { ...y_axis, ...config }
1833
+ else y2_axis = { ...y2_axis, ...config }
1133
1834
  },
1134
1835
  get_series: () => series,
1135
1836
  set_series: (new_series) => (series = new_series),
1136
1837
  get_loading: () => axis_loading,
1137
1838
  set_loading: (axis) => (axis_loading = axis),
1138
- };
1139
- // Create shared handler bound to this component's state
1140
- // Using $derived so handler updates when callback props change
1141
- const handle_axis_change = $derived(create_axis_change_handler(axis_state, data_loader, on_axis_change, on_error));
1142
- let auto_load_attempted = false; // prevent infinite retries on failure
1143
- // Auto-load data if series is empty but options exist (runs once)
1144
- $effect(() => {
1839
+ }
1840
+
1841
+ // Create shared handler bound to this component's state
1842
+ // Using $derived so handler updates when callback props change
1843
+ const handle_axis_change = $derived(create_axis_change_handler(
1844
+ axis_state,
1845
+ data_loader,
1846
+ on_axis_change,
1847
+ on_error,
1848
+ ))
1849
+
1850
+ let auto_load_attempted = false // prevent infinite retries on failure
1851
+
1852
+ // Auto-load data if series is empty but options exist (runs once)
1853
+ $effect(() => {
1145
1854
  if (series.length === 0 && data_loader && !auto_load_attempted) {
1146
- // Check x-axis first, then y-axis
1147
- if (x_axis.options?.length) {
1148
- auto_load_attempted = true;
1149
- const first_key = x_axis.selected_key ?? x_axis.options[0].key;
1150
- handle_axis_change(`x`, first_key).catch(() => { });
1151
- }
1152
- else if (y_axis.options?.length) {
1153
- auto_load_attempted = true;
1154
- const first_key = y_axis.selected_key ?? y_axis.options[0].key;
1155
- handle_axis_change(`y`, first_key).catch(() => { });
1156
- }
1855
+ // Check x-axis first, then y-axis
1856
+ if (x_axis.options?.length) {
1857
+ auto_load_attempted = true
1858
+ const first_key = x_axis.selected_key ?? x_axis.options[0].key
1859
+ handle_axis_change(`x`, first_key).catch(() => {})
1860
+ } else if (y_axis.options?.length) {
1861
+ auto_load_attempted = true
1862
+ const first_key = y_axis.selected_key ?? y_axis.options[0].key
1863
+ handle_axis_change(`y`, first_key).catch(() => {})
1864
+ }
1157
1865
  }
1158
- });
1866
+ })
1159
1867
  </script>
1160
1868
 
1161
1869
  {#snippet fill_regions_layer(fills: typeof computed_fills)}
@@ -1192,11 +1900,12 @@ $effect(() => {
1192
1900
  <ReferenceLine
1193
1901
  ref_line={line}
1194
1902
  line_idx={line.idx}
1195
- {x_min}
1196
- {x_max}
1903
+ x_min={line.x_axis === `x2` ? x2_min : x_min}
1904
+ x_max={line.x_axis === `x2` ? x2_max : x_max}
1197
1905
  y_min={line.y_axis === `y2` ? y2_min : y_min}
1198
1906
  y_max={line.y_axis === `y2` ? y2_max : y_max}
1199
1907
  x_scale={x_scale_fn}
1908
+ x2_scale={x2_scale_fn}
1200
1909
  y_scale={y_scale_fn}
1201
1910
  y2_scale={y2_scale_fn}
1202
1911
  {clip_path_id}
@@ -1247,6 +1956,9 @@ $effect(() => {
1247
1956
  <svg
1248
1957
  bind:this={svg_element}
1249
1958
  role="application"
1959
+ aria-label={rest[`aria-label`] ??
1960
+ ([final_x_axis.label, final_y_axis.label].filter(Boolean).join(` vs `) ||
1961
+ `Scatter plot`)}
1250
1962
  tabindex="0"
1251
1963
  onfocusin={() => (is_focused = true)}
1252
1964
  onfocusout={() => (is_focused = false)}
@@ -1265,13 +1977,16 @@ $effect(() => {
1265
1977
  // Reset to current auto ranges (not stale initial_*_range which may have expanded)
1266
1978
  // This ensures lazy expansion restarts fresh from current data bounds
1267
1979
  initial_x_range = [...auto_x_range] as [number, number]
1980
+ initial_x2_range = [...auto_x2_range] as [number, number]
1268
1981
  initial_y_range = [...auto_y_range] as [number, number]
1269
1982
  initial_y2_range = [...auto_y2_range] as [number, number]
1270
1983
  zoom_x_range = [...auto_x_range] as [number, number]
1984
+ zoom_x2_range = [...auto_x2_range] as [number, number]
1271
1985
  zoom_y_range = [...auto_y_range] as [number, number]
1272
1986
  zoom_y2_range = get_synced_y2(auto_y_range, [...auto_y2_range] as Vec2)
1273
1987
  // Also reset axis props so future data changes recalculate auto ranges
1274
1988
  x_axis = { ...x_axis, range: [null, null] }
1989
+ x2_axis = { ...x2_axis, range: [null, null] }
1275
1990
  y_axis = { ...y_axis, range: [null, null] }
1276
1991
  y2_axis = { ...y2_axis, range: [null, null] }
1277
1992
  }}
@@ -1289,10 +2004,12 @@ $effect(() => {
1289
2004
  height,
1290
2005
  width,
1291
2006
  x_scale_fn,
2007
+ x2_scale_fn,
1292
2008
  y_scale_fn,
1293
2009
  y2_scale_fn,
1294
2010
  pad,
1295
2011
  x_range: [x_min, x_max],
2012
+ x2_range: [x2_min, x2_max],
1296
2013
  y_range: [y_min, y_max],
1297
2014
  y2_range: [y2_min, y2_max],
1298
2015
  fullscreen,
@@ -1306,9 +2023,7 @@ $effect(() => {
1306
2023
  <g class="x-axis">
1307
2024
  {#if width > 0 && height > 0}
1308
2025
  {#each x_tick_values as tick (tick)}
1309
- {@const tick_pos_raw = final_x_axis.format?.startsWith(`%`)
1310
- ? x_scale_fn(new Date(tick))
1311
- : x_scale_fn(tick)}
2026
+ {@const tick_pos_raw = is_time_x ? x_scale_fn(new Date(tick)) : x_scale_fn(tick)}
1312
2027
  {#if isFinite(tick_pos_raw)}
1313
2028
  // Check if tick position is finite
1314
2029
  {@const tick_pos = tick_pos_raw}
@@ -1325,14 +2040,19 @@ $effect(() => {
1325
2040
  {/if}
1326
2041
  <line y1="0" y2={inside ? -5 : 5} stroke="var(--border-color, gray)" />
1327
2042
 
1328
- {#if tick >= x_min && tick <= x_max}
2043
+ {#if tick >= Math.min(x_min, x_max) && tick <= Math.max(x_min, x_max)}
1329
2044
  {@const base_y = inside ? -8 : 20}
1330
2045
  {@const shift = final_x_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
1331
2046
  {@const x = shift.x ?? 0}
1332
2047
  {@const y = base_y + (shift.y ?? 0)}
1333
2048
  {@const custom_label = get_tick_label(tick, final_x_axis.ticks)}
1334
2049
  {@const dominant_baseline = inside ? `auto` : `hanging`}
1335
- <text {x} {y} dominant-baseline={dominant_baseline}>
2050
+ <text
2051
+ {x}
2052
+ {y}
2053
+ dominant-baseline={dominant_baseline}
2054
+ fill={final_x_axis.color}
2055
+ >
1336
2056
  {custom_label ?? format_value(tick, final_x_axis.format ?? ``)}
1337
2057
  </text>
1338
2058
  {/if}
@@ -1343,8 +2063,8 @@ $effect(() => {
1343
2063
  {/if}
1344
2064
 
1345
2065
  <!-- Current frame indicator -->
1346
- {#if current_x_value !== null && current_x_value !== undefined}
1347
- {@const current_pos_raw = final_x_axis.format?.startsWith(`%`)
2066
+ {#if current_x_value != null}
2067
+ {@const current_pos_raw = is_time_x
1348
2068
  ? x_scale_fn(new Date(current_x_value))
1349
2069
  : x_scale_fn(current_x_value)}
1350
2070
  {#if isFinite(current_pos_raw)}
@@ -1366,28 +2086,18 @@ $effect(() => {
1366
2086
  {/if}
1367
2087
 
1368
2088
  {#if final_x_axis.label || final_x_axis.options?.length}
1369
- <foreignObject
1370
- x={width / 2 + (final_x_axis.label_shift?.x ?? 0) -
1371
- AXIS_LABEL_CONTAINER.x_offset}
1372
- y={height - pad.b - (final_x_axis.label_shift?.y ?? -40) -
1373
- AXIS_LABEL_CONTAINER.y_offset}
1374
- width={AXIS_LABEL_CONTAINER.width}
1375
- height={AXIS_LABEL_CONTAINER.height}
1376
- style="overflow: visible; pointer-events: none"
1377
- >
1378
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
1379
- <InteractiveAxisLabel
1380
- label={final_x_axis.label ?? ``}
1381
- options={final_x_axis.options}
1382
- selected_key={final_x_axis.selected_key}
1383
- loading={axis_loading === `x`}
1384
- axis_type="x"
1385
- color={final_x_axis.color}
1386
- on_select={(key) => handle_axis_change(`x`, key)}
1387
- class="axis-label x-label"
1388
- />
1389
- </div>
1390
- </foreignObject>
2089
+ {@const { label_shift, label = ``, options, selected_key, color } = final_x_axis}
2090
+ <AxisLabel
2091
+ x={width / 2 + (label_shift?.x ?? 0)}
2092
+ y={height - pad.b - (label_shift?.y ?? -40)}
2093
+ {label}
2094
+ {options}
2095
+ {selected_key}
2096
+ loading={axis_loading === `x`}
2097
+ axis_type="x"
2098
+ {color}
2099
+ on_select={(key) => handle_axis_change(`x`, key)}
2100
+ />
1391
2101
  {/if}
1392
2102
  </g>
1393
2103
 
@@ -1415,7 +2125,7 @@ $effect(() => {
1415
2125
  stroke="var(--border-color, gray)"
1416
2126
  />
1417
2127
 
1418
- {#if tick >= y_min && tick <= y_max}
2128
+ {#if tick >= Math.min(y_min, y_max) && tick <= Math.max(y_min, y_max)}
1419
2129
  {@const base_x = inside ? 8 : -8}
1420
2130
  {@const shift = final_y_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
1421
2131
  {@const x = base_x + (shift.x ?? 0)}
@@ -1436,31 +2146,26 @@ $effect(() => {
1436
2146
  {/if}
1437
2147
 
1438
2148
  {#if height > 0 && (final_y_axis.label || final_y_axis.options?.length)}
1439
- <foreignObject
1440
- x={-AXIS_LABEL_CONTAINER.x_offset}
1441
- y={-AXIS_LABEL_CONTAINER.y_offset}
1442
- width={AXIS_LABEL_CONTAINER.width}
1443
- height={AXIS_LABEL_CONTAINER.height}
1444
- style="overflow: visible; pointer-events: none"
1445
- transform="rotate(-90, {(final_y_axis.label_shift?.y ?? 12)}, {pad.t +
1446
- (height - pad.t - pad.b) / 2 +
1447
- ((final_y_axis.label_shift?.x ?? 0))}) translate({(final_y_axis.label_shift?.y ?? 12)}, {pad.t +
1448
- (height - pad.t - pad.b) / 2 +
1449
- ((final_y_axis.label_shift?.x ?? 0))})"
1450
- >
1451
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
1452
- <InteractiveAxisLabel
1453
- label={final_y_axis.label ?? ``}
1454
- options={final_y_axis.options}
1455
- selected_key={final_y_axis.selected_key}
1456
- loading={axis_loading === `y`}
1457
- axis_type="y"
1458
- color={final_y_axis.color}
1459
- on_select={(key) => handle_axis_change(`y`, key)}
1460
- class="axis-label y-label"
1461
- />
1462
- </div>
1463
- </foreignObject>
2149
+ {@const { label_shift, label = ``, options, selected_key, color, tick } =
2150
+ final_y_axis}
2151
+ {@const y_inside = tick?.label?.inside ?? false}
2152
+ {@const y_label_x = Math.max(
2153
+ 12,
2154
+ pad.l - (y_inside ? 0 : tick_label_widths.y_max) - LABEL_GAP_DEFAULT,
2155
+ ) +
2156
+ (label_shift?.x ?? 0)}
2157
+ <AxisLabel
2158
+ x={y_label_x}
2159
+ y={pad.t + (height - pad.t - pad.b) / 2 + (label_shift?.y ?? 0)}
2160
+ rotate
2161
+ {label}
2162
+ {options}
2163
+ {selected_key}
2164
+ loading={axis_loading === `y`}
2165
+ axis_type="y"
2166
+ {color}
2167
+ on_select={(key) => handle_axis_change(`y`, key)}
2168
+ />
1464
2169
  {/if}
1465
2170
  </g>
1466
2171
 
@@ -1490,7 +2195,7 @@ $effect(() => {
1490
2195
  stroke="var(--border-color, gray)"
1491
2196
  />
1492
2197
 
1493
- {#if tick >= y2_min && tick <= y2_max}
2198
+ {#if tick >= Math.min(y2_min, y2_max) && tick <= Math.max(y2_min, y2_max)}
1494
2199
  {@const base_x = inside ? -8 : 8}
1495
2200
  {@const shift = final_y2_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
1496
2201
  {@const x = base_x + (shift.x ?? 0)}
@@ -1511,94 +2216,122 @@ $effect(() => {
1511
2216
  {/if}
1512
2217
 
1513
2218
  {#if height > 0 && (final_y2_axis.label || final_y2_axis.options?.length)}
1514
- <foreignObject
1515
- x={-AXIS_LABEL_CONTAINER.x_offset}
1516
- y={-AXIS_LABEL_CONTAINER.y_offset}
1517
- width={AXIS_LABEL_CONTAINER.width}
1518
- height={AXIS_LABEL_CONTAINER.height}
1519
- style="overflow: visible; pointer-events: none"
1520
- transform="rotate(-90, {width - pad.r + ((final_y2_axis.label_shift?.y ?? 0))}, {pad.t +
1521
- (height - pad.t - pad.b) / 2 +
1522
- ((final_y2_axis.label_shift?.x ?? 0))}) translate({width -
1523
- pad.r +
1524
- ((final_y2_axis.label_shift?.y ?? 0))}, {pad.t +
1525
- (height - pad.t - pad.b) / 2 +
1526
- ((final_y2_axis.label_shift?.x ?? 0))})"
1527
- >
1528
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
1529
- <InteractiveAxisLabel
1530
- label={final_y2_axis.label ?? ``}
1531
- options={final_y2_axis.options}
1532
- selected_key={final_y2_axis.selected_key}
1533
- loading={axis_loading === `y2`}
1534
- axis_type="y2"
1535
- color={final_y2_axis.color}
1536
- on_select={(key) => handle_axis_change(`y2`, key)}
1537
- class="axis-label y2-label"
1538
- />
1539
- </div>
1540
- </foreignObject>
2219
+ {@const { label_shift, label = ``, options, selected_key, color, tick } =
2220
+ final_y2_axis}
2221
+ {@const inside = tick?.label?.inside ?? false}
2222
+ {@const tick_shift = inside ? 0 : (tick?.label?.shift?.x ?? 0) + 8}
2223
+ {@const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max}
2224
+ <AxisLabel
2225
+ x={width - pad.r + tick_shift + tick_width_contribution +
2226
+ LABEL_GAP_DEFAULT + (label_shift?.x ?? 0)}
2227
+ y={pad.t + (height - pad.t - pad.b) / 2 + (label_shift?.y ?? 0)}
2228
+ rotate
2229
+ {label}
2230
+ {options}
2231
+ {selected_key}
2232
+ loading={axis_loading === `y2`}
2233
+ axis_type="y2"
2234
+ {color}
2235
+ on_select={(key) => handle_axis_change(`y2`, key)}
2236
+ />
1541
2237
  {/if}
1542
2238
  </g>
1543
2239
  {/if}
1544
2240
 
1545
- <!-- Tooltip rendered inside overlay (moved outside SVG for stacking above colorbar) -->
2241
+ <!-- X2-axis (Top) -->
2242
+ {#if x2_points.length > 0}
2243
+ <g class="x2-axis">
2244
+ {#if width > 0 && height > 0}
2245
+ {#each x2_tick_values as tick (tick)}
2246
+ {@const tick_pos_raw = is_time_x2
2247
+ ? x2_scale_fn(new Date(tick))
2248
+ : x2_scale_fn(tick)}
2249
+ {#if isFinite(tick_pos_raw)}
2250
+ {@const tick_pos = tick_pos_raw}
2251
+ {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
2252
+ {@const inside = final_x2_axis.tick?.label?.inside ?? false}
2253
+ <g class="tick" transform="translate({tick_pos}, {pad.t})">
2254
+ {#if final_display.x2_grid}
2255
+ <line
2256
+ y1="0"
2257
+ y2={height - pad.b - pad.t}
2258
+ {...DEFAULT_GRID_STYLE}
2259
+ {...(final_x2_axis.grid_style ?? {})}
2260
+ />
2261
+ {/if}
2262
+ <line
2263
+ y1="0"
2264
+ y2={inside ? 5 : -5}
2265
+ stroke={final_x2_axis.color || `var(--border-color, gray)`}
2266
+ />
1546
2267
 
1547
- <!-- Zoom Selection Rectangle -->
1548
- {#if drag_start_coords && drag_current_coords && isFinite(drag_start_coords.x) &&
1549
- isFinite(drag_start_coords.y) && isFinite(drag_current_coords.x) &&
1550
- isFinite(drag_current_coords.y)}
1551
- {@const x = Math.min(drag_start_coords.x, drag_current_coords.x)}
1552
- {@const y = Math.min(drag_start_coords.y, drag_current_coords.y)}
1553
- {@const rect_width = Math.abs(drag_start_coords.x - drag_current_coords.x)}
1554
- {@const rect_height = Math.abs(drag_start_coords.y - drag_current_coords.y)}
1555
- <rect class="zoom-rect" {x} {y} width={rect_width} height={rect_height} />
1556
- {/if}
2268
+ {#if tick >= Math.min(x2_min, x2_max) && tick <= Math.max(x2_min, x2_max)}
2269
+ {@const base_y = inside ? 8 : -20}
2270
+ {@const shift = final_x2_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
2271
+ {@const x = shift.x ?? 0}
2272
+ {@const y = base_y + (shift.y ?? 0)}
2273
+ {@const custom_label = get_tick_label(tick, final_x2_axis.ticks)}
2274
+ {@const dominant_baseline = inside ? `hanging` : `auto`}
2275
+ <text
2276
+ {x}
2277
+ {y}
2278
+ dominant-baseline={dominant_baseline}
2279
+ fill={final_x2_axis.color}
2280
+ >
2281
+ {custom_label ?? format_value(tick, final_x2_axis.format ?? ``)}
2282
+ </text>
2283
+ {/if}
2284
+ </g>
2285
+ {/if}
2286
+ {/if}
2287
+ {/each}
2288
+ {/if}
1557
2289
 
1558
- <!-- Zero lines (shown for linear and arcsinh scales, not log) -->
1559
- {#if final_display.x_zero_line &&
1560
- get_scale_type_name(final_x_axis.scale_type) !== `log` &&
1561
- !final_x_axis.format?.startsWith(`%`) && x_min <= 0 && x_max >= 0}
1562
- {@const zero_x_pos = x_scale_fn(0)}
1563
- {#if isFinite(zero_x_pos)}
1564
- <line
1565
- class="zero-line"
1566
- x1={zero_x_pos}
1567
- x2={zero_x_pos}
1568
- y1={pad.t}
1569
- y2={height - pad.b}
1570
- />
1571
- {/if}
1572
- {/if}
1573
- {#if final_display.y_zero_line &&
1574
- get_scale_type_name(final_y_axis.scale_type) !== `log` &&
1575
- y_min <= 0 && y_max >= 0}
1576
- {@const zero_y_pos = y_scale_fn(0)}
1577
- {#if isFinite(zero_y_pos)}
1578
- <line
1579
- class="zero-line"
1580
- x1={pad.l}
1581
- x2={width - pad.r}
1582
- y1={zero_y_pos}
1583
- y2={zero_y_pos}
1584
- />
1585
- {/if}
1586
- {/if}
1587
- {#if final_display.y_zero_line && y2_points.length > 0 &&
1588
- get_scale_type_name(final_y2_axis.scale_type) !== `log` && y2_min <= 0 &&
1589
- y2_max >= 0}
1590
- {@const zero_y2_pos = y2_scale_fn(0)}
1591
- {#if isFinite(zero_y2_pos)}
1592
- <line
1593
- class="zero-line"
1594
- x1={pad.l}
1595
- x2={width - pad.r}
1596
- y1={zero_y2_pos}
1597
- y2={zero_y2_pos}
1598
- />
1599
- {/if}
2290
+ {#if final_x2_axis.label || final_x2_axis.options?.length}
2291
+ {@const { label_shift, label = ``, options, selected_key, color } =
2292
+ final_x2_axis}
2293
+ <AxisLabel
2294
+ x={width / 2 + (label_shift?.x ?? 0)}
2295
+ y={Math.max(12, pad.t - (label_shift?.y ?? 40))}
2296
+ {label}
2297
+ {options}
2298
+ {selected_key}
2299
+ loading={axis_loading === `x2`}
2300
+ axis_type="x2"
2301
+ {color}
2302
+ on_select={(key) => handle_axis_change(`x2`, key)}
2303
+ />
2304
+ {/if}
2305
+ </g>
1600
2306
  {/if}
1601
2307
 
2308
+ <!-- Tooltip rendered inside overlay (moved outside SVG for stacking above colorbar) -->
2309
+
2310
+ <ZoomRect start={drag_start_coords} current={drag_current_coords} />
2311
+
2312
+ <ZeroLines
2313
+ display={final_display}
2314
+ {x_scale_fn}
2315
+ {x2_scale_fn}
2316
+ {y_scale_fn}
2317
+ {y2_scale_fn}
2318
+ x_range={zoom_x_range}
2319
+ x2_range={zoom_x2_range}
2320
+ y_range={zoom_y_range}
2321
+ y2_range={zoom_y2_range}
2322
+ x_scale_type={final_x_axis.scale_type}
2323
+ x2_scale_type={final_x2_axis.scale_type}
2324
+ y_scale_type={final_y_axis.scale_type}
2325
+ y2_scale_type={final_y2_axis.scale_type}
2326
+ x_is_time={is_time_x}
2327
+ x2_is_time={is_time_x2}
2328
+ has_x2={x2_points.length > 0}
2329
+ has_y2={y2_points.length > 0}
2330
+ {width}
2331
+ {height}
2332
+ {pad}
2333
+ />
2334
+
1602
2335
  <defs>
1603
2336
  <clipPath id={clip_path_id}>
1604
2337
  <rect
@@ -1644,9 +2377,7 @@ $effect(() => {
1644
2377
  <Line
1645
2378
  points={finite_screen_points}
1646
2379
  origin={[
1647
- final_x_axis.format?.startsWith(`%`)
1648
- ? x_scale_fn(new Date(x_min))
1649
- : x_scale_fn(x_min),
2380
+ is_time_x ? x_scale_fn(new Date(x_min)) : x_scale_fn(x_min),
1650
2381
  series_data.y_axis === `y2` ? y2_scale_fn(y2_min) : y_scale_fn(y_min),
1651
2382
  ]}
1652
2383
  line_color={(tc(`line.color`) ? styles.line?.color : null) ?? color_fallback}
@@ -1685,9 +2416,7 @@ $effect(() => {
1685
2416
  ...label_style,
1686
2417
  offset: {
1687
2418
  x: calculated_label_pos.x -
1688
- (final_x_axis.format?.startsWith(`%`)
1689
- ? x_scale_fn(new Date(point.x))
1690
- : x_scale_fn(point.x)),
2419
+ (is_time_x ? x_scale_fn(new Date(point.x)) : x_scale_fn(point.x)),
1691
2420
  y: calculated_label_pos.y - (series_data.y_axis === `y2`
1692
2421
  ? y2_scale_fn(point.y)
1693
2422
  : y_scale_fn(point.y)),
@@ -1714,6 +2443,7 @@ $effect(() => {
1714
2443
  tooltip_point?.point_idx === point.point_idx}
1715
2444
  is_selected={selected_point?.series_idx === point.series_idx &&
1716
2445
  selected_point?.point_idx === point.point_idx}
2446
+ leader_line_threshold={actual_label_config.leader_line_threshold}
1717
2447
  style={{
1718
2448
  symbol_type: pt?.symbol_type ?? series_default_symbol,
1719
2449
  ...pt,
@@ -1744,10 +2474,9 @@ $effect(() => {
1744
2474
  series_default_color}
1745
2475
  {...point_events &&
1746
2476
  Object.fromEntries(
1747
- Object.entries(point_events)
1748
- .filter(([event_name]) => event_name !== `onclick`).map((
1749
- [event_name, handler],
1750
- ) => [event_name, (event: Event) => handler({ point, event })]),
2477
+ point_event_names.map((name) => [name, (event: Event) =>
2478
+ point_events?.[name]?.({ point, event })]
2479
+ ),
1751
2480
  )}
1752
2481
  onclick={(event: MouseEvent) => {
1753
2482
  // Call user-provided onclick handler first if it exists
@@ -1830,9 +2559,16 @@ $effect(() => {
1830
2559
  {#if tooltip}
1831
2560
  {@render tooltip(handler_props)}
1832
2561
  {:else}
1833
- {@html point_label?.text ? `${point_label.text}<br />` : ``}x: {
1834
- handler_props.x_formatted
1835
- }<br />y: {handler_props.y_formatted}
2562
+ {@const hp = handler_props}
2563
+ {#if has_multiple_series && hp.label}<strong>{hp.label}</strong><br />{/if}
2564
+ {@html sanitize_html(point_label?.text ? `${point_label.text}<br />` : ``)}
2565
+ {@html sanitize_html(hp.x_axis.label || `x`)}: {hp.x_formatted}<br />
2566
+ {@html sanitize_html(hp.y_axis.label || `y`)}: {hp.y_formatted}
2567
+ {#if hp.colorbar?.value != null}
2568
+ <br />{@html sanitize_html(hp.colorbar.title || `Color`)}: {
2569
+ format_value(hp.colorbar.value, hp.colorbar.tick_format || `.3~g`)
2570
+ }
2571
+ {/if}
1836
2572
  {/if}
1837
2573
  </PlotTooltip>
1838
2574
  {/if}
@@ -1843,21 +2579,24 @@ $effect(() => {
1843
2579
  toggle_props={{
1844
2580
  ...controls.toggle_props,
1845
2581
  style:
1846
- `--ctrl-btn-right: var(--fullscreen-btn-offset, 36px); top: var(--ctrl-btn-top, 5pt); ${
2582
+ `--ctrl-btn-right: var(--fullscreen-btn-offset, 30px); top: var(--ctrl-btn-top, 5pt); ${
1847
2583
  controls.toggle_props?.style ?? ``
1848
2584
  }`,
1849
2585
  }}
1850
2586
  pane_props={controls.pane_props}
1851
2587
  bind:x_axis
2588
+ bind:x2_axis
1852
2589
  bind:y_axis
1853
2590
  bind:y2_axis
1854
2591
  bind:display
1855
2592
  bind:styles
1856
2593
  {auto_x_range}
2594
+ {auto_x2_range}
1857
2595
  {auto_y_range}
1858
2596
  {auto_y2_range}
1859
2597
  bind:selected_series_idx
1860
2598
  series={series_with_ids}
2599
+ has_x2_points={x2_points.length > 0}
1861
2600
  has_y2_points={y2_points.length > 0}
1862
2601
  children={controls_extra}
1863
2602
  on_touch={(key) => touched.add(key)}
@@ -2057,19 +2796,16 @@ $effect(() => {
2057
2796
  stroke-dasharray: var(--scatter-grid-dash, 4);
2058
2797
  stroke-width: var(--scatter-grid-width, 0.4);
2059
2798
  }
2060
- g.x-axis text {
2799
+ g:is(.x-axis, .x2-axis) text {
2061
2800
  text-anchor: middle;
2062
2801
  dominant-baseline: top;
2063
2802
  }
2064
2803
  g:is(.y-axis, .y2-axis) text {
2065
2804
  dominant-baseline: central;
2066
2805
  }
2067
- g:is(.x-axis, .y-axis, .y2-axis) .tick text {
2806
+ g:is(.x-axis, .x2-axis, .y-axis, .y2-axis) .tick text {
2068
2807
  font-size: var(--tick-font-size, 0.8em); /* shrink tick labels */
2069
2808
  }
2070
- foreignobject {
2071
- overflow: visible;
2072
- }
2073
2809
  .scatter :global(.axis-label) {
2074
2810
  text-align: center;
2075
2811
  width: 100%;
@@ -2092,16 +2828,4 @@ $effect(() => {
2092
2828
  .current-frame-indicator:hover {
2093
2829
  opacity: 0.8;
2094
2830
  }
2095
- .zoom-rect {
2096
- fill: var(--scatter-zoom-rect-fill, rgba(100, 100, 255, 0.2));
2097
- stroke: var(--scatter-zoom-rect-stroke, rgba(100, 100, 255, 0.8));
2098
- stroke-width: var(--scatter-zoom-rect-stroke-width, 1);
2099
- pointer-events: none; /* Prevent rect from interfering with mouse events */
2100
- }
2101
- .zero-line {
2102
- stroke: var(--scatter-zero-line-color, light-dark(black, white));
2103
- stroke-width: var(--scatter-zero-line-width, 1);
2104
- stroke-dasharray: none;
2105
- opacity: var(--scatter-zero-line-opacity, 0.3);
2106
- }
2107
2831
  </style>