matterviz 0.3.0 → 0.3.2

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 (286) hide show
  1. package/dist/FilePicker.svelte +37 -20
  2. package/dist/Icon.svelte +2 -2
  3. package/dist/MillerIndexInput.svelte +60 -0
  4. package/dist/MillerIndexInput.svelte.d.ts +7 -0
  5. package/dist/app.css +38 -2
  6. package/dist/brillouin/BrillouinZone.svelte +20 -62
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  8. package/dist/brillouin/BrillouinZoneExportPane.svelte +12 -20
  9. package/dist/brillouin/BrillouinZoneScene.svelte +2 -2
  10. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  11. package/dist/chempot-diagram/ChemPotDiagram.svelte +192 -0
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +677 -0
  14. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  15. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2688 -0
  16. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  17. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -0
  18. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  19. package/dist/chempot-diagram/color.d.ts +10 -0
  20. package/dist/chempot-diagram/color.js +33 -0
  21. package/dist/chempot-diagram/compute.d.ts +38 -0
  22. package/dist/chempot-diagram/compute.js +650 -0
  23. package/dist/chempot-diagram/index.d.ts +5 -0
  24. package/dist/chempot-diagram/index.js +5 -0
  25. package/dist/chempot-diagram/pointer.d.ts +16 -0
  26. package/dist/chempot-diagram/pointer.js +40 -0
  27. package/dist/chempot-diagram/temperature.d.ts +15 -0
  28. package/dist/chempot-diagram/temperature.js +37 -0
  29. package/dist/chempot-diagram/types.d.ts +83 -0
  30. package/dist/chempot-diagram/types.js +27 -0
  31. package/dist/colors/index.d.ts +3 -1
  32. package/dist/colors/index.js +4 -0
  33. package/dist/composition/BarChart.svelte +13 -22
  34. package/dist/composition/BubbleChart.svelte +5 -3
  35. package/dist/composition/FormulaFilter.svelte +770 -90
  36. package/dist/composition/FormulaFilter.svelte.d.ts +37 -1
  37. package/dist/composition/PieChart.svelte +43 -18
  38. package/dist/composition/PieChart.svelte.d.ts +1 -1
  39. package/dist/constants.d.ts +1 -0
  40. package/dist/constants.js +2 -0
  41. package/dist/convex-hull/ConvexHull.svelte +14 -1
  42. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  43. package/dist/convex-hull/ConvexHull2D.svelte +14 -45
  44. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  45. package/dist/convex-hull/ConvexHull3D.svelte +396 -134
  46. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  47. package/dist/convex-hull/ConvexHull4D.svelte +93 -42
  48. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  49. package/dist/convex-hull/ConvexHullControls.svelte +94 -31
  50. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +4 -2
  51. package/dist/convex-hull/ConvexHullStats.svelte +697 -128
  52. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  53. package/dist/convex-hull/ConvexHullTooltip.svelte +1 -0
  54. package/dist/convex-hull/GasPressureControls.svelte +72 -38
  55. package/dist/convex-hull/GasPressureControls.svelte.d.ts +2 -1
  56. package/dist/convex-hull/TemperatureSlider.svelte +46 -19
  57. package/dist/convex-hull/TemperatureSlider.svelte.d.ts +2 -1
  58. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  59. package/dist/convex-hull/demo-temperature.js +36 -0
  60. package/dist/convex-hull/gas-thermodynamics.js +16 -5
  61. package/dist/convex-hull/helpers.d.ts +7 -1
  62. package/dist/convex-hull/helpers.js +45 -15
  63. package/dist/convex-hull/index.d.ts +15 -1
  64. package/dist/convex-hull/index.js +1 -0
  65. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  66. package/dist/convex-hull/thermodynamics.js +106 -17
  67. package/dist/convex-hull/types.d.ts +7 -0
  68. package/dist/convex-hull/types.js +11 -0
  69. package/dist/coordination/CoordinationBarPlot.svelte +29 -46
  70. package/dist/element/BohrAtom.svelte +1 -1
  71. package/dist/element/data.js +2 -14
  72. package/dist/element/data.json.gz +0 -0
  73. package/dist/element/index.d.ts +1 -1
  74. package/dist/element/index.js +1 -0
  75. package/dist/element/types.d.ts +1 -0
  76. package/dist/fermi-surface/FermiSurface.svelte +21 -65
  77. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  78. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  79. package/dist/fermi-surface/FermiSurfaceScene.svelte +1 -1
  80. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  81. package/dist/fermi-surface/compute.js +1 -21
  82. package/dist/fermi-surface/marching-cubes.d.ts +2 -13
  83. package/dist/fermi-surface/marching-cubes.js +2 -519
  84. package/dist/fermi-surface/parse.js +17 -23
  85. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1273 -0
  86. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  87. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +171 -0
  88. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +31 -0
  89. package/dist/heatmap-matrix/index.d.ts +53 -0
  90. package/dist/heatmap-matrix/index.js +100 -0
  91. package/dist/heatmap-matrix/shared.d.ts +2 -0
  92. package/dist/heatmap-matrix/shared.js +4 -0
  93. package/dist/icons.d.ts +119 -0
  94. package/dist/icons.js +119 -0
  95. package/dist/index.d.ts +6 -1
  96. package/dist/index.js +6 -1
  97. package/dist/io/export.js +15 -3
  98. package/dist/io/file-drop.d.ts +7 -0
  99. package/dist/io/file-drop.js +43 -0
  100. package/dist/io/index.d.ts +2 -2
  101. package/dist/io/index.js +2 -112
  102. package/dist/io/types.d.ts +1 -0
  103. package/dist/io/url-drop.d.ts +2 -0
  104. package/dist/io/url-drop.js +118 -0
  105. package/dist/isosurface/Isosurface.svelte +231 -0
  106. package/dist/isosurface/Isosurface.svelte.d.ts +8 -0
  107. package/dist/isosurface/IsosurfaceControls.svelte +273 -0
  108. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +9 -0
  109. package/dist/isosurface/index.d.ts +5 -0
  110. package/dist/isosurface/index.js +6 -0
  111. package/dist/isosurface/parse.d.ts +6 -0
  112. package/dist/isosurface/parse.js +548 -0
  113. package/dist/isosurface/slice.d.ts +11 -0
  114. package/dist/isosurface/slice.js +145 -0
  115. package/dist/isosurface/types.d.ts +55 -0
  116. package/dist/isosurface/types.js +178 -0
  117. package/dist/labels.d.ts +2 -1
  118. package/dist/labels.js +1 -0
  119. package/dist/layout/InfoTag.svelte +62 -62
  120. package/dist/layout/SubpageGrid.svelte +74 -0
  121. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  122. package/dist/layout/index.d.ts +1 -0
  123. package/dist/layout/index.js +1 -0
  124. package/dist/layout/json-tree/JsonNode.svelte +226 -53
  125. package/dist/layout/json-tree/JsonTree.svelte +425 -51
  126. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  127. package/dist/layout/json-tree/JsonValue.svelte +218 -97
  128. package/dist/layout/json-tree/types.d.ts +27 -2
  129. package/dist/layout/json-tree/utils.d.ts +14 -1
  130. package/dist/layout/json-tree/utils.js +254 -0
  131. package/dist/marching-cubes.d.ts +14 -0
  132. package/dist/marching-cubes.js +519 -0
  133. package/dist/math.d.ts +8 -0
  134. package/dist/math.js +374 -7
  135. package/dist/overlays/ContextMenu.svelte +3 -2
  136. package/dist/overlays/DraggablePane.svelte +163 -58
  137. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  138. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +232 -77
  139. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  140. package/dist/phase-diagram/PhaseDiagramControls.svelte +32 -11
  141. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  142. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +103 -0
  143. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  144. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +102 -95
  145. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +7 -0
  146. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +100 -26
  147. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  148. package/dist/phase-diagram/index.d.ts +2 -0
  149. package/dist/phase-diagram/index.js +2 -0
  150. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  151. package/dist/phase-diagram/svg-to-diagram.js +865 -0
  152. package/dist/phase-diagram/types.d.ts +10 -0
  153. package/dist/phase-diagram/utils.d.ts +7 -4
  154. package/dist/phase-diagram/utils.js +149 -59
  155. package/dist/plot/AxisLabel.svelte +26 -0
  156. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  157. package/dist/plot/BarPlot.svelte +473 -228
  158. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  159. package/dist/plot/BarPlotControls.svelte +3 -2
  160. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  161. package/dist/plot/ColorBar.svelte +54 -54
  162. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  163. package/dist/plot/ElementScatter.svelte +4 -3
  164. package/dist/plot/FillArea.svelte +4 -1
  165. package/dist/plot/Histogram.svelte +320 -230
  166. package/dist/plot/Histogram.svelte.d.ts +2 -2
  167. package/dist/plot/HistogramControls.svelte +29 -10
  168. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  169. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +2 -2
  170. package/dist/plot/PlotControls.svelte +109 -27
  171. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  172. package/dist/plot/PlotLegend.svelte +1 -1
  173. package/dist/plot/PortalSelect.svelte +2 -1
  174. package/dist/plot/ReferenceLine.svelte +2 -1
  175. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  176. package/dist/plot/ReferencePlane.svelte +1 -3
  177. package/dist/plot/ScatterPlot.svelte +343 -209
  178. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  179. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  180. package/dist/plot/ScatterPlot3DControls.svelte +203 -250
  181. package/dist/plot/ScatterPlot3DScene.svelte +4 -7
  182. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  183. package/dist/plot/ScatterPlotControls.svelte +95 -55
  184. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  185. package/dist/plot/ZeroLines.svelte +44 -0
  186. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  187. package/dist/plot/ZoomRect.svelte +21 -0
  188. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  189. package/dist/plot/axis-utils.d.ts +1 -1
  190. package/dist/plot/data-cleaning.js +1 -5
  191. package/dist/plot/index.d.ts +6 -2
  192. package/dist/plot/index.js +6 -2
  193. package/dist/plot/interactions.d.ts +8 -10
  194. package/dist/plot/interactions.js +10 -19
  195. package/dist/plot/layout.d.ts +7 -1
  196. package/dist/plot/layout.js +12 -4
  197. package/dist/plot/reference-line.d.ts +4 -21
  198. package/dist/plot/reference-line.js +7 -81
  199. package/dist/plot/types.d.ts +42 -17
  200. package/dist/plot/types.js +10 -0
  201. package/dist/plot/utils/label-placement.js +14 -11
  202. package/dist/plot/utils.d.ts +1 -0
  203. package/dist/plot/utils.js +14 -0
  204. package/dist/rdf/RdfPlot.svelte +55 -66
  205. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  206. package/dist/rdf/index.d.ts +1 -1
  207. package/dist/rdf/index.js +1 -1
  208. package/dist/settings.d.ts +5 -0
  209. package/dist/settings.js +37 -3
  210. package/dist/spectral/Bands.svelte +515 -143
  211. package/dist/spectral/Bands.svelte.d.ts +22 -2
  212. package/dist/spectral/helpers.d.ts +23 -1
  213. package/dist/spectral/helpers.js +65 -9
  214. package/dist/spectral/types.d.ts +2 -0
  215. package/dist/structure/AtomLegend.svelte +31 -10
  216. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  217. package/dist/structure/CellSelect.svelte +92 -22
  218. package/dist/structure/Lattice.svelte +2 -0
  219. package/dist/structure/Structure.svelte +716 -173
  220. package/dist/structure/Structure.svelte.d.ts +7 -2
  221. package/dist/structure/StructureControls.svelte +26 -14
  222. package/dist/structure/StructureControls.svelte.d.ts +5 -1
  223. package/dist/structure/StructureInfoPane.svelte +7 -1
  224. package/dist/structure/StructureScene.svelte +386 -95
  225. package/dist/structure/StructureScene.svelte.d.ts +15 -4
  226. package/dist/structure/atom-properties.d.ts +6 -2
  227. package/dist/structure/atom-properties.js +38 -25
  228. package/dist/structure/export.js +10 -7
  229. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  230. package/dist/structure/ferrox-wasm-types.js +0 -3
  231. package/dist/structure/ferrox-wasm.d.ts +3 -2
  232. package/dist/structure/ferrox-wasm.js +1 -2
  233. package/dist/structure/index.d.ts +7 -0
  234. package/dist/structure/index.js +22 -0
  235. package/dist/structure/parse.js +19 -16
  236. package/dist/structure/partial-occupancy.d.ts +25 -0
  237. package/dist/structure/partial-occupancy.js +102 -0
  238. package/dist/structure/validation.js +6 -3
  239. package/dist/symmetry/SymmetryStats.svelte +18 -4
  240. package/dist/symmetry/WyckoffTable.svelte +18 -10
  241. package/dist/symmetry/index.d.ts +7 -4
  242. package/dist/symmetry/index.js +83 -18
  243. package/dist/table/HeatmapTable.svelte +468 -69
  244. package/dist/table/HeatmapTable.svelte.d.ts +13 -1
  245. package/dist/table/ToggleMenu.svelte +291 -44
  246. package/dist/table/ToggleMenu.svelte.d.ts +4 -1
  247. package/dist/table/index.d.ts +3 -0
  248. package/dist/tooltip/index.d.ts +1 -1
  249. package/dist/tooltip/index.js +1 -0
  250. package/dist/trajectory/Trajectory.svelte +147 -145
  251. package/dist/trajectory/TrajectoryExportPane.svelte +13 -9
  252. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  253. package/dist/trajectory/constants.d.ts +6 -0
  254. package/dist/trajectory/constants.js +7 -0
  255. package/dist/trajectory/extract.js +3 -5
  256. package/dist/trajectory/format-detect.d.ts +9 -0
  257. package/dist/trajectory/format-detect.js +76 -0
  258. package/dist/trajectory/frame-reader.d.ts +17 -0
  259. package/dist/trajectory/frame-reader.js +339 -0
  260. package/dist/trajectory/helpers.d.ts +15 -0
  261. package/dist/trajectory/helpers.js +187 -0
  262. package/dist/trajectory/index.d.ts +1 -0
  263. package/dist/trajectory/index.js +11 -4
  264. package/dist/trajectory/parse/ase.d.ts +2 -0
  265. package/dist/trajectory/parse/ase.js +76 -0
  266. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  267. package/dist/trajectory/parse/hdf5.js +121 -0
  268. package/dist/trajectory/parse/index.d.ts +12 -0
  269. package/dist/trajectory/parse/index.js +304 -0
  270. package/dist/trajectory/parse/lammps.d.ts +5 -0
  271. package/dist/trajectory/parse/lammps.js +169 -0
  272. package/dist/trajectory/parse/vasp.d.ts +2 -0
  273. package/dist/trajectory/parse/vasp.js +65 -0
  274. package/dist/trajectory/parse/xyz.d.ts +2 -0
  275. package/dist/trajectory/parse/xyz.js +109 -0
  276. package/dist/trajectory/types.d.ts +11 -0
  277. package/dist/trajectory/types.js +1 -0
  278. package/dist/utils.d.ts +2 -0
  279. package/dist/utils.js +4 -0
  280. package/dist/xrd/XrdPlot.svelte +6 -4
  281. package/dist/xrd/calc-xrd.js +0 -1
  282. package/package.json +33 -23
  283. package/readme.md +4 -4
  284. package/dist/trajectory/parse.d.ts +0 -42
  285. package/dist/trajectory/parse.js +0 -1267
  286. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
@@ -3,23 +3,25 @@
3
3
  generics="Metadata extends Record<string, unknown> = Record<string, unknown>"
4
4
  >import { format_value } from '../labels';
5
5
  import { FullscreenToggle, set_fullscreen_bg } from '../layout';
6
- import { BarPlotControls, compute_element_placement, InteractiveAxisLabel, PlotLegend, ReferenceLine, ScatterPoint, } from './';
7
- import { AXIS_LABEL_CONTAINER, create_axis_change_handler, } from './axis-utils';
6
+ import { AxisLabel, BarPlotControls, compute_element_placement, PlotLegend, ReferenceLine, ScatterPoint, } from './';
7
+ import { create_axis_change_handler } from './axis-utils';
8
8
  import { process_prop } from './data-transform';
9
9
  import { create_dimension_tracker, create_hover_lock, } from './hover-lock.svelte';
10
10
  import { get_relative_coords, pan_range, PINCH_ZOOM_THRESHOLD, pixels_to_data_delta, } from './interactions';
11
11
  import { group_ref_lines_by_z, index_ref_lines } from './reference-line';
12
- import { create_color_scale, create_scale, create_size_scale, generate_ticks, get_nice_data_range, } from './scales';
12
+ import { create_color_scale, create_scale, create_size_scale, generate_ticks, get_nice_data_range, get_tick_label, } from './scales';
13
13
  import { DEFAULT_GRID_STYLE, DEFAULT_MARKERS, get_scale_type_name, } from './types';
14
14
  import { DEFAULTS } from '../settings';
15
15
  import { extent } from 'd3-array';
16
16
  import { untrack } from 'svelte';
17
17
  import { Tween } from 'svelte/motion';
18
18
  import { SvelteMap } from 'svelte/reactivity';
19
- import { calc_auto_padding, constrain_tooltip_position, filter_padding, LABEL_GAP_DEFAULT, measure_text_width, } from './layout';
19
+ import { calc_auto_padding, constrain_tooltip_position, filter_padding, LABEL_GAP_DEFAULT, measure_max_tick_width, } from './layout';
20
20
  import PlotTooltip from './PlotTooltip.svelte';
21
21
  import { bar_path } from './svg';
22
- let { series = $bindable([]), orientation = $bindable(`vertical`), mode = $bindable(`overlay`), x_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.bar.display), x_range = [null, null], y_range = [null, null], y2_range = [null, null], range_padding = 0.05, padding = { t: 20, b: 60, l: 60, r: 20 }, legend = {}, show_legend, bar = {}, line = {}, tooltip, user_content, hovered = $bindable(false), change = () => { }, on_bar_click, on_bar_hover,
22
+ import ZeroLines from './ZeroLines.svelte';
23
+ import ZoomRect from './ZoomRect.svelte';
24
+ let { series = $bindable([]), orientation = $bindable(`vertical`), mode = $bindable(`overlay`), x_axis = $bindable({}), x2_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.bar.display), x_range = [null, null], x2_range = [null, null], y_range = [null, null], y2_range = [null, null], range_padding = 0.05, padding = { t: 20, b: 60, l: 60, r: 20 }, legend = {}, show_legend, bar = {}, line = {}, tooltip, user_content, hovered = $bindable(false), change = () => { }, on_bar_click, on_bar_hover,
23
25
  // Line marker props (matching ScatterPlot)
24
26
  color_scale = {
25
27
  type: `linear`,
@@ -38,6 +40,15 @@ y2_axis = {
38
40
  range: [null, null],
39
41
  ...y2_axis,
40
42
  };
43
+ x2_axis = {
44
+ format: ``,
45
+ scale_type: `linear`,
46
+ ticks: 5,
47
+ label_shift: { x: 0, y: 40 },
48
+ tick: { label: { shift: { x: 0, y: 0 } } },
49
+ range: [null, null],
50
+ ...x2_axis,
51
+ };
41
52
  let [width, height] = $state([0, 0]);
42
53
  let wrapper = $state();
43
54
  let svg_element = $state(null);
@@ -49,11 +60,47 @@ let axis_loading = $state(null);
49
60
  // Compute ref_lines with index and group by z-index (using shared utilities)
50
61
  let indexed_ref_lines = $derived(index_ref_lines(ref_lines));
51
62
  let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines));
63
+ let is_categorical = $derived(series.some((srs) => srs.x.some((val) => typeof val === `string`)));
64
+ let category_list = $derived.by(() => {
65
+ if (!is_categorical)
66
+ return [];
67
+ if (x_axis.categories?.length)
68
+ return [...x_axis.categories];
69
+ return [...new Set(series.flatMap((srs) => srs.x.map(String)))];
70
+ });
71
+ let category_indices = $derived(category_list.length ? category_list.map((_, idx) => idx) : null);
72
+ let internal_series = $derived.by(() => {
73
+ // safe: when !category_indices, all x values are numeric (is_categorical is false)
74
+ if (!category_indices)
75
+ return series;
76
+ return series.map((srs) => {
77
+ const orig_map = new Map(srs.x.map((val, idx) => [String(val), idx]));
78
+ if (orig_map.size < srs.x.length) {
79
+ console.warn(`BarPlot: series "${srs.label ?? `?`}" has duplicate x values — last occurrence wins`);
80
+ }
81
+ // Resolve original index for each category (undefined if series lacks it)
82
+ const orig_indices = category_list.map((cat) => orig_map.get(cat));
83
+ const remap = (arr, fallback) => orig_indices.map((oi) => oi != null ? (arr?.[oi] ?? fallback) : fallback);
84
+ const bw_arr = Array.isArray(srs.bar_width) ? srs.bar_width : null;
85
+ const meta_arr = Array.isArray(srs.metadata) ? srs.metadata : null;
86
+ return {
87
+ ...srs,
88
+ x: category_indices,
89
+ y: remap(srs.y, srs.render_mode === `line` ? NaN : 0),
90
+ labels: remap(srs.labels, null),
91
+ metadata: orig_indices.map((oi) => oi != null ? (meta_arr ? meta_arr[oi] : srs.metadata) : undefined),
92
+ ...(bw_arr ? { bar_width: remap(bw_arr, 0.5) } : {}),
93
+ ...(srs.color_values ? { color_values: remap(srs.color_values, null) } : {}),
94
+ ...(srs.size_values ? { size_values: remap(srs.size_values, null) } : {}),
95
+ };
96
+ });
97
+ });
52
98
  // Compute auto ranges from visible series
53
- let visible_series = $derived(series.filter((srs) => srs?.visible ?? true));
99
+ let visible_series = $derived(internal_series.filter((srs) => srs?.visible ?? true));
54
100
  // Separate series by y-axis
55
101
  let y1_series = $derived(visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`));
56
102
  let y2_series = $derived(visible_series.filter((srs) => srs.y_axis === `y2`));
103
+ let x2_series = $derived(visible_series.filter((srs) => srs.x_axis === `x2`));
57
104
  let auto_ranges = $derived.by(() => {
58
105
  // Calculate separate ranges for y1 and y2 axes
59
106
  const calc_y_range = (series_list, y_limit, scale_type) => {
@@ -103,29 +150,46 @@ let auto_ranges = $derived.by(() => {
103
150
  }
104
151
  return y_range;
105
152
  };
106
- // Get all x values for x_range calculation
107
- const all_x_points = visible_series.flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })));
108
- const x_scale_type = x_axis.scale_type ?? `linear`;
109
- const x_auto_range = all_x_points.length
110
- ? get_nice_data_range(all_x_points, (pt) => pt.x, x_range, x_scale_type, range_padding, x_axis.format?.startsWith(`%`) || false)
153
+ // Get x values split by axis for range calculation
154
+ // For categorical data, use fixed range centered on integer indices
155
+ let x_auto_range;
156
+ if (category_list.length) {
157
+ x_auto_range = [-0.5, category_list.length - 0.5];
158
+ }
159
+ else {
160
+ const x1_x_points = visible_series
161
+ .filter((srs) => (srs.x_axis ?? `x1`) === `x1`)
162
+ .flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })));
163
+ x_auto_range = x1_x_points.length
164
+ ? get_nice_data_range(x1_x_points, (pt) => pt.x, x_range, x_axis.scale_type ?? `linear`, range_padding, x_axis.format?.startsWith(`%`) || false)
165
+ : [0, 1];
166
+ }
167
+ const x2_x_points = x2_series.flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })));
168
+ const x2_scale_type = x2_axis.scale_type ?? `linear`;
169
+ const x2_auto_range = x2_x_points.length
170
+ ? get_nice_data_range(x2_x_points, (pt) => pt.x, x2_range, x2_scale_type, range_padding, x2_axis.format?.startsWith(`%`) || false)
111
171
  : [0, 1];
112
172
  const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`);
113
173
  const y2_auto_range = calc_y_range(y2_series, y2_range, y2_axis.scale_type ?? `linear`);
114
174
  // Map data ranges to axis ranges depending on orientation
115
175
  return orientation === `horizontal`
116
- ? ({ x: y1_range, y: x_auto_range, y2: y2_auto_range })
117
- : ({ x: x_auto_range, y: y1_range, y2: y2_auto_range });
176
+ ? ({ x: y1_range, x2: x2_auto_range, y: x_auto_range, y2: y2_auto_range })
177
+ : ({ x: x_auto_range, x2: x2_auto_range, y: y1_range, y2: y2_auto_range });
118
178
  });
119
179
  // Initialize and current ranges
120
180
  let ranges = $state({
121
- initial: { x: [0, 1], y: [0, 1], y2: [0, 1] },
122
- current: { x: [0, 1], y: [0, 1], y2: [0, 1] },
181
+ initial: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
182
+ current: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
123
183
  });
124
184
  $effect(() => {
125
185
  const new_x = [
126
186
  x_axis.range?.[0] ?? auto_ranges.x[0],
127
187
  x_axis.range?.[1] ?? auto_ranges.x[1],
128
188
  ];
189
+ const new_x2 = [
190
+ x2_axis.range?.[0] ?? auto_ranges.x2[0],
191
+ x2_axis.range?.[1] ?? auto_ranges.x2[1],
192
+ ];
129
193
  const new_y = [
130
194
  y_axis.range?.[0] ?? auto_ranges.y[0],
131
195
  y_axis.range?.[1] ?? auto_ranges.y[1],
@@ -138,13 +202,15 @@ $effect(() => {
138
202
  // Comparing against initial preserves user's pan/zoom state
139
203
  if (ranges.initial.x[0] !== new_x[0] ||
140
204
  ranges.initial.x[1] !== new_x[1] ||
205
+ ranges.initial.x2[0] !== new_x2[0] ||
206
+ ranges.initial.x2[1] !== new_x2[1] ||
141
207
  ranges.initial.y[0] !== new_y[0] ||
142
208
  ranges.initial.y[1] !== new_y[1] ||
143
209
  ranges.initial.y2[0] !== new_y2[0] ||
144
210
  ranges.initial.y2[1] !== new_y2[1]) {
145
211
  ranges = {
146
- initial: { x: new_x, y: new_y, y2: new_y2 },
147
- current: { x: new_x, y: new_y, y2: new_y2 },
212
+ initial: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
213
+ current: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
148
214
  };
149
215
  }
150
216
  });
@@ -157,6 +223,7 @@ $effect(() => {
157
223
  ? calc_auto_padding({
158
224
  padding,
159
225
  default_padding,
226
+ x2_axis: { ...x2_axis, tick_values: ticks.x2 },
160
227
  y_axis: { ...y_axis, tick_values: ticks.y },
161
228
  y2_axis: { ...y2_axis, tick_values: ticks.y2 },
162
229
  })
@@ -164,15 +231,23 @@ $effect(() => {
164
231
  // Expand right padding if y2 ticks are shown (only for vertical orientation)
165
232
  if (width && height && y2_series.length && ticks.y2.length &&
166
233
  orientation === `vertical`) {
167
- const y2_tick_width = Math.max(0, ...ticks.y2.map((tick) => measure_text_width(format_value(tick, y2_axis.format), `12px sans-serif`)));
168
234
  // Need space for: tick shift + tick width + gap (30px) + label space (20px if present)
169
235
  // When ticks are inside, they don't contribute to padding
170
236
  const inside = y2_axis.tick?.label?.inside ?? false;
171
237
  const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8;
172
- const tick_width_contribution = inside ? 0 : y2_tick_width;
238
+ const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max;
173
239
  const label_space = y2_axis.label ? 20 : 0;
174
240
  new_pad.r = Math.max(new_pad.r, tick_shift + tick_width_contribution + 30 + label_space);
175
241
  }
242
+ // Expand top padding if x2 ticks are shown (only for vertical orientation)
243
+ if (width && height && x2_series.length && ticks.x2.length &&
244
+ orientation === `vertical`) {
245
+ const inside = x2_axis.tick?.label?.inside ?? false;
246
+ const tick_shift = inside ? 0 : Math.abs(x2_axis.tick?.label?.shift?.y ?? 0) + 5;
247
+ const tick_height = inside ? 0 : 16;
248
+ const label_space = x2_axis.label ? 20 : 0;
249
+ new_pad.t = Math.max(new_pad.t, tick_shift + tick_height + 30 + label_space);
250
+ }
176
251
  // Only update if padding actually changed (prevents infinite loop)
177
252
  if (pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
178
253
  pad.r !== new_pad.r)
@@ -186,6 +261,10 @@ let scales = $derived({
186
261
  pad.l,
187
262
  width - pad.r,
188
263
  ]),
264
+ x2: create_scale(x2_axis.scale_type ?? `linear`, ranges.current.x2, [
265
+ pad.l,
266
+ width - pad.r,
267
+ ]),
189
268
  y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [
190
269
  height - pad.b,
191
270
  pad.t,
@@ -217,23 +296,45 @@ let all_size_values = $derived(visible_series
217
296
  let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range));
218
297
  // Size scale function (using shared utility)
219
298
  let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values));
299
+ // Auto-generate tick labels for categorical data (unless user provides explicit ticks)
300
+ // In vertical mode categories are on x-axis; in horizontal mode on y-axis
301
+ let cat_axis = $derived(orientation === `horizontal` ? `y` : `x`);
302
+ let effective_cat_ticks = $derived.by(() => {
303
+ if (!category_list.length)
304
+ return undefined;
305
+ // Only respect user ticks when they're a Record (custom label mapping),
306
+ // not a number (tick count) or array (tick positions)
307
+ const user_ticks = cat_axis === `x` ? x_axis.ticks : y_axis.ticks;
308
+ if (user_ticks != null && typeof user_ticks === `object` &&
309
+ !Array.isArray(user_ticks))
310
+ return user_ticks;
311
+ return Object.fromEntries(category_list.map((cat, idx) => [idx, cat]));
312
+ });
220
313
  // Ticks
221
314
  let ticks = $derived({
222
315
  x: width && height
223
- ? generate_ticks(ranges.current.x, x_axis.scale_type ?? `linear`, x_axis.ticks, scales.x, {
224
- default_count: 8,
225
- })
316
+ ? (category_indices && cat_axis === `x` ? category_indices : generate_ticks(ranges.current.x, x_axis.scale_type ?? `linear`, x_axis.ticks, scales.x, { default_count: 8 }))
226
317
  : [],
227
318
  y: width && height
228
- ? generate_ticks(ranges.current.y, y_axis.scale_type ?? `linear`, y_axis.ticks, scales.y, {
229
- default_count: 6,
230
- })
319
+ ? (category_indices && cat_axis === `y` ? category_indices : generate_ticks(ranges.current.y, y_axis.scale_type ?? `linear`, y_axis.ticks, scales.y, { default_count: 6 }))
231
320
  : [],
232
321
  y2: width && height && y2_series.length > 0 && orientation === `vertical`
233
322
  ? generate_ticks(ranges.current.y2, y2_axis.scale_type ?? `linear`, y2_axis.ticks, scales.y2, {
234
323
  default_count: 6,
235
324
  })
236
325
  : [],
326
+ x2: width && height && x2_series.length > 0 && orientation === `vertical`
327
+ ? generate_ticks(ranges.current.x2, x2_axis.scale_type ?? `linear`, x2_axis.ticks, scales.x2, {
328
+ default_count: 8,
329
+ })
330
+ : [],
331
+ });
332
+ // Cache measured tick-label widths so expensive canvas text measurement
333
+ // only runs when ticks/format change, not on every template rerender.
334
+ let tick_label_widths = $derived({
335
+ y_max: measure_max_tick_width(ticks.y, y_axis.format ?? ``),
336
+ y2_max: measure_max_tick_width(ticks.y2, y2_axis.format ?? ``),
337
+ x2_max: measure_max_tick_width(ticks.x2, x2_axis.format ?? ``),
237
338
  });
238
339
  // Zoom drag state
239
340
  let drag_state = $state({ start: null, current: null, bounds: null });
@@ -258,6 +359,8 @@ const on_window_mouse_up = () => {
258
359
  const y2 = scales.y.invert(drag_state.current.y);
259
360
  const y2_1 = scales.y2.invert(drag_state.start.y);
260
361
  const y2_2 = scales.y2.invert(drag_state.current.y);
362
+ const x2a_1_raw = scales.x2.invert(drag_state.start.x);
363
+ const x2a_2_raw = scales.x2.invert(drag_state.current.x);
261
364
  const dx = Math.abs(drag_state.start.x - drag_state.current.x);
262
365
  const dy = Math.abs(drag_state.start.y - drag_state.current.y);
263
366
  let xr1, xr2;
@@ -271,9 +374,26 @@ const on_window_mouse_up = () => {
271
374
  }
272
375
  else
273
376
  [xr1, xr2] = [NaN, NaN]; // bail: mixed types
377
+ let x2r1, x2r2;
378
+ if (x2a_1_raw instanceof Date && x2a_2_raw instanceof Date) {
379
+ ;
380
+ [x2r1, x2r2] = [x2a_1_raw.getTime(), x2a_2_raw.getTime()];
381
+ }
382
+ else if (typeof x2a_1_raw === `number` && typeof x2a_2_raw === `number`) {
383
+ ;
384
+ [x2r1, x2r2] = [x2a_1_raw, x2a_2_raw];
385
+ }
386
+ else
387
+ [x2r1, x2r2] = [NaN, NaN];
274
388
  if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
275
389
  // Update axis ranges to trigger reactivity and prevent effect from overriding
276
390
  x_axis = { ...x_axis, range: [Math.min(xr1, xr2), Math.max(xr1, xr2)] };
391
+ if (x2_series.length > 0 && Number.isFinite(x2r1) && Number.isFinite(x2r2)) {
392
+ x2_axis = {
393
+ ...x2_axis,
394
+ range: [Math.min(x2r1, x2r2), Math.max(x2r1, x2r2)],
395
+ };
396
+ }
277
397
  y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] };
278
398
  y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] };
279
399
  }
@@ -292,9 +412,11 @@ const on_pan_move = (evt) => {
292
412
  // Convert pixel delta to data delta (note: drag direction is inverted for natural pan feel)
293
413
  const sensitivity = pan?.drag_sensitivity ?? 1;
294
414
  const x_delta = pixels_to_data_delta(-dx * sensitivity, pan_drag_state.initial_x_range, chart_width);
415
+ const x2_delta = pixels_to_data_delta(-dx * sensitivity, pan_drag_state.initial_x2_range, chart_width);
295
416
  const y_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y_range, chart_height);
296
417
  const y2_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y2_range, chart_height);
297
418
  ranges.current.x = pan_range(pan_drag_state.initial_x_range, x_delta);
419
+ ranges.current.x2 = pan_range(pan_drag_state.initial_x2_range, x2_delta);
298
420
  ranges.current.y = pan_range(pan_drag_state.initial_y_range, y_delta);
299
421
  ranges.current.y2 = pan_range(pan_drag_state.initial_y2_range, y2_delta);
300
422
  };
@@ -315,6 +437,7 @@ function handle_mouse_down(evt) {
315
437
  pan_drag_state = {
316
438
  start: { x: evt.clientX, y: evt.clientY },
317
439
  initial_x_range: [...ranges.current.x],
440
+ initial_x2_range: [...ranges.current.x2],
318
441
  initial_y_range: [...ranges.current.y],
319
442
  initial_y2_range: [...ranges.current.y2],
320
443
  };
@@ -343,10 +466,12 @@ function handle_wheel(evt) {
343
466
  const sensitivity = pan?.wheel_sensitivity ?? 1;
344
467
  // Determine pan direction based on wheel delta
345
468
  const x_delta = pixels_to_data_delta(evt.deltaX * sensitivity, ranges.current.x, chart_width);
469
+ const x2_delta = pixels_to_data_delta(evt.deltaX * sensitivity, ranges.current.x2, chart_width);
346
470
  const y_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y, chart_height);
347
471
  const y2_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y2, chart_height);
348
472
  if (Math.abs(evt.deltaX) > Math.abs(evt.deltaY)) {
349
473
  ranges.current.x = pan_range(ranges.current.x, x_delta);
474
+ ranges.current.x2 = pan_range(ranges.current.x2, x2_delta);
350
475
  }
351
476
  else {
352
477
  ranges.current.y = pan_range(ranges.current.y, y_delta);
@@ -363,6 +488,7 @@ function handle_touch_start(evt) {
363
488
  touch_state = {
364
489
  start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
365
490
  initial_x_range: [...ranges.current.x],
491
+ initial_x2_range: [...ranges.current.x2],
366
492
  initial_y_range: [...ranges.current.y],
367
493
  initial_y2_range: [...ranges.current.y2],
368
494
  };
@@ -394,16 +520,23 @@ function handle_touch_move(evt) {
394
520
  // Pinch zoom centered on gesture center
395
521
  // Divide by scale so spread (scale > 1) = smaller span (zoom in)
396
522
  const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0];
523
+ const x2_span = touch_state.initial_x2_range[1] -
524
+ touch_state.initial_x2_range[0];
397
525
  const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0];
398
526
  const y2_span = touch_state.initial_y2_range[1] -
399
527
  touch_state.initial_y2_range[0];
400
528
  const x_center = (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2;
529
+ const x2_center = (touch_state.initial_x2_range[0] + touch_state.initial_x2_range[1]) / 2;
401
530
  const y_center = (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2;
402
531
  const y2_center = (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2;
403
532
  ranges.current.x = [
404
533
  x_center - x_span / scale / 2,
405
534
  x_center + x_span / scale / 2,
406
535
  ];
536
+ ranges.current.x2 = [
537
+ x2_center - x2_span / scale / 2,
538
+ x2_center + x2_span / scale / 2,
539
+ ];
407
540
  ranges.current.y = [
408
541
  y_center - y_span / scale / 2,
409
542
  y_center + y_span / scale / 2,
@@ -416,9 +549,11 @@ function handle_touch_move(evt) {
416
549
  else {
417
550
  // Pan
418
551
  const x_delta = pixels_to_data_delta(-dx, touch_state.initial_x_range, chart_width);
552
+ const x2_delta = pixels_to_data_delta(-dx, touch_state.initial_x2_range, chart_width);
419
553
  const y_delta = pixels_to_data_delta(dy, touch_state.initial_y_range, chart_height);
420
554
  const y2_delta = pixels_to_data_delta(dy, touch_state.initial_y2_range, chart_height);
421
555
  ranges.current.x = pan_range(touch_state.initial_x_range, x_delta);
556
+ ranges.current.x2 = pan_range(touch_state.initial_x2_range, x2_delta);
422
557
  ranges.current.y = pan_range(touch_state.initial_y_range, y_delta);
423
558
  ranges.current.y2 = pan_range(touch_state.initial_y2_range, y2_delta);
424
559
  }
@@ -499,13 +634,15 @@ function toggle_group_visibility(_group_name, series_indices) {
499
634
  let bar_points_for_placement = $derived.by(() => {
500
635
  if (!width || !height || !visible_series.length)
501
636
  return [];
502
- return visible_series.flatMap((srs) => {
637
+ return internal_series.flatMap((srs, series_idx) => {
638
+ if (!(srs?.visible ?? true))
639
+ return [];
503
640
  const is_line = srs.render_mode === `line`;
504
- // Use original series index to look up stacked_offsets
505
- const series_idx = series.indexOf(srs);
506
641
  const series_offsets = stacked_offsets[series_idx] ?? [];
507
642
  const use_y2 = srs.y_axis === `y2`;
508
643
  const y_scale = use_y2 ? scales.y2 : scales.y;
644
+ const use_x2_pl = srs.x_axis === `x2`;
645
+ const x_scale_pl = use_x2_pl ? scales.x2 : scales.x;
509
646
  return srs.x
510
647
  .map((x_val, bar_idx) => {
511
648
  const y_val = srs.y[bar_idx];
@@ -513,8 +650,8 @@ let bar_points_for_placement = $derived.by(() => {
513
650
  ? (series_offsets[bar_idx] ?? 0)
514
651
  : 0;
515
652
  const [bar_x, bar_y] = orientation === `vertical`
516
- ? [scales.x(x_val), y_scale(base + y_val)]
517
- : [scales.x(base + y_val), scales.y(x_val)];
653
+ ? [x_scale_pl(x_val), y_scale(base + y_val)]
654
+ : [x_scale_pl(base + y_val), scales.y(x_val)];
518
655
  return { x: bar_x, y: bar_y };
519
656
  })
520
657
  .filter(({ x, y }) => isFinite(x) && isFinite(y));
@@ -574,7 +711,7 @@ $effect(() => {
574
711
  let hover_info = $state(null);
575
712
  let tooltip_el = $state();
576
713
  function get_bar_data(series_idx, bar_idx, color) {
577
- const srs = series[series_idx];
714
+ const srs = internal_series[series_idx];
578
715
  const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]];
579
716
  const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y];
580
717
  const metadata = Array.isArray(srs.metadata)
@@ -582,9 +719,52 @@ function get_bar_data(series_idx, bar_idx, color) {
582
719
  : srs.metadata;
583
720
  const label = srs.labels?.[bar_idx] ?? null;
584
721
  const active_y_axis = srs.y_axis ?? `y1`;
585
- const coords = { x, y, orient_x, orient_y, x_axis, y_axis, y2_axis };
586
- return { ...coords, metadata, color, label, series_idx, bar_idx, active_y_axis };
722
+ const active_x_axis = srs.x_axis ?? `x1`;
723
+ const category_label = category_list[x];
724
+ const coords = {
725
+ x,
726
+ y,
727
+ orient_x,
728
+ orient_y,
729
+ x_axis: active_x_axis === `x2` ? x2_axis : x_axis,
730
+ x2_axis,
731
+ y_axis: active_y_axis === `y2` ? y2_axis : y_axis,
732
+ y2_axis,
733
+ };
734
+ return {
735
+ ...coords,
736
+ metadata,
737
+ color,
738
+ label,
739
+ series_idx,
740
+ bar_idx,
741
+ active_y_axis,
742
+ active_x_axis,
743
+ category_label,
744
+ };
745
+ }
746
+ // Find the point closest to the cursor on a polyline overlay (O(n) scan).
747
+ function find_closest_point(evt, points) {
748
+ const svg_el = evt.target.closest(`svg`);
749
+ if (!svg_el)
750
+ return null;
751
+ const rect = svg_el.getBoundingClientRect();
752
+ const mx = evt.clientX - rect.left;
753
+ const my = evt.clientY - rect.top;
754
+ let best = null;
755
+ let best_dist = Infinity;
756
+ for (const pt of points) {
757
+ const dist = (pt.x - mx) ** 2 + (pt.y - my) ** 2;
758
+ if (dist < best_dist) {
759
+ best_dist = dist;
760
+ best = pt;
761
+ }
762
+ }
763
+ return best;
587
764
  }
765
+ const line_point_fill = (pt, series_color) => pt.color_value != null
766
+ ? color_scale_fn(pt.color_value)
767
+ : pt.point_style?.fill ?? series_color;
588
768
  const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
589
769
  hovered = true;
590
770
  hover_info = get_bar_data(series_idx, bar_idx, color);
@@ -595,14 +775,14 @@ const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
595
775
  let stacked_offsets = $derived.by(() => {
596
776
  if (mode !== `stacked`)
597
777
  return [];
598
- const max_len = Math.max(0, ...series.map((srs) => srs.y.length));
599
- const offsets = series.map(() => Array.from({ length: max_len }, () => 0));
778
+ const max_len = Math.max(0, ...internal_series.map((srs) => srs.y.length));
779
+ const offsets = internal_series.map(() => Array.from({ length: max_len }, () => 0));
600
780
  // Separate accumulators for y1 and y2 axes
601
781
  const y1_pos_acc = Array.from({ length: max_len }, () => 0);
602
782
  const y1_neg_acc = Array.from({ length: max_len }, () => 0);
603
783
  const y2_pos_acc = Array.from({ length: max_len }, () => 0);
604
784
  const y2_neg_acc = Array.from({ length: max_len }, () => 0);
605
- series.forEach((srs, series_idx) => {
785
+ internal_series.forEach((srs, series_idx) => {
606
786
  if (!(srs?.visible ?? true) || srs.render_mode === `line`)
607
787
  return;
608
788
  const use_y2 = srs.y_axis === `y2`;
@@ -621,7 +801,7 @@ let stacked_offsets = $derived.by(() => {
621
801
  let group_info = $derived.by(() => {
622
802
  if (mode !== `grouped`)
623
803
  return { bar_series_count: 0, bar_series_indices: [] };
624
- const bar_series_indices = series
804
+ const bar_series_indices = internal_series
625
805
  .map((srs, idx) => (srs?.visible ?? true) && srs.render_mode !== `line` ? idx : -1)
626
806
  .filter((idx) => idx >= 0);
627
807
  return { bar_series_count: bar_series_indices.length, bar_series_indices };
@@ -632,11 +812,21 @@ $effect(() => {
632
812
  });
633
813
  // State accessors for shared axis change handler
634
814
  const axis_state = {
635
- get_axis: (axis) => (axis === `x` ? x_axis : axis === `y` ? y_axis : y2_axis),
815
+ get_axis: (axis) => {
816
+ if (axis === `x`)
817
+ return x_axis;
818
+ if (axis === `x2`)
819
+ return x2_axis;
820
+ if (axis === `y`)
821
+ return y_axis;
822
+ return y2_axis;
823
+ },
636
824
  set_axis: (axis, config) => {
637
825
  // Spread into existing state to preserve merged type structure
638
826
  if (axis === `x`)
639
827
  x_axis = { ...x_axis, ...config };
828
+ else if (axis === `x2`)
829
+ x2_axis = { ...x2_axis, ...config };
640
830
  else if (axis === `y`)
641
831
  y_axis = { ...y_axis, ...config };
642
832
  else
@@ -674,11 +864,12 @@ $effect(() => {
674
864
  <ReferenceLine
675
865
  ref_line={line}
676
866
  line_idx={line.idx}
677
- x_min={ranges.current.x[0]}
678
- x_max={ranges.current.x[1]}
867
+ x_min={line.x_axis === `x2` ? ranges.current.x2[0] : ranges.current.x[0]}
868
+ x_max={line.x_axis === `x2` ? ranges.current.x2[1] : ranges.current.x[1]}
679
869
  y_min={line.y_axis === `y2` ? ranges.current.y2[0] : ranges.current.y[0]}
680
870
  y_max={line.y_axis === `y2` ? ranges.current.y2[1] : ranges.current.y[1]}
681
871
  x_scale={scales.x}
872
+ x2_scale={scales.x2}
682
873
  y_scale={scales.y}
683
874
  y2_scale={scales.y2}
684
875
  {clip_path_id}
@@ -729,6 +920,8 @@ $effect(() => {
729
920
  <svg
730
921
  bind:this={svg_element}
731
922
  role="application"
923
+ aria-label={rest[`aria-label`] ??
924
+ ([x_axis.label, y_axis.label].filter(Boolean).join(` vs `) || `Bar chart`)}
732
925
  tabindex="0"
733
926
  onfocusin={() => (is_focused = true)}
734
927
  onfocusout={() => (is_focused = false)}
@@ -736,10 +929,12 @@ $effect(() => {
736
929
  ondblclick={() => {
737
930
  // Reset zoom to initial ranges (undo any pan/zoom)
738
931
  ranges.current.x = [...ranges.initial.x] as [number, number]
932
+ ranges.current.x2 = [...ranges.initial.x2] as [number, number]
739
933
  ranges.current.y = [...ranges.initial.y] as [number, number]
740
934
  ranges.current.y2 = [...ranges.initial.y2] as [number, number]
741
935
  // Also reset axis props so future data changes recalculate auto ranges
742
936
  x_axis = { ...x_axis, range: [null, null] }
937
+ x2_axis = { ...x2_axis, range: [null, null] }
743
938
  y_axis = { ...y_axis, range: [null, null] }
744
939
  y2_axis = { ...y2_axis, range: [null, null] }
745
940
  }}
@@ -759,26 +954,19 @@ $effect(() => {
759
954
  ? `grab`
760
955
  : `crosshair`}
761
956
  >
762
- <!-- Zoom rectangle -->
763
- {#if drag_state.start && drag_state.current && isFinite(drag_state.start.x) &&
764
- isFinite(drag_state.start.y) && isFinite(drag_state.current.x) &&
765
- isFinite(drag_state.current.y)}
766
- {@const x = Math.min(drag_state.start.x, drag_state.current.x)}
767
- {@const y = Math.min(drag_state.start.y, drag_state.current.y)}
768
- {@const rect_w = Math.abs(drag_state.start.x - drag_state.current.x)}
769
- {@const rect_h = Math.abs(drag_state.start.y - drag_state.current.y)}
770
- <rect class="zoom-rect" {x} {y} width={rect_w} height={rect_h} />
771
- {/if}
957
+ <ZoomRect start={drag_state.start} current={drag_state.current} />
772
958
 
773
959
  <!-- User content (custom overlays, reference lines, etc.) -->
774
960
  {@render user_content?.({
775
961
  height,
776
962
  width,
777
963
  x_scale_fn: scales.x,
964
+ x2_scale_fn: scales.x2,
778
965
  y_scale_fn: scales.y,
779
966
  y2_scale_fn: scales.y2,
780
967
  pad,
781
968
  x_range: ranges.current.x,
969
+ x2_range: ranges.current.x2,
782
970
  y_range: ranges.current.y,
783
971
  y2_range: ranges.current.y2,
784
972
  fullscreen,
@@ -833,37 +1021,106 @@ $effect(() => {
833
1021
  ? `rotate(${rotation}, ${shift_x}, ${text_y})`
834
1022
  : undefined}
835
1023
  >
836
- {format_value(tick, x_axis.format)}
1024
+ {
1025
+ get_tick_label(
1026
+ tick as number,
1027
+ cat_axis === `x` ? effective_cat_ticks : x_axis.ticks,
1028
+ ) ??
1029
+ format_value(tick, x_axis.format)
1030
+ }
837
1031
  </text>
838
1032
  </g>
839
1033
  {/if}
840
1034
  {/each}
841
1035
  {#if x_axis.label || x_axis.options?.length}
842
- {@const shift_x = x_axis.label_shift?.x ?? 0}
843
- {@const shift_y = x_axis.label_shift?.y ?? 0}
844
- <foreignObject
845
- x={pad.l + chart_width / 2 + shift_x - AXIS_LABEL_CONTAINER.x_offset}
846
- y={height - (pad.b / 3) + shift_y - AXIS_LABEL_CONTAINER.y_offset}
847
- width={AXIS_LABEL_CONTAINER.width}
848
- height={AXIS_LABEL_CONTAINER.height}
849
- style="overflow: visible; pointer-events: none"
850
- >
851
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
852
- <InteractiveAxisLabel
853
- label={x_axis.label ?? ``}
854
- options={x_axis.options}
855
- selected_key={x_axis.selected_key}
856
- loading={axis_loading === `x`}
857
- axis_type="x"
858
- color={x_axis.color}
859
- on_select={(key) => handle_axis_change(`x`, key)}
860
- class="axis-label x-label"
861
- />
862
- </div>
863
- </foreignObject>
1036
+ {@const { label_shift, label = ``, options, selected_key, color } = x_axis}
1037
+ <AxisLabel
1038
+ x={pad.l + chart_width / 2 + (label_shift?.x ?? 0)}
1039
+ y={height - (pad.b / 3) + (label_shift?.y ?? 0)}
1040
+ {label}
1041
+ {options}
1042
+ {selected_key}
1043
+ loading={axis_loading === `x`}
1044
+ axis_type="x"
1045
+ {color}
1046
+ on_select={(key) => handle_axis_change(`x`, key)}
1047
+ />
864
1048
  {/if}
865
1049
  </g>
866
1050
 
1051
+ <!-- X2-axis (Top) -->
1052
+ <!-- Note: x2 axis is only supported for vertical orientation -->
1053
+ {#if x2_series.length > 0 && orientation === `vertical`}
1054
+ <g class="x2-axis">
1055
+ <line
1056
+ x1={pad.l}
1057
+ x2={width - pad.r}
1058
+ y1={pad.t}
1059
+ y2={pad.t}
1060
+ stroke={x2_axis.color || `var(--border-color, gray)`}
1061
+ stroke-width="1"
1062
+ />
1063
+ {#each ticks.x2 as tick (tick)}
1064
+ {@const tick_x = scales.x2(tick as number)}
1065
+ {#if isFinite(tick_x)}
1066
+ {@const rotation = x2_axis.tick?.label?.rotation ?? 0}
1067
+ {@const shift_x = x2_axis.tick?.label?.shift?.x ?? 0}
1068
+ {@const shift_y = x2_axis.tick?.label?.shift?.y ?? 0}
1069
+ {@const inside = x2_axis.tick?.label?.inside ?? false}
1070
+ {@const base_y = inside ? 8 : (rotation !== 0 ? -8 : -18)}
1071
+ {@const text_y = base_y + shift_y}
1072
+ {@const text_anchor = rotation !== 0 ? (inside ? `start` : `end`) : `middle`}
1073
+ {@const dominant_baseline = inside ? `hanging` : `auto`}
1074
+ <g class="tick" transform="translate({tick_x}, {pad.t})">
1075
+ {#if display.x2_grid}
1076
+ <line
1077
+ y1="0"
1078
+ y2={height - pad.b - pad.t}
1079
+ {...DEFAULT_GRID_STYLE}
1080
+ {...(x2_axis.grid_style ?? {})}
1081
+ />
1082
+ {/if}
1083
+ <line
1084
+ y1={inside ? 5 : 0}
1085
+ y2={inside ? 0 : -5}
1086
+ stroke={x2_axis.color || `var(--border-color, gray)`}
1087
+ stroke-width="1"
1088
+ />
1089
+ <text
1090
+ x={shift_x}
1091
+ y={text_y}
1092
+ text-anchor={text_anchor}
1093
+ dominant-baseline={dominant_baseline}
1094
+ fill={x2_axis.color || `var(--text-color)`}
1095
+ transform={rotation !== 0
1096
+ ? `rotate(${rotation}, ${shift_x}, ${text_y})`
1097
+ : undefined}
1098
+ >
1099
+ {
1100
+ get_tick_label(tick as number, x2_axis.ticks) ??
1101
+ format_value(tick, x2_axis.format)
1102
+ }
1103
+ </text>
1104
+ </g>
1105
+ {/if}
1106
+ {/each}
1107
+ {#if x2_axis.label || x2_axis.options?.length}
1108
+ {@const { label_shift, label = ``, options, selected_key, color } = x2_axis}
1109
+ <AxisLabel
1110
+ x={pad.l + chart_width / 2 + (label_shift?.x ?? 0)}
1111
+ y={Math.max(12, pad.t - (label_shift?.y ?? 40))}
1112
+ {label}
1113
+ {options}
1114
+ {selected_key}
1115
+ loading={axis_loading === `x2`}
1116
+ axis_type="x2"
1117
+ {color}
1118
+ on_select={(key) => handle_axis_change(`x2`, key)}
1119
+ />
1120
+ {/if}
1121
+ </g>
1122
+ {/if}
1123
+
867
1124
  <!-- Y-axis -->
868
1125
  <g class="y-axis">
869
1126
  <line
@@ -909,47 +1166,36 @@ $effect(() => {
909
1166
  ? `rotate(${rotation}, ${text_x}, ${shift_y})`
910
1167
  : undefined}
911
1168
  >
912
- {format_value(tick, y_axis.format)}
1169
+ {
1170
+ get_tick_label(
1171
+ tick as number,
1172
+ cat_axis === `y` ? effective_cat_ticks : y_axis.ticks,
1173
+ ) ??
1174
+ format_value(tick, y_axis.format)
1175
+ }
913
1176
  </text>
914
1177
  </g>
915
1178
  {/if}
916
1179
  {/each}
917
1180
  {#if y_axis.label || y_axis.options?.length}
918
- {@const max_y_tick_width = Math.max(
919
- 0,
920
- ...ticks.y.map((tick) =>
921
- measure_text_width(
922
- format_value(tick, y_axis.format),
923
- `12px sans-serif`,
924
- )
925
- ),
926
- )}
927
- {@const shift_x = y_axis.label_shift?.x ?? 0}
928
- {@const shift_y = y_axis.label_shift?.y ?? 0}
929
- {@const y_label_x = Math.max(12, pad.l - max_y_tick_width - LABEL_GAP_DEFAULT) +
930
- shift_x}
931
- {@const y_label_y = pad.t + chart_height / 2 + shift_y}
932
- <foreignObject
933
- x={y_label_x - AXIS_LABEL_CONTAINER.x_offset}
934
- y={y_label_y - AXIS_LABEL_CONTAINER.y_offset}
935
- width={AXIS_LABEL_CONTAINER.width}
936
- height={AXIS_LABEL_CONTAINER.height}
937
- style="overflow: visible; pointer-events: none"
938
- transform="rotate(-90, {y_label_x}, {y_label_y})"
939
- >
940
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
941
- <InteractiveAxisLabel
942
- label={y_axis.label ?? ``}
943
- options={y_axis.options}
944
- selected_key={y_axis.selected_key}
945
- loading={axis_loading === `y`}
946
- axis_type="y"
947
- color={y_axis.color}
948
- on_select={(key) => handle_axis_change(`y`, key)}
949
- class="axis-label y-label"
950
- />
951
- </div>
952
- </foreignObject>
1181
+ {@const { label_shift, label = ``, options, selected_key, color, tick } = y_axis}
1182
+ {@const y_inside = tick?.label?.inside ?? false}
1183
+ <AxisLabel
1184
+ x={Math.max(
1185
+ 12,
1186
+ pad.l - (y_inside ? 0 : tick_label_widths.y_max) - LABEL_GAP_DEFAULT,
1187
+ ) +
1188
+ (label_shift?.x ?? 0)}
1189
+ y={pad.t + chart_height / 2 + (label_shift?.y ?? 0)}
1190
+ rotate
1191
+ {label}
1192
+ {options}
1193
+ {selected_key}
1194
+ loading={axis_loading === `y`}
1195
+ axis_type="y"
1196
+ {color}
1197
+ on_select={(key) => handle_axis_change(`y`, key)}
1198
+ />
953
1199
  {/if}
954
1200
  </g>
955
1201
 
@@ -999,51 +1245,33 @@ $effect(() => {
999
1245
  ? `rotate(${rotation}, ${shift_x}, ${shift_y})`
1000
1246
  : undefined}
1001
1247
  >
1002
- {format_value(tick, y2_axis.format)}
1248
+ {
1249
+ get_tick_label(tick as number, y2_axis.ticks) ??
1250
+ format_value(tick, y2_axis.format)
1251
+ }
1003
1252
  </text>
1004
1253
  </g>
1005
1254
  {/if}
1006
1255
  {/each}
1007
1256
  {#if y2_axis.label || y2_axis.options?.length}
1008
- {@const max_y2_tick_width = Math.max(
1009
- 0,
1010
- ...ticks.y2.map((tick) =>
1011
- measure_text_width(
1012
- format_value(tick, y2_axis.format),
1013
- `12px sans-serif`,
1014
- )
1015
- ),
1016
- )}
1017
- {@const shift_x = y2_axis.label_shift?.x ?? 0}
1018
- {@const shift_y = y2_axis.label_shift?.y ?? 0}
1019
- {@const inside = y2_axis.tick?.label?.inside ?? false}
1020
- {@const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8}
1021
- {@const tick_width_contribution = inside ? 0 : max_y2_tick_width}
1022
- {@const y2_label_x = width - pad.r + tick_shift + tick_width_contribution +
1023
- LABEL_GAP_DEFAULT +
1024
- shift_x}
1025
- {@const y2_label_y = pad.t + chart_height / 2 + shift_y}
1026
- <foreignObject
1027
- x={y2_label_x - AXIS_LABEL_CONTAINER.x_offset}
1028
- y={y2_label_y - AXIS_LABEL_CONTAINER.y_offset}
1029
- width={AXIS_LABEL_CONTAINER.width}
1030
- height={AXIS_LABEL_CONTAINER.height}
1031
- style="overflow: visible; pointer-events: none"
1032
- transform="rotate(-90, {y2_label_x}, {y2_label_y})"
1033
- >
1034
- <div xmlns="http://www.w3.org/1999/xhtml" style="pointer-events: auto">
1035
- <InteractiveAxisLabel
1036
- label={y2_axis.label ?? ``}
1037
- options={y2_axis.options}
1038
- selected_key={y2_axis.selected_key}
1039
- loading={axis_loading === `y2`}
1040
- axis_type="y2"
1041
- color={y2_axis.color}
1042
- on_select={(key) => handle_axis_change(`y2`, key)}
1043
- class="axis-label y2-label"
1044
- />
1045
- </div>
1046
- </foreignObject>
1257
+ {@const { label_shift, label = ``, options, selected_key, color, tick } =
1258
+ y2_axis}
1259
+ {@const inside = tick?.label?.inside ?? false}
1260
+ {@const tick_shift = inside ? 0 : (tick?.label?.shift?.x ?? 0) + 8}
1261
+ {@const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max}
1262
+ <AxisLabel
1263
+ x={width - pad.r + tick_shift + tick_width_contribution +
1264
+ LABEL_GAP_DEFAULT + (label_shift?.x ?? 0)}
1265
+ y={pad.t + chart_height / 2 + (label_shift?.y ?? 0)}
1266
+ rotate
1267
+ {label}
1268
+ {options}
1269
+ {selected_key}
1270
+ loading={axis_loading === `y2`}
1271
+ axis_type="y2"
1272
+ {color}
1273
+ on_select={(key) => handle_axis_change(`y2`, key)}
1274
+ />
1047
1275
  {/if}
1048
1276
  </g>
1049
1277
  {/if}
@@ -1057,43 +1285,34 @@ $effect(() => {
1057
1285
 
1058
1286
  <!-- Clipped content: zero lines, bars, and lines -->
1059
1287
  <g clip-path="url(#{clip_path_id})">
1060
- <!-- Zero lines (shown for linear and arcsinh scales, not log) -->
1061
- {#if display.x_zero_line &&
1062
- get_scale_type_name(x_axis.scale_type) !== `log` &&
1063
- ranges.current.x[0] <= 0 && ranges.current.x[1] >= 0}
1064
- {@const zx = scales.x(0)}
1065
- {#if isFinite(zx)}
1066
- <line class="zero-line" x1={zx} x2={zx} y1={pad.t} y2={height - pad.b} />
1067
- {/if}
1068
- {/if}
1069
- {#if display.y_zero_line &&
1070
- get_scale_type_name(y_axis.scale_type) !== `log` &&
1071
- ranges.current.y[0] <= 0 && ranges.current.y[1] >= 0}
1072
- {@const zy = scales.y(0)}
1073
- {#if isFinite(zy)}
1074
- <line class="zero-line" x1={pad.l} x2={width - pad.r} y1={zy} y2={zy} />
1075
- {/if}
1076
- {/if}
1077
- {#if display.y_zero_line && y2_series.length > 0 &&
1078
- get_scale_type_name(y2_axis.scale_type) !== `log` &&
1079
- ranges.current.y2[0] <= 0 && ranges.current.y2[1] >= 0}
1080
- {@const zero_y2 = scales.y2(0)}
1081
- {#if isFinite(zero_y2)}
1082
- <line
1083
- class="zero-line"
1084
- x1={pad.l}
1085
- x2={width - pad.r}
1086
- y1={zero_y2}
1087
- y2={zero_y2}
1088
- />
1089
- {/if}
1090
- {/if}
1288
+ <ZeroLines
1289
+ {display}
1290
+ x_scale_fn={scales.x}
1291
+ x2_scale_fn={scales.x2}
1292
+ y_scale_fn={scales.y}
1293
+ y2_scale_fn={scales.y2}
1294
+ x_range={ranges.current.x}
1295
+ x2_range={ranges.current.x2}
1296
+ y_range={ranges.current.y}
1297
+ y2_range={ranges.current.y2}
1298
+ x_scale_type={x_axis.scale_type}
1299
+ x2_scale_type={x2_axis.scale_type}
1300
+ y_scale_type={y_axis.scale_type}
1301
+ y2_scale_type={y2_axis.scale_type}
1302
+ x_is_time={x_axis.format?.startsWith(`%`) ?? false}
1303
+ x2_is_time={x2_axis.format?.startsWith(`%`) ?? false}
1304
+ has_x2={x2_series.length > 0}
1305
+ has_y2={y2_series.length > 0}
1306
+ {width}
1307
+ {height}
1308
+ {pad}
1309
+ />
1091
1310
 
1092
1311
  <!-- Reference lines: below lines -->
1093
1312
  {@render ref_lines_layer(ref_lines_by_z.below_lines)}
1094
1313
 
1095
1314
  <!-- Bars and Lines -->
1096
- {#each series as srs, series_idx (srs?.id ?? series_idx)}
1315
+ {#each internal_series as srs, series_idx (srs?.id ?? series_idx)}
1097
1316
  {#if srs?.visible ?? true}
1098
1317
  {@const is_line = srs.render_mode === `line`}
1099
1318
  <g
@@ -1107,6 +1326,8 @@ $effect(() => {
1107
1326
  {@const line_dash = srs.line_style?.line_dash ?? `none`}
1108
1327
  {@const use_y2 = srs.y_axis === `y2`}
1109
1328
  {@const y_scale = use_y2 ? scales.y2 : scales.y}
1329
+ {@const use_x2 = srs.x_axis === `x2`}
1330
+ {@const x_scale = use_x2 ? scales.x2 : scales.x}
1110
1331
  {@const series_markers = srs.markers ?? DEFAULT_MARKERS}
1111
1332
  {@const show_line = series_markers === `line` ||
1112
1333
  series_markers === `line+points`}
@@ -1116,8 +1337,8 @@ $effect(() => {
1116
1337
  const y_val = srs.y[idx]
1117
1338
  // Lines don't stack - they show absolute values (useful for totals/trends)
1118
1339
  const plot_x = orientation === `vertical`
1119
- ? scales.x(x_val)
1120
- : scales.x(y_val)
1340
+ ? x_scale(x_val)
1341
+ : x_scale(y_val)
1121
1342
  const plot_y = orientation === `vertical`
1122
1343
  ? y_scale(y_val)
1123
1344
  : scales.y(x_val)
@@ -1148,9 +1369,12 @@ $effect(() => {
1148
1369
  point_idx: idx,
1149
1370
  } as LineSeriesPoint
1150
1371
  }).filter((pt) => isFinite(pt.x) && isFinite(pt.y))}
1151
- {#if show_line && points.length > 1}
1372
+ {@const polyline_str = show_line && points.length > 1
1373
+ ? points.map((pt) => `${pt.x},${pt.y}`).join(` `)
1374
+ : ``}
1375
+ {#if polyline_str}
1152
1376
  <polyline
1153
- points={points.map((pt) => `${pt.x},${pt.y}`).join(` `)}
1377
+ points={polyline_str}
1154
1378
  fill="none"
1155
1379
  stroke={color}
1156
1380
  stroke-width={stroke_width}
@@ -1159,41 +1383,58 @@ $effect(() => {
1159
1383
  stroke-linecap="round"
1160
1384
  />
1161
1385
  {/if}
1162
- <!-- Add invisible wider line for easier hovering (only if no points shown) -->
1163
- {#if show_line && !show_points && points.length > 1 &&
1164
- (on_bar_hover || on_bar_click)}
1386
+ {#if polyline_str && !show_points && (on_bar_hover || on_bar_click)}
1387
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1388
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1165
1389
  <polyline
1166
- points={points.map((pt) => `${pt.x},${pt.y}`).join(` `)}
1390
+ points={polyline_str}
1167
1391
  fill="none"
1168
1392
  stroke="transparent"
1169
1393
  stroke-width={Math.max(10, stroke_width * 3)}
1170
1394
  stroke-linejoin="round"
1171
1395
  stroke-linecap="round"
1172
1396
  style:cursor={on_bar_click ? `pointer` : undefined}
1397
+ onmousemove={(evt) => {
1398
+ const pt = find_closest_point(evt, points)
1399
+ if (!pt) return
1400
+ hovered = true
1401
+ const fill = line_point_fill(pt, color)
1402
+ hover_info = get_bar_data(series_idx, pt.idx, fill)
1403
+ change(hover_info)
1404
+ on_bar_hover?.({ ...hover_info!, event: evt })
1405
+ }}
1406
+ onmouseleave={() => {
1407
+ change(null)
1408
+ hover_info = null
1409
+ on_bar_hover?.(null)
1410
+ }}
1411
+ onclick={(evt) => {
1412
+ const pt = find_closest_point(evt, points)
1413
+ if (!pt) return
1414
+ const fill = line_point_fill(pt, color)
1415
+ const bar_data = get_bar_data(series_idx, pt.idx, fill)
1416
+ on_bar_click?.({ ...bar_data, event: evt })
1417
+ }}
1173
1418
  />
1174
1419
  {/if}
1175
1420
  {#if show_points}
1176
1421
  {@const clickable = on_bar_click || on_point_click}
1177
- {@const get_pt = (evt: Event) =>
1178
- points.find((pt) =>
1179
- pt.idx ===
1180
- parseInt(
1181
- (evt.target as Element)?.closest(`[data-bar-idx]`)
1182
- ?.getAttribute(`data-bar-idx`) ?? ``,
1183
- 10,
1184
- )
1185
- )}
1186
- {@const fill = (pt: LineSeriesPoint) =>
1187
- pt.color_value != null
1188
- ? color_scale_fn(pt.color_value)
1189
- : pt.point_style?.fill ?? color}
1422
+ {@const get_pt = (evt: Event) => {
1423
+ const attr = evt.target instanceof Element
1424
+ ? evt.target.closest(`[data-bar-idx]`)?.getAttribute(
1425
+ `data-bar-idx`,
1426
+ )
1427
+ : null
1428
+ return points.find((pt) => pt.idx === parseInt(attr ?? ``, 10))
1429
+ }}
1190
1430
  {@const set_hover = (
1191
1431
  pt: LineSeriesPoint | null,
1192
1432
  evt: MouseEvent | FocusEvent,
1193
1433
  ) => {
1194
1434
  if (pt) {
1195
1435
  hovered = true
1196
- hover_info = get_bar_data(series_idx, pt.idx, fill(pt))
1436
+ const fill = line_point_fill(pt, color)
1437
+ hover_info = get_bar_data(series_idx, pt.idx, fill)
1197
1438
  change(hover_info)
1198
1439
  } else {
1199
1440
  change(null)
@@ -1208,13 +1449,15 @@ $effect(() => {
1208
1449
  pt: LineSeriesPoint,
1209
1450
  evt: MouseEvent | KeyboardEvent,
1210
1451
  ) => {
1211
- const bar_data = get_bar_data(series_idx, pt.idx, fill(pt))
1452
+ const fill = line_point_fill(pt, color)
1453
+ const bar_data = get_bar_data(series_idx, pt.idx, fill)
1212
1454
  on_bar_click?.({ ...bar_data, event: evt })
1213
1455
  on_point_click?.({ ...bar_data, event: evt, point: pt })
1214
1456
  }}
1215
1457
  {@const leaving = (evt: MouseEvent | FocusEvent) =>
1216
- (evt.relatedTarget as Element)?.closest(`.line-points`) !==
1217
- evt.currentTarget}
1458
+ (evt.relatedTarget instanceof Element
1459
+ ? evt.relatedTarget.closest(`.line-points`)
1460
+ : null) !== evt.currentTarget}
1218
1461
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_mouse_events_have_key_events -->
1219
1462
  <g
1220
1463
  class="line-points"
@@ -1247,7 +1490,7 @@ $effect(() => {
1247
1490
  >
1248
1491
  {#each points as pt (pt.idx)}
1249
1492
  {@const sty = pt.point_style}
1250
- {@const fl = fill(pt)}
1493
+ {@const fl = line_point_fill(pt, color)}
1251
1494
  {@const rad = pt.size_value != null
1252
1495
  ? size_scale_fn(pt.size_value)
1253
1496
  : sty?.radius ?? 4}
@@ -1306,9 +1549,11 @@ $effect(() => {
1306
1549
  {@const val = y_val}
1307
1550
  {@const use_y2 = srs.y_axis === `y2`}
1308
1551
  {@const y_scale = use_y2 ? scales.y2 : scales.y}
1552
+ {@const use_x2_bar = srs.x_axis === `x2`}
1553
+ {@const x_scale_bar = use_x2_bar ? scales.x2 : scales.x}
1309
1554
  {@const [cat_scale, val_scale] = is_vertical
1310
- ? [scales.x, y_scale]
1311
- : [scales.y, scales.x]}
1555
+ ? [x_scale_bar, y_scale]
1556
+ : [scales.y, x_scale_bar]}
1312
1557
  {@const c0 = cat_scale(cat_val + group_offset - half)}
1313
1558
  {@const c1 = cat_scale(cat_val + group_offset + half)}
1314
1559
  {@const v0 = val_scale(base)}
@@ -1405,7 +1650,9 @@ $effect(() => {
1405
1650
  {/if}
1406
1651
 
1407
1652
  {#if hover_info && hovered}
1408
- {@const cx = scales.x(hover_info.orient_x)}
1653
+ {@const cx = (hover_info.active_x_axis === `x2` ? scales.x2 : scales.x)(
1654
+ hover_info.orient_x,
1655
+ )}
1409
1656
  {@const cy = (hover_info.active_y_axis === `y2` ? scales.y2 : scales.y)(
1410
1657
  hover_info.orient_y,
1411
1658
  )}
@@ -1418,7 +1665,6 @@ $effect(() => {
1418
1665
  height,
1419
1666
  { offset_x: 10, offset_y: 5 },
1420
1667
  )}
1421
- {@const active_y_config = hover_info.active_y_axis === `y2` ? y2_axis : y_axis}
1422
1668
  <PlotTooltip
1423
1669
  x={tooltip_pos.x}
1424
1670
  y={tooltip_pos.y}
@@ -1429,14 +1675,20 @@ $effect(() => {
1429
1675
  {#if tooltip}
1430
1676
  {@render tooltip({ ...hover_info, fullscreen })}
1431
1677
  {:else}
1678
+ {@const series_label = series[hover_info.series_idx]?.label}
1679
+ {#if series.length > 1 && series_label}
1680
+ <div><strong>{series_label}</strong></div>
1681
+ {/if}
1432
1682
  <div>
1433
- {@html x_axis.label || `x`}: {
1434
- format_value(hover_info.orient_x, x_axis.format || `.3~s`)
1683
+ {@html hover_info.x_axis.label || `x`}: {
1684
+ (cat_axis === `x` ? hover_info.category_label : undefined) ??
1685
+ format_value(hover_info.orient_x, hover_info.x_axis.format || `.3~s`)
1435
1686
  }
1436
1687
  </div>
1437
1688
  <div>
1438
- {@html active_y_config.label || `y`}: {
1439
- format_value(hover_info.orient_y, active_y_config.format || `.3~s`)
1689
+ {@html hover_info.y_axis.label || `y`}: {
1690
+ (cat_axis === `y` ? hover_info.category_label : undefined) ??
1691
+ format_value(hover_info.orient_y, hover_info.y_axis.format || `.3~s`)
1440
1692
  }
1441
1693
  </div>
1442
1694
  {/if}
@@ -1447,7 +1699,7 @@ $effect(() => {
1447
1699
  <BarPlotControls
1448
1700
  toggle_props={{
1449
1701
  ...controls_toggle_props,
1450
- style: `--ctrl-btn-right: var(--fullscreen-btn-offset, 36px); top: 4px; ${
1702
+ style: `--ctrl-btn-right: var(--fullscreen-btn-offset, 30px); ${
1451
1703
  controls_toggle_props?.style ?? ``
1452
1704
  }`,
1453
1705
  }}
@@ -1457,12 +1709,16 @@ $effect(() => {
1457
1709
  bind:orientation
1458
1710
  bind:mode
1459
1711
  bind:x_axis
1712
+ bind:x2_axis
1460
1713
  bind:y_axis
1461
1714
  bind:y2_axis
1462
1715
  bind:display
1463
1716
  auto_x_range={auto_ranges.x as Vec2}
1717
+ auto_x2_range={auto_ranges.x2 as Vec2}
1464
1718
  auto_y_range={auto_ranges.y as Vec2}
1465
1719
  auto_y2_range={auto_ranges.y2 as Vec2}
1720
+ has_x2_points={x2_series.length > 0}
1721
+ has_y2_points={y2_series.length > 0}
1466
1722
  children={controls_extra}
1467
1723
  />
1468
1724
  {/if}
@@ -1542,22 +1798,11 @@ $effect(() => {
1542
1798
  border: var(--barplot-dragover-border, var(--dragover-border));
1543
1799
  background-color: var(--barplot-dragover-bg, var(--dragover-bg));
1544
1800
  }
1545
- g:is(.x-axis, .y-axis, .y2-axis) .tick text {
1801
+ g:is(.x-axis, .x2-axis, .y-axis, .y2-axis) .tick text {
1546
1802
  font-size: var(--tick-font-size, 0.8em);
1547
1803
  }
1548
- .zoom-rect {
1549
- fill: var(--barplot-zoom-rect-fill, rgba(100, 100, 255, 0.2));
1550
- stroke: var(--barplot-zoom-rect-stroke, rgba(100, 100, 255, 0.8));
1551
- stroke-width: var(--barplot-zoom-rect-stroke-width, 1);
1552
- pointer-events: none;
1553
- }
1554
1804
  .bar-label {
1555
1805
  fill: var(--text-color);
1556
1806
  font-size: 11px;
1557
1807
  }
1558
- .zero-line {
1559
- stroke: var(--barplot-zero-line-color, light-dark(black, white));
1560
- stroke-width: var(--barplot-zero-line-width, 1);
1561
- opacity: var(--barplot-zero-line-opacity, 0.3);
1562
- }
1563
1808
  </style>