matterviz 0.3.2 → 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 (280) hide show
  1. package/dist/EmptyState.svelte +10 -2
  2. package/dist/FilePicker.svelte +123 -82
  3. package/dist/Icon.svelte +18 -12
  4. package/dist/MillerIndexInput.svelte +27 -21
  5. package/dist/api/optimade.js +6 -6
  6. package/dist/app.css +216 -207
  7. package/dist/brillouin/BrillouinZone.svelte +292 -149
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  9. package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +69 -42
  11. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte +275 -163
  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 +162 -27
  18. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +451 -281
  19. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2148 -1642
  20. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -5
  21. package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
  22. package/dist/chempot-diagram/async-compute.svelte.js +77 -0
  23. package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
  24. package/dist/chempot-diagram/chempot-worker.js +11 -0
  25. package/dist/chempot-diagram/color.js +1 -2
  26. package/dist/chempot-diagram/compute.d.ts +10 -0
  27. package/dist/chempot-diagram/compute.js +250 -88
  28. package/dist/chempot-diagram/index.d.ts +2 -1
  29. package/dist/chempot-diagram/index.js +2 -1
  30. package/dist/chempot-diagram/temperature.js +8 -9
  31. package/dist/chempot-diagram/types.d.ts +3 -0
  32. package/dist/chempot-diagram/types.js +1 -0
  33. package/dist/colors/index.d.ts +1 -1
  34. package/dist/colors/index.js +5 -3
  35. package/dist/composition/BarChart.svelte +128 -55
  36. package/dist/composition/BubbleChart.svelte +102 -49
  37. package/dist/composition/Composition.svelte +100 -79
  38. package/dist/composition/Formula.svelte +108 -62
  39. package/dist/composition/FormulaFilter.svelte +665 -537
  40. package/dist/composition/PieChart.svelte +183 -108
  41. package/dist/composition/format.d.ts +5 -0
  42. package/dist/composition/format.js +20 -3
  43. package/dist/composition/parse.js +14 -9
  44. package/dist/convex-hull/ConvexHull.svelte +93 -40
  45. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  46. package/dist/convex-hull/ConvexHull2D.svelte +549 -360
  47. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  48. package/dist/convex-hull/ConvexHull3D.svelte +1296 -827
  49. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  50. package/dist/convex-hull/ConvexHull4D.svelte +1004 -688
  51. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  52. package/dist/convex-hull/ConvexHullControls.svelte +115 -28
  53. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +1 -1
  54. package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
  55. package/dist/convex-hull/ConvexHullStats.svelte +425 -328
  56. package/dist/convex-hull/ConvexHullTooltip.svelte +40 -16
  57. package/dist/convex-hull/GasPressureControls.svelte +104 -61
  58. package/dist/convex-hull/StructurePopup.svelte +25 -4
  59. package/dist/convex-hull/TemperatureSlider.svelte +45 -25
  60. package/dist/convex-hull/barycentric-coords.js +13 -7
  61. package/dist/convex-hull/demo-temperature.js +8 -4
  62. package/dist/convex-hull/gas-thermodynamics.js +17 -12
  63. package/dist/convex-hull/helpers.d.ts +9 -0
  64. package/dist/convex-hull/helpers.js +77 -34
  65. package/dist/convex-hull/thermodynamics.js +61 -56
  66. package/dist/convex-hull/types.d.ts +9 -14
  67. package/dist/convex-hull/types.js +0 -17
  68. package/dist/coordination/CoordinationBarPlot.svelte +227 -154
  69. package/dist/element/BohrAtom.svelte +55 -12
  70. package/dist/element/ElementHeading.svelte +7 -2
  71. package/dist/element/ElementPhoto.svelte +15 -9
  72. package/dist/element/ElementStats.svelte +10 -4
  73. package/dist/element/ElementTile.svelte +137 -73
  74. package/dist/element/Nucleus.svelte +39 -11
  75. package/dist/feedback/ClickFeedback.svelte +16 -5
  76. package/dist/feedback/DragOverlay.svelte +10 -2
  77. package/dist/feedback/Spinner.svelte +4 -2
  78. package/dist/feedback/StatusMessage.svelte +8 -2
  79. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  80. package/dist/fermi-surface/FermiSurface.svelte +328 -187
  81. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  82. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  83. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  84. package/dist/fermi-surface/FermiSurfaceScene.svelte +535 -342
  85. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  86. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  87. package/dist/fermi-surface/compute.js +16 -20
  88. package/dist/fermi-surface/parse.js +24 -14
  89. package/dist/fermi-surface/symmetry.js +2 -7
  90. package/dist/fermi-surface/types.d.ts +3 -5
  91. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1019 -765
  92. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +1 -1
  93. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +76 -22
  94. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +2 -3
  95. package/dist/icons.js +47 -0
  96. package/dist/index.d.ts +2 -1
  97. package/dist/index.js +2 -1
  98. package/dist/io/decompress.js +1 -1
  99. package/dist/io/export.d.ts +3 -0
  100. package/dist/io/export.js +129 -143
  101. package/dist/io/is-binary.js +2 -3
  102. package/dist/io/url-drop.js +1 -2
  103. package/dist/isosurface/Isosurface.svelte +202 -148
  104. package/dist/isosurface/IsosurfaceControls.svelte +46 -28
  105. package/dist/isosurface/parse.js +34 -29
  106. package/dist/isosurface/slice.js +5 -10
  107. package/dist/isosurface/types.d.ts +2 -1
  108. package/dist/isosurface/types.js +61 -12
  109. package/dist/labels.js +11 -8
  110. package/dist/layout/FullscreenToggle.svelte +11 -2
  111. package/dist/layout/InfoCard.svelte +38 -6
  112. package/dist/layout/InfoTag.svelte +63 -32
  113. package/dist/layout/PropertyFilter.svelte +82 -37
  114. package/dist/layout/SettingsSection.svelte +85 -55
  115. package/dist/layout/SubpageGrid.svelte +10 -2
  116. package/dist/layout/json-tree/JsonNode.svelte +183 -138
  117. package/dist/layout/json-tree/JsonTree.svelte +499 -413
  118. package/dist/layout/json-tree/JsonValue.svelte +127 -99
  119. package/dist/layout/json-tree/utils.js +4 -2
  120. package/dist/marching-cubes.js +25 -2
  121. package/dist/math.d.ts +13 -17
  122. package/dist/math.js +133 -67
  123. package/dist/overlays/ContextMenu.svelte +65 -40
  124. package/dist/overlays/DraggablePane.svelte +211 -139
  125. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  126. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  127. package/dist/periodic-table/PropertySelect.svelte +25 -7
  128. package/dist/periodic-table/TableInset.svelte +8 -3
  129. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +446 -309
  130. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  131. package/dist/phase-diagram/PhaseDiagramControls.svelte +102 -43
  132. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  133. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +63 -40
  134. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +71 -28
  135. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +1 -1
  136. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +158 -101
  137. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  138. package/dist/phase-diagram/build-diagram.js +9 -9
  139. package/dist/phase-diagram/colors.js +1 -3
  140. package/dist/phase-diagram/parse.js +10 -9
  141. package/dist/phase-diagram/svg-to-diagram.js +53 -49
  142. package/dist/phase-diagram/utils.d.ts +1 -0
  143. package/dist/phase-diagram/utils.js +80 -25
  144. package/dist/plot/AxisLabel.svelte +28 -3
  145. package/dist/plot/BarPlot.svelte +1182 -734
  146. package/dist/plot/BarPlot.svelte.d.ts +2 -2
  147. package/dist/plot/BarPlotControls.svelte +31 -5
  148. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  149. package/dist/plot/ColorBar.svelte +479 -329
  150. package/dist/plot/ColorScaleSelect.svelte +27 -6
  151. package/dist/plot/ElementScatter.svelte +36 -15
  152. package/dist/plot/FillArea.svelte +152 -95
  153. package/dist/plot/Histogram.svelte +934 -571
  154. package/dist/plot/Histogram.svelte.d.ts +1 -1
  155. package/dist/plot/HistogramControls.svelte +53 -9
  156. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  157. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  158. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  159. package/dist/plot/Line.svelte +63 -28
  160. package/dist/plot/PlotControls.svelte +157 -114
  161. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  162. package/dist/plot/PlotLegend.svelte +174 -91
  163. package/dist/plot/PlotTooltip.svelte +45 -6
  164. package/dist/plot/PortalSelect.svelte +175 -147
  165. package/dist/plot/ReferenceLine.svelte +76 -22
  166. package/dist/plot/ReferenceLine3D.svelte +132 -107
  167. package/dist/plot/ReferencePlane.svelte +146 -121
  168. package/dist/plot/ScatterPlot.svelte +1681 -1091
  169. package/dist/plot/ScatterPlot.svelte.d.ts +2 -2
  170. package/dist/plot/ScatterPlot3D.svelte +256 -131
  171. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  172. package/dist/plot/ScatterPlot3DControls.svelte +113 -63
  173. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  174. package/dist/plot/ScatterPlot3DScene.svelte +608 -403
  175. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  176. package/dist/plot/ScatterPlotControls.svelte +65 -25
  177. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  178. package/dist/plot/ScatterPoint.svelte +98 -26
  179. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  180. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  181. package/dist/plot/Surface3D.svelte +159 -108
  182. package/dist/plot/ZeroLines.svelte +55 -3
  183. package/dist/plot/ZoomRect.svelte +4 -2
  184. package/dist/plot/axis-utils.js +1 -3
  185. package/dist/plot/data-cleaning.js +12 -28
  186. package/dist/plot/data-transform.js +2 -1
  187. package/dist/plot/fill-utils.js +2 -0
  188. package/dist/plot/layout.d.ts +4 -1
  189. package/dist/plot/layout.js +33 -14
  190. package/dist/plot/reference-line.d.ts +2 -2
  191. package/dist/plot/reference-line.js +7 -5
  192. package/dist/plot/scales.js +24 -36
  193. package/dist/plot/types.d.ts +11 -23
  194. package/dist/plot/types.js +6 -11
  195. package/dist/plot/utils/label-placement.d.ts +32 -15
  196. package/dist/plot/utils/label-placement.js +227 -66
  197. package/dist/plot/utils/series-visibility.js +2 -3
  198. package/dist/rdf/RdfPlot.svelte +143 -91
  199. package/dist/rdf/calc-rdf.js +4 -5
  200. package/dist/sanitize.d.ts +4 -0
  201. package/dist/sanitize.js +107 -0
  202. package/dist/settings.d.ts +18 -6
  203. package/dist/settings.js +46 -16
  204. package/dist/spectral/Bands.svelte +632 -453
  205. package/dist/spectral/BandsAndDos.svelte +90 -49
  206. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  207. package/dist/spectral/Dos.svelte +389 -258
  208. package/dist/spectral/helpers.js +55 -43
  209. package/dist/state.svelte.d.ts +1 -1
  210. package/dist/state.svelte.js +3 -2
  211. package/dist/structure/Arrow.svelte +59 -20
  212. package/dist/structure/AtomLegend.svelte +215 -134
  213. package/dist/structure/Bond.svelte +73 -47
  214. package/dist/structure/CanvasTooltip.svelte +10 -2
  215. package/dist/structure/CellSelect.svelte +72 -45
  216. package/dist/structure/Cylinder.svelte +33 -17
  217. package/dist/structure/Lattice.svelte +88 -33
  218. package/dist/structure/Structure.svelte +1063 -797
  219. package/dist/structure/Structure.svelte.d.ts +1 -1
  220. package/dist/structure/StructureControls.svelte +349 -118
  221. package/dist/structure/StructureExportPane.svelte +124 -89
  222. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  223. package/dist/structure/StructureInfoPane.svelte +304 -237
  224. package/dist/structure/StructureScene.svelte +879 -443
  225. package/dist/structure/StructureScene.svelte.d.ts +15 -7
  226. package/dist/structure/atom-properties.js +8 -8
  227. package/dist/structure/bonding.js +6 -7
  228. package/dist/structure/export.js +14 -29
  229. package/dist/structure/ferrox-wasm.js +1 -1
  230. package/dist/structure/index.d.ts +13 -3
  231. package/dist/structure/index.js +83 -23
  232. package/dist/structure/measure.d.ts +2 -2
  233. package/dist/structure/measure.js +4 -44
  234. package/dist/structure/parse.js +113 -141
  235. package/dist/structure/partial-occupancy.js +7 -10
  236. package/dist/structure/pbc.d.ts +1 -0
  237. package/dist/structure/pbc.js +16 -6
  238. package/dist/structure/supercell.d.ts +2 -2
  239. package/dist/structure/supercell.js +12 -22
  240. package/dist/structure/validation.js +1 -2
  241. package/dist/symmetry/SymmetryStats.svelte +84 -41
  242. package/dist/symmetry/WyckoffTable.svelte +26 -6
  243. package/dist/symmetry/cell-transform.js +5 -3
  244. package/dist/symmetry/index.js +8 -7
  245. package/dist/symmetry/spacegroups.js +148 -148
  246. package/dist/table/HeatmapTable.svelte +790 -554
  247. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  248. package/dist/table/ToggleMenu.svelte +125 -92
  249. package/dist/table/index.js +2 -4
  250. package/dist/theme/ThemeControl.svelte +21 -12
  251. package/dist/time.js +4 -1
  252. package/dist/tooltip/TooltipContent.svelte +33 -8
  253. package/dist/trajectory/Trajectory.svelte +758 -558
  254. package/dist/trajectory/TrajectoryError.svelte +14 -3
  255. package/dist/trajectory/TrajectoryExportPane.svelte +137 -83
  256. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  257. package/dist/trajectory/extract.js +10 -26
  258. package/dist/trajectory/format-detect.js +5 -5
  259. package/dist/trajectory/frame-reader.d.ts +1 -1
  260. package/dist/trajectory/frame-reader.js +5 -12
  261. package/dist/trajectory/helpers.d.ts +0 -1
  262. package/dist/trajectory/helpers.js +2 -17
  263. package/dist/trajectory/index.js +14 -12
  264. package/dist/trajectory/parse/ase.js +5 -4
  265. package/dist/trajectory/parse/hdf5.js +26 -18
  266. package/dist/trajectory/parse/index.js +13 -18
  267. package/dist/trajectory/parse/lammps.js +17 -7
  268. package/dist/trajectory/parse/vasp.js +5 -2
  269. package/dist/trajectory/parse/xyz.js +8 -7
  270. package/dist/trajectory/plotting.js +13 -8
  271. package/dist/utils.d.ts +1 -0
  272. package/dist/utils.js +13 -0
  273. package/dist/xrd/XrdPlot.svelte +337 -247
  274. package/dist/xrd/broadening.js +14 -9
  275. package/dist/xrd/calc-xrd.js +12 -18
  276. package/dist/xrd/parse.d.ts +1 -1
  277. package/dist/xrd/parse.js +17 -17
  278. package/package.json +99 -103
  279. package/readme.md +1 -1
  280. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,37 +1,227 @@
1
1
  <script
2
2
  lang="ts"
3
3
  generics="Metadata extends Record<string, unknown> = Record<string, unknown>"
4
- >import { format_value } from '../labels';
5
- import { FullscreenToggle, set_fullscreen_bg } from '../layout';
6
- import { AxisLabel, BarPlotControls, compute_element_placement, PlotLegend, ReferenceLine, ScatterPoint, } from './';
7
- import { create_axis_change_handler } from './axis-utils';
8
- import { process_prop } from './data-transform';
9
- import { create_dimension_tracker, create_hover_lock, } from './hover-lock.svelte';
10
- import { get_relative_coords, pan_range, PINCH_ZOOM_THRESHOLD, pixels_to_data_delta, } from './interactions';
11
- import { group_ref_lines_by_z, index_ref_lines } from './reference-line';
12
- import { create_color_scale, create_scale, create_size_scale, generate_ticks, get_nice_data_range, get_tick_label, } from './scales';
13
- import { DEFAULT_GRID_STYLE, DEFAULT_MARKERS, get_scale_type_name, } from './types';
14
- import { DEFAULTS } from '../settings';
15
- import { extent } from 'd3-array';
16
- import { untrack } from 'svelte';
17
- import { Tween } from 'svelte/motion';
18
- import { SvelteMap } from 'svelte/reactivity';
19
- import { calc_auto_padding, constrain_tooltip_position, filter_padding, LABEL_GAP_DEFAULT, measure_max_tick_width, } from './layout';
20
- import PlotTooltip from './PlotTooltip.svelte';
21
- import { bar_path } from './svg';
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,
25
- // Line marker props (matching ScatterPlot)
26
- color_scale = {
27
- type: `linear`,
28
- scheme: `interpolateViridis`,
29
- value_range: undefined,
30
- }, size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, point_tween, on_point_click, on_point_hover, ref_lines = $bindable([]), on_ref_line_click, on_ref_line_hover, show_controls = $bindable(true), controls_open = $bindable(false), controls_toggle_props, controls_pane_props, fullscreen = $bindable(false), fullscreen_toggle = true, children, header_controls, controls_extra, data_loader, on_axis_change, on_error, pan = {}, ...rest } = $props();
31
- // Initialize bar, line, y2_axis with defaults - using $derived for reactivity
32
- let bar_state = $derived({ ...DEFAULTS.bar.bar, ...bar });
33
- let line_state = $derived({ ...DEFAULTS.bar.line, ...line });
34
- y2_axis = {
4
+ >
5
+ import type { D3ColorSchemeName, D3InterpolateName } from '../colors'
6
+ import { format_value } from '../labels'
7
+ import { sanitize_html } from '../sanitize'
8
+ import { FullscreenToggle, set_fullscreen_bg } from '../layout'
9
+ import type {
10
+ AxisLoadError,
11
+ BarHandlerProps,
12
+ BarMode,
13
+ BarSeries,
14
+ BarStyle,
15
+ BasePlotProps,
16
+ DataLoaderFn,
17
+ InitialRanges,
18
+ InternalPoint,
19
+ LegendConfig,
20
+ LegendItem,
21
+ LineStyle,
22
+ Orientation,
23
+ PanConfig,
24
+ PlotConfig,
25
+ RefLine,
26
+ RefLineEvent,
27
+ ScaleType,
28
+ TweenedOptions,
29
+ UserContentProps,
30
+ XyObj,
31
+ } from './'
32
+ import {
33
+ AxisLabel,
34
+ BarPlotControls,
35
+ compute_element_placement,
36
+ PlotLegend,
37
+ ReferenceLine,
38
+ ScatterPoint,
39
+ } from './'
40
+ import type { AxisChangeState } from './axis-utils'
41
+ import { create_axis_change_handler } from './axis-utils'
42
+ import { process_prop } from './data-transform'
43
+ import {
44
+ create_dimension_tracker,
45
+ create_hover_lock,
46
+ } from './hover-lock.svelte'
47
+ import {
48
+ get_relative_coords,
49
+ pan_range,
50
+ PINCH_ZOOM_THRESHOLD,
51
+ pixels_to_data_delta,
52
+ } from './interactions'
53
+ import type { IndexedRefLine } from './reference-line'
54
+ import { group_ref_lines_by_z, index_ref_lines } from './reference-line'
55
+ import {
56
+ create_color_scale,
57
+ create_scale,
58
+ create_size_scale,
59
+ generate_ticks,
60
+ get_nice_data_range,
61
+ get_tick_label,
62
+ } from './scales'
63
+ import {
64
+ DEFAULT_GRID_STYLE,
65
+ DEFAULT_MARKERS,
66
+ get_scale_type_name,
67
+ } from './types'
68
+ import { DEFAULTS } from '../settings'
69
+ import { extent } from 'd3-array'
70
+ import type { Snippet } from 'svelte'
71
+ import { untrack } from 'svelte'
72
+ import type { HTMLAttributes } from 'svelte/elements'
73
+ import { Tween } from 'svelte/motion'
74
+ import { SvelteMap } from 'svelte/reactivity'
75
+ import type { Vec2 } from '../math'
76
+ import {
77
+ calc_auto_padding,
78
+ constrain_tooltip_position,
79
+ filter_padding,
80
+ LABEL_GAP_DEFAULT,
81
+ measure_max_tick_width,
82
+ } from './layout'
83
+ import PlotTooltip from './PlotTooltip.svelte'
84
+ import { bar_path } from './svg'
85
+ import ZeroLines from './ZeroLines.svelte'
86
+ import ZoomRect from './ZoomRect.svelte'
87
+
88
+ // Handler props for line marker events (extends BarHandlerProps with point-specific data)
89
+ interface LineMarkerHandlerProps extends BarHandlerProps<Metadata> {
90
+ point: InternalPoint<Metadata>
91
+ }
92
+
93
+ // Extended point type with computed screen coordinates (used internally for rendering)
94
+ type LineSeriesPoint = InternalPoint<Metadata> & {
95
+ x: number // Screen x coordinate
96
+ y: number // Screen y coordinate
97
+ data_x: number // Original data x value
98
+ data_y: number // Original data y value
99
+ idx: number // Index in series
100
+ }
101
+
102
+ let {
103
+ series = $bindable([]),
104
+ orientation = $bindable(`vertical`),
105
+ mode = $bindable(`overlay`),
106
+ x_axis = $bindable({}),
107
+ x2_axis = $bindable({}),
108
+ y_axis = $bindable({}),
109
+ y2_axis = $bindable({}),
110
+ display = $bindable(DEFAULTS.bar.display),
111
+ x_range = [null, null],
112
+ x2_range = [null, null],
113
+ y_range = [null, null],
114
+ y2_range = [null, null],
115
+ range_padding = 0.05,
116
+ padding = { t: 20, b: 60, l: 60, r: 20 },
117
+ legend = {},
118
+ show_legend,
119
+ bar = {},
120
+ line = {},
121
+ tooltip,
122
+ user_content,
123
+ hovered = $bindable(false),
124
+ change = () => {},
125
+ on_bar_click,
126
+ on_bar_hover,
127
+ // Line marker props (matching ScatterPlot)
128
+ color_scale = {
129
+ type: `linear`,
130
+ scheme: `interpolateViridis`,
131
+ value_range: undefined,
132
+ },
133
+ size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined },
134
+ point_tween,
135
+ on_point_click,
136
+ on_point_hover,
137
+ ref_lines = $bindable([]),
138
+ on_ref_line_click,
139
+ on_ref_line_hover,
140
+ show_controls = $bindable(true),
141
+ controls_open = $bindable(false),
142
+ controls_toggle_props,
143
+ controls_pane_props,
144
+ fullscreen = $bindable(false),
145
+ fullscreen_toggle = true,
146
+ children,
147
+ header_controls,
148
+ controls_extra,
149
+ data_loader,
150
+ on_axis_change,
151
+ on_error,
152
+ pan = {},
153
+ ...rest
154
+ }: HTMLAttributes<HTMLDivElement> & BasePlotProps & PlotConfig & {
155
+ series?: BarSeries<Metadata>[]
156
+ // Component-specific props
157
+ orientation?: Orientation
158
+ mode?: BarMode
159
+ legend?: LegendConfig | null
160
+ show_legend?: boolean
161
+ bar?: BarStyle
162
+ line?: LineStyle
163
+ tooltip?: Snippet<[BarHandlerProps<Metadata>]>
164
+ user_content?: Snippet<[UserContentProps]>
165
+ header_controls?: Snippet<
166
+ [{ height: number; width: number; fullscreen: boolean }]
167
+ >
168
+ controls_extra?: Snippet<
169
+ [{ orientation: Orientation; mode: BarMode } & Required<PlotConfig>]
170
+ >
171
+ change?: (data: BarHandlerProps<Metadata> | null) => void
172
+ on_bar_click?: (
173
+ data: BarHandlerProps<Metadata> & { event: MouseEvent | KeyboardEvent },
174
+ ) => void
175
+ on_bar_hover?: (
176
+ data:
177
+ | (BarHandlerProps<Metadata> & {
178
+ event: MouseEvent | FocusEvent | KeyboardEvent
179
+ })
180
+ | null,
181
+ ) => void
182
+ // Line marker props (matching ScatterPlot)
183
+ // Note: For line series with markers, BOTH on_bar_* AND on_point_* events fire.
184
+ // Use on_point_* for marker-specific data (includes `point` with InternalPoint details)
185
+ // or on_bar_* for backward compatibility with bar-style event handling.
186
+ color_scale?: {
187
+ type?: ScaleType
188
+ scheme?: D3ColorSchemeName | D3InterpolateName
189
+ value_range?: [number, number]
190
+ } | D3InterpolateName
191
+ size_scale?: {
192
+ type?: ScaleType
193
+ radius_range?: [number, number]
194
+ value_range?: [number, number]
195
+ }
196
+ point_tween?: TweenedOptions<XyObj>
197
+ on_point_click?: (
198
+ data: LineMarkerHandlerProps & { event: MouseEvent | KeyboardEvent },
199
+ ) => void
200
+ on_point_hover?: (
201
+ data:
202
+ | (LineMarkerHandlerProps & {
203
+ event: MouseEvent | FocusEvent | KeyboardEvent
204
+ })
205
+ | null,
206
+ ) => void
207
+ ref_lines?: RefLine[]
208
+ on_ref_line_click?: (event: RefLineEvent) => void
209
+ on_ref_line_hover?: (event: RefLineEvent | null) => void
210
+ // Interactive axis props
211
+ data_loader?: DataLoaderFn<Metadata, BarSeries<Metadata>>
212
+ on_axis_change?: (
213
+ axis: `x` | `x2` | `y` | `y2`,
214
+ key: string,
215
+ new_series: BarSeries<Metadata>[],
216
+ ) => void
217
+ on_error?: (error: AxisLoadError) => void
218
+ pan?: PanConfig
219
+ } = $props()
220
+
221
+ // Initialize bar, line, y2_axis with defaults - using $derived for reactivity
222
+ let bar_state = $derived({ ...DEFAULTS.bar.bar, ...bar })
223
+ let line_state = $derived({ ...DEFAULTS.bar.line, ...line })
224
+ y2_axis = {
35
225
  format: ``,
36
226
  scale_type: `linear`,
37
227
  ticks: 5,
@@ -39,8 +229,8 @@ y2_axis = {
39
229
  tick: { label: { shift: { x: 0, y: 0 } } }, // base offset handled in rendering
40
230
  range: [null, null],
41
231
  ...y2_axis,
42
- };
43
- x2_axis = {
232
+ }
233
+ x2_axis = {
44
234
  format: ``,
45
235
  scale_type: `linear`,
46
236
  ticks: 5,
@@ -48,815 +238,1073 @@ x2_axis = {
48
238
  tick: { label: { shift: { x: 0, y: 0 } } },
49
239
  range: [null, null],
50
240
  ...x2_axis,
51
- };
52
- let [width, height] = $state([0, 0]);
53
- let wrapper = $state();
54
- let svg_element = $state(null);
55
- let clip_path_id = `chart-clip-${crypto?.randomUUID?.()}`;
56
- // Reference line hover state
57
- let hovered_ref_line_idx = $state(null);
58
- // Interactive axis loading state
59
- let axis_loading = $state(null);
60
- // Compute ref_lines with index and group by z-index (using shared utilities)
61
- let indexed_ref_lines = $derived(index_ref_lines(ref_lines));
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(() => {
241
+ }
242
+
243
+ let [width, height] = $state([0, 0])
244
+ let wrapper: HTMLDivElement | undefined = $state()
245
+ let svg_element: SVGElement | null = $state(null)
246
+ let clip_path_id = `chart-clip-${crypto?.randomUUID?.()}`
247
+
248
+ // Reference line hover state
249
+ let hovered_ref_line_idx = $state<number | null>(null)
250
+
251
+ // Interactive axis loading state
252
+ let axis_loading = $state<`x` | `x2` | `y` | `y2` | null>(null)
253
+
254
+ // Compute ref_lines with index and group by z-index (using shared utilities)
255
+ let indexed_ref_lines = $derived(index_ref_lines(ref_lines))
256
+ let ref_lines_by_z = $derived(group_ref_lines_by_z(indexed_ref_lines))
257
+
258
+ // === Categorical Normalization ===
259
+ // Internal type with guaranteed numeric x (for downstream scale/rendering code)
260
+ type NumericBarSeries = Omit<BarSeries<Metadata>, `x`> & { x: readonly number[] }
261
+
262
+ let is_categorical = $derived(
263
+ series.some((srs) => srs.x.some((val) => typeof val === `string`)),
264
+ )
265
+
266
+ let category_list = $derived.by(() => {
267
+ if (!is_categorical) return [] as string[]
268
+ if (x_axis.categories?.length) return [...x_axis.categories]
269
+ return [...new Set(series.flatMap((srs) => srs.x.map(String)))]
270
+ })
271
+
272
+ let category_indices = $derived(
273
+ category_list.length ? category_list.map((_, idx) => idx) : null,
274
+ )
275
+
276
+ let internal_series = $derived.by<NumericBarSeries[]>(() => {
73
277
  // safe: when !category_indices, all x values are numeric (is_categorical is false)
74
- if (!category_indices)
75
- return series;
278
+ if (!category_indices) return series as unknown as NumericBarSeries[]
76
279
  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
- });
98
- // Compute auto ranges from visible series
99
- let visible_series = $derived(internal_series.filter((srs) => srs?.visible ?? true));
100
- // Separate series by y-axis
101
- let y1_series = $derived(visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`));
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`));
104
- let auto_ranges = $derived.by(() => {
280
+ const orig_map = new Map(srs.x.map((val, idx) => [String(val), idx]))
281
+ if (orig_map.size < srs.x.length) {
282
+ console.warn(
283
+ `BarPlot: series "${
284
+ srs.label ?? `?`
285
+ }" has duplicate x values — last occurrence wins`,
286
+ )
287
+ }
288
+ // Resolve original index for each category (undefined if series lacks it)
289
+ const orig_indices = category_list.map((cat) => orig_map.get(cat))
290
+ const remap = <T>(arr: readonly T[] | null | undefined, fallback: T): T[] =>
291
+ orig_indices.map((oi) => oi != null ? (arr?.[oi] ?? fallback) : fallback)
292
+ const bw_arr = Array.isArray(srs.bar_width) ? srs.bar_width : null
293
+ const meta_arr = Array.isArray(srs.metadata) ? srs.metadata : null
294
+ return {
295
+ ...srs,
296
+ x: category_indices,
297
+ y: remap(srs.y, srs.render_mode === `line` ? NaN : 0),
298
+ labels: remap(srs.labels, null),
299
+ metadata: orig_indices.map((oi) =>
300
+ oi != null ? (meta_arr ? meta_arr[oi] : srs.metadata) : undefined
301
+ ) as Metadata[],
302
+ ...(bw_arr ? { bar_width: remap(bw_arr, 0.5) } : {}),
303
+ ...(srs.color_values ? { color_values: remap(srs.color_values, null) } : {}),
304
+ ...(srs.size_values ? { size_values: remap(srs.size_values, null) } : {}),
305
+ } as NumericBarSeries
306
+ })
307
+ })
308
+
309
+ // Compute auto ranges from visible series
310
+ let visible_series = $derived(
311
+ internal_series.filter((srs) => srs?.visible ?? true),
312
+ )
313
+
314
+ // Separate series by y-axis
315
+ let y1_series = $derived(
316
+ visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`),
317
+ )
318
+ let y2_series = $derived(
319
+ visible_series.filter((srs) => srs.y_axis === `y2`),
320
+ )
321
+ let x2_series = $derived(
322
+ visible_series.filter((srs) => srs.x_axis === `x2`),
323
+ )
324
+
325
+ let auto_ranges = $derived.by(() => {
105
326
  // Calculate separate ranges for y1 and y2 axes
106
- const calc_y_range = (series_list, y_limit, scale_type) => {
107
- let points = series_list.flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] })));
108
- // In stacked mode, calculate stacked totals for accurate range (only for bars on the same axis)
109
- if (mode === `stacked`) {
110
- const stacked_totals = new SvelteMap();
111
- // Only include visible bar series (not lines) in stacking
112
- series_list
113
- .filter((srs) => srs.render_mode !== `line`)
114
- .forEach((srs) => srs.x.forEach((x_val, idx) => {
115
- const y_val = srs.y[idx] ?? 0;
116
- const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 };
117
- if (y_val >= 0)
118
- totals.pos += y_val;
119
- else
120
- totals.neg += y_val;
121
- stacked_totals.set(x_val, totals);
122
- }));
123
- // Replace points with stacked totals + line series (which don't stack)
124
- points = [
125
- ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
126
- ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
127
- ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
128
- ]),
129
- ...series_list
130
- .filter((srs) => srs.render_mode === `line`)
131
- .flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))),
132
- ];
133
- }
134
- if (!points.length)
135
- return [0, 1];
136
- let y_range = get_nice_data_range(points, (pt) => pt.y, y_limit, scale_type, range_padding, false);
137
- // For bar plots, ensure the value axis starts at 0 unless there are negative values
138
- // Only apply zero-clamping for linear and arcsinh scales (not log)
139
- const type_name = get_scale_type_name(scale_type);
140
- if (type_name === `linear` || type_name === `arcsinh`) {
141
- const has_negative = points.some((pt) => pt.y < 0);
142
- const has_positive = points.some((pt) => pt.y > 0);
143
- // Only adjust if no explicit y_range is set
144
- if (y_limit?.[0] == null && y_limit?.[1] == null) {
145
- if (has_positive && !has_negative)
146
- y_range = [0, y_range[1]];
147
- else if (has_negative && !has_positive)
148
- y_range = [y_range[0], 0];
149
- }
327
+ const calc_y_range = (
328
+ series_list: typeof visible_series,
329
+ y_limit: typeof y_range,
330
+ scale_type: ScaleType,
331
+ ) => {
332
+ let points = series_list.flatMap((srs) =>
333
+ srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))
334
+ )
335
+
336
+ // In stacked mode, calculate stacked totals for accurate range (only for bars on the same axis)
337
+ if (mode === `stacked`) {
338
+ const stacked_totals = new SvelteMap<number, { pos: number; neg: number }>()
339
+
340
+ // Only include visible bar series (not lines) in stacking
341
+ series_list
342
+ .filter((srs) => srs.render_mode !== `line`)
343
+ .forEach((srs) =>
344
+ srs.x.forEach((x_val, idx) => {
345
+ const y_val = srs.y[idx] ?? 0
346
+ const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 }
347
+ if (y_val >= 0) totals.pos += y_val
348
+ else totals.neg += y_val
349
+ stacked_totals.set(x_val, totals)
350
+ })
351
+ )
352
+
353
+ // Replace points with stacked totals + line series (which don't stack)
354
+ points = [
355
+ ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
356
+ ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
357
+ ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
358
+ ]),
359
+ ...series_list
360
+ .filter((srs) => srs.render_mode === `line`)
361
+ .flatMap((srs) =>
362
+ srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))
363
+ ),
364
+ ]
365
+ }
366
+
367
+ if (!points.length) return [0, 1]
368
+
369
+ let y_range = get_nice_data_range(
370
+ points,
371
+ (pt) => pt.y,
372
+ y_limit,
373
+ scale_type,
374
+ range_padding,
375
+ false,
376
+ )
377
+
378
+ // For bar plots, ensure the value axis starts at 0 unless there are negative values
379
+ // Only apply zero-clamping for linear and arcsinh scales (not log)
380
+ const type_name = get_scale_type_name(scale_type)
381
+ if (type_name === `linear` || type_name === `arcsinh`) {
382
+ const has_negative = points.some((pt) => pt.y < 0)
383
+ const has_positive = points.some((pt) => pt.y > 0)
384
+
385
+ // Only adjust if no explicit y_range is set
386
+ if (y_limit?.[0] == null && y_limit?.[1] == null) {
387
+ if (has_positive && !has_negative) y_range = [0, y_range[1]]
388
+ else if (has_negative && !has_positive) y_range = [y_range[0], 0]
150
389
  }
151
- return y_range;
152
- };
390
+ }
391
+
392
+ return y_range
393
+ }
394
+
153
395
  // Get x values split by axis for range calculation
154
396
  // For categorical data, use fixed range centered on integer indices
155
- let x_auto_range;
397
+ let x_auto_range: number[]
156
398
  if (category_list.length) {
157
- x_auto_range = [-0.5, category_list.length - 0.5];
399
+ x_auto_range = [-0.5, category_list.length - 0.5]
400
+ } else {
401
+ const x1_x_points = visible_series
402
+ .filter((srs) => (srs.x_axis ?? `x1`) === `x1`)
403
+ .flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 })))
404
+ x_auto_range = x1_x_points.length
405
+ ? get_nice_data_range(
406
+ x1_x_points,
407
+ (pt) => pt.x,
408
+ x_range,
409
+ x_axis.scale_type ?? `linear`,
410
+ range_padding,
411
+ x_axis.format?.startsWith(`%`) || false,
412
+ )
413
+ : [0, 1]
158
414
  }
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`;
415
+
416
+ const x2_x_points = x2_series.flatMap((srs) =>
417
+ srs.x.map((x_val) => ({ x: x_val, y: 0 }))
418
+ )
419
+ const x2_scale_type = x2_axis.scale_type ?? `linear`
169
420
  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)
171
- : [0, 1];
172
- const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`);
173
- const y2_auto_range = calc_y_range(y2_series, y2_range, y2_axis.scale_type ?? `linear`);
421
+ ? get_nice_data_range(
422
+ x2_x_points,
423
+ (pt) => pt.x,
424
+ x2_range,
425
+ x2_scale_type,
426
+ range_padding,
427
+ x2_axis.format?.startsWith(`%`) || false,
428
+ )
429
+ : [0, 1]
430
+
431
+ const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`)
432
+ const y2_auto_range = calc_y_range(
433
+ y2_series,
434
+ y2_range,
435
+ y2_axis.scale_type ?? `linear`,
436
+ )
437
+
174
438
  // Map data ranges to axis ranges depending on orientation
175
439
  return orientation === `horizontal`
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 });
178
- });
179
- // Initialize and current ranges
180
- let ranges = $state({
440
+ ? ({ x: y1_range, x2: x2_auto_range, y: x_auto_range, y2: y2_auto_range })
441
+ : ({ x: x_auto_range, x2: x2_auto_range, y: y1_range, y2: y2_auto_range })
442
+ })
443
+
444
+ // Initialize and current ranges
445
+ let ranges = $state<{
446
+ initial: { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 }
447
+ current: { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 }
448
+ }>({
181
449
  initial: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
182
450
  current: { x: [0, 1], x2: [0, 1], y: [0, 1], y2: [0, 1] },
183
- });
184
- $effect(() => {
451
+ })
452
+
453
+ $effect(() => { // handle x_axis.range / x2_axis.range / y_axis.range / y2_axis.range changes
185
454
  const new_x = [
186
- x_axis.range?.[0] ?? auto_ranges.x[0],
187
- x_axis.range?.[1] ?? auto_ranges.x[1],
188
- ];
455
+ x_axis.range?.[0] ?? auto_ranges.x[0],
456
+ x_axis.range?.[1] ?? auto_ranges.x[1],
457
+ ] as Vec2
189
458
  const new_x2 = [
190
- x2_axis.range?.[0] ?? auto_ranges.x2[0],
191
- x2_axis.range?.[1] ?? auto_ranges.x2[1],
192
- ];
459
+ x2_axis.range?.[0] ?? auto_ranges.x2[0],
460
+ x2_axis.range?.[1] ?? auto_ranges.x2[1],
461
+ ] as Vec2
193
462
  const new_y = [
194
- y_axis.range?.[0] ?? auto_ranges.y[0],
195
- y_axis.range?.[1] ?? auto_ranges.y[1],
196
- ];
463
+ y_axis.range?.[0] ?? auto_ranges.y[0],
464
+ y_axis.range?.[1] ?? auto_ranges.y[1],
465
+ ] as Vec2
197
466
  const new_y2 = [
198
- y2_axis.range?.[0] ?? auto_ranges.y2[0],
199
- y2_axis.range?.[1] ?? auto_ranges.y2[1],
200
- ];
467
+ y2_axis.range?.[0] ?? auto_ranges.y2[0],
468
+ y2_axis.range?.[1] ?? auto_ranges.y2[1],
469
+ ] as Vec2
201
470
  // Only update if the initial (data-driven) ranges changed, not when user pans
202
471
  // Comparing against initial preserves user's pan/zoom state
203
- if (ranges.initial.x[0] !== new_x[0] ||
204
- ranges.initial.x[1] !== new_x[1] ||
205
- ranges.initial.x2[0] !== new_x2[0] ||
206
- ranges.initial.x2[1] !== new_x2[1] ||
207
- ranges.initial.y[0] !== new_y[0] ||
208
- ranges.initial.y[1] !== new_y[1] ||
209
- ranges.initial.y2[0] !== new_y2[0] ||
210
- ranges.initial.y2[1] !== new_y2[1]) {
211
- ranges = {
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 },
214
- };
472
+ if (
473
+ ranges.initial.x[0] !== new_x[0] ||
474
+ ranges.initial.x[1] !== new_x[1] ||
475
+ ranges.initial.x2[0] !== new_x2[0] ||
476
+ ranges.initial.x2[1] !== new_x2[1] ||
477
+ ranges.initial.y[0] !== new_y[0] ||
478
+ ranges.initial.y[1] !== new_y[1] ||
479
+ ranges.initial.y2[0] !== new_y2[0] ||
480
+ ranges.initial.y2[1] !== new_y2[1]
481
+ ) {
482
+ ranges = {
483
+ initial: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
484
+ current: { x: new_x, x2: new_x2, y: new_y, y2: new_y2 },
485
+ }
215
486
  }
216
- });
217
- // Layout: dynamic padding based on tick label widths
218
- const default_padding = { t: 20, b: 60, l: 60, r: 20 };
219
- let pad = $derived(filter_padding(padding, default_padding));
220
- // Update padding when format or ticks change
221
- $effect(() => {
487
+ })
488
+
489
+ // Layout: dynamic padding based on tick label widths
490
+ const default_padding = { t: 20, b: 60, l: 60, r: 20 }
491
+ let pad = $derived(filter_padding(padding, default_padding))
492
+
493
+ // Update padding when format or ticks change
494
+ $effect(() => {
222
495
  const new_pad = width && height && ticks.y.length
223
- ? calc_auto_padding({
224
- padding,
225
- default_padding,
226
- x2_axis: { ...x2_axis, tick_values: ticks.x2 },
227
- y_axis: { ...y_axis, tick_values: ticks.y },
228
- y2_axis: { ...y2_axis, tick_values: ticks.y2 },
229
- })
230
- : filter_padding(padding, default_padding);
496
+ ? calc_auto_padding({
497
+ padding,
498
+ default_padding,
499
+ x2_axis: { ...x2_axis, tick_values: ticks.x2 },
500
+ y_axis: { ...y_axis, tick_values: ticks.y },
501
+ y2_axis: { ...y2_axis, tick_values: ticks.y2 },
502
+ })
503
+ : filter_padding(padding, default_padding)
231
504
  // Expand right padding if y2 ticks are shown (only for vertical orientation)
232
- if (width && height && y2_series.length && ticks.y2.length &&
233
- orientation === `vertical`) {
234
- // Need space for: tick shift + tick width + gap (30px) + label space (20px if present)
235
- // When ticks are inside, they don't contribute to padding
236
- const inside = y2_axis.tick?.label?.inside ?? false;
237
- const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8;
238
- const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max;
239
- const label_space = y2_axis.label ? 20 : 0;
240
- new_pad.r = Math.max(new_pad.r, tick_shift + tick_width_contribution + 30 + label_space);
505
+ if (
506
+ width && height && y2_series.length && ticks.y2.length &&
507
+ orientation === `vertical`
508
+ ) {
509
+ // Need space for: tick shift + tick width + gap (30px) + label space (20px if present)
510
+ // When ticks are inside, they don't contribute to padding
511
+ const inside = y2_axis.tick?.label?.inside ?? false
512
+ const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8
513
+ const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max
514
+ const label_space = y2_axis.label ? 20 : 0
515
+ new_pad.r = Math.max(
516
+ new_pad.r,
517
+ tick_shift + tick_width_contribution + 30 + label_space,
518
+ )
241
519
  }
242
520
  // 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);
521
+ if (
522
+ width && height && x2_series.length && ticks.x2.length &&
523
+ orientation === `vertical`
524
+ ) {
525
+ const inside = x2_axis.tick?.label?.inside ?? false
526
+ const tick_shift = inside ? 0 : Math.abs(x2_axis.tick?.label?.shift?.y ?? 0) + 5
527
+ const tick_height = inside ? 0 : 16
528
+ const label_space = x2_axis.label ? 20 : 0
529
+ new_pad.t = Math.max(new_pad.t, tick_shift + tick_height + 30 + label_space)
250
530
  }
531
+
251
532
  // Only update if padding actually changed (prevents infinite loop)
252
- if (pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
253
- pad.r !== new_pad.r)
254
- pad = new_pad;
255
- });
256
- const chart_width = $derived(Math.max(1, width - pad.l - pad.r));
257
- const chart_height = $derived(Math.max(1, height - pad.t - pad.b));
258
- // Scales
259
- let scales = $derived({
533
+ if (
534
+ pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
535
+ pad.r !== new_pad.r
536
+ ) pad = new_pad
537
+ })
538
+ const chart_width = $derived(Math.max(1, width - pad.l - pad.r))
539
+ const chart_height = $derived(Math.max(1, height - pad.t - pad.b))
540
+
541
+ // Scales
542
+ let scales = $derived({
260
543
  x: create_scale(x_axis.scale_type ?? `linear`, ranges.current.x, [
261
- pad.l,
262
- width - pad.r,
544
+ pad.l,
545
+ width - pad.r,
263
546
  ]),
264
547
  x2: create_scale(x2_axis.scale_type ?? `linear`, ranges.current.x2, [
265
- pad.l,
266
- width - pad.r,
548
+ pad.l,
549
+ width - pad.r,
267
550
  ]),
268
551
  y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [
269
- height - pad.b,
270
- pad.t,
552
+ height - pad.b,
553
+ pad.t,
271
554
  ]),
272
555
  y2: create_scale(y2_axis.scale_type ?? `linear`, ranges.current.y2, [
273
- height - pad.b,
274
- pad.t,
556
+ height - pad.b,
557
+ pad.t,
275
558
  ]),
276
- });
277
- // Compute plot center for point tweening origin
278
- let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2);
279
- let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2);
280
- // Compute color values from line series for color scaling (filter to numbers only)
281
- let all_color_values = $derived(visible_series
282
- .filter((srs) => srs.render_mode === `line`)
283
- .flatMap((srs) => (srs.color_values ?? []).filter((val) => typeof val === `number`)));
284
- // Create auto color range (safely handle empty arrays or undefined extent results)
285
- let auto_color_range = $derived.by(() => {
286
- if (all_color_values.length === 0)
287
- return [0, 1];
288
- const [min_val, max_val] = extent(all_color_values);
289
- return [min_val ?? 0, max_val ?? 1];
290
- });
291
- // All size values from line series (for size scale, filter to numbers only)
292
- let all_size_values = $derived(visible_series
293
- .filter((srs) => srs.render_mode === `line`)
294
- .flatMap((srs) => [...(srs.size_values ?? [])].filter((val) => typeof val === `number`)));
295
- // Color scale function (using shared utility)
296
- let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range));
297
- // Size scale function (using shared utility)
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;
559
+ })
560
+
561
+ // Compute plot center for point tweening origin
562
+ let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2)
563
+ let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2)
564
+
565
+ // Compute color values from line series for color scaling (filter to numbers only)
566
+ let all_color_values = $derived(
567
+ visible_series
568
+ .filter((srs: BarSeries<Metadata>) => srs.render_mode === `line`)
569
+ .flatMap((srs: BarSeries<Metadata>) =>
570
+ (srs.color_values ?? []).filter(
571
+ (val): val is number => typeof val === `number`,
572
+ )
573
+ ),
574
+ )
575
+
576
+ // Create auto color range (safely handle empty arrays or undefined extent results)
577
+ let auto_color_range: [number, number] = $derived.by(() => {
578
+ if (all_color_values.length === 0) return [0, 1]
579
+ const [min_val, max_val] = extent(all_color_values)
580
+ return [min_val ?? 0, max_val ?? 1]
581
+ })
582
+
583
+ // All size values from line series (for size scale, filter to numbers only)
584
+ let all_size_values = $derived(
585
+ visible_series
586
+ .filter((srs: BarSeries<Metadata>) => srs.render_mode === `line`)
587
+ .flatMap((srs: BarSeries<Metadata>) =>
588
+ [...(srs.size_values ?? [])].filter(
589
+ (val): val is number => typeof val === `number`,
590
+ )
591
+ ),
592
+ )
593
+
594
+ // Color scale function (using shared utility)
595
+ let color_scale_fn = $derived(create_color_scale(color_scale, auto_color_range))
596
+
597
+ // Size scale function (using shared utility)
598
+ let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values))
599
+
600
+ // Auto-generate tick labels for categorical data (unless user provides explicit ticks)
601
+ // In vertical mode categories are on x-axis; in horizontal mode on y-axis
602
+ let cat_axis = $derived(orientation === `horizontal` ? `y` : `x`)
603
+ let effective_cat_ticks = $derived.by(() => {
604
+ if (!category_list.length) return undefined
305
605
  // Only respect user ticks when they're a Record (custom label mapping),
306
606
  // 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
- });
313
- // Ticks
314
- let ticks = $derived({
607
+ const user_ticks = cat_axis === `x` ? x_axis.ticks : y_axis.ticks
608
+ if (
609
+ user_ticks != null && typeof user_ticks === `object` &&
610
+ !Array.isArray(user_ticks)
611
+ ) return user_ticks
612
+ return Object.fromEntries(
613
+ category_list.map((cat, idx) => [idx, cat]),
614
+ ) as Record<number, string>
615
+ })
616
+
617
+ // Ticks
618
+ let ticks = $derived({
315
619
  x: width && height
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 }))
317
- : [],
620
+ ? (category_indices && cat_axis === `x` ? category_indices : generate_ticks(
621
+ ranges.current.x,
622
+ x_axis.scale_type ?? `linear`,
623
+ x_axis.ticks,
624
+ scales.x,
625
+ { default_count: 8 },
626
+ ))
627
+ : [],
318
628
  y: width && height
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 }))
320
- : [],
629
+ ? (category_indices && cat_axis === `y` ? category_indices : generate_ticks(
630
+ ranges.current.y,
631
+ y_axis.scale_type ?? `linear`,
632
+ y_axis.ticks,
633
+ scales.y,
634
+ { default_count: 6 },
635
+ ))
636
+ : [],
321
637
  y2: width && height && y2_series.length > 0 && orientation === `vertical`
322
- ? generate_ticks(ranges.current.y2, y2_axis.scale_type ?? `linear`, y2_axis.ticks, scales.y2, {
323
- default_count: 6,
324
- })
325
- : [],
638
+ ? generate_ticks(
639
+ ranges.current.y2,
640
+ y2_axis.scale_type ?? `linear`,
641
+ y2_axis.ticks,
642
+ scales.y2,
643
+ {
644
+ default_count: 6,
645
+ },
646
+ )
647
+ : [],
326
648
  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({
649
+ ? generate_ticks(
650
+ ranges.current.x2,
651
+ x2_axis.scale_type ?? `linear`,
652
+ x2_axis.ticks,
653
+ scales.x2,
654
+ {
655
+ default_count: 8,
656
+ },
657
+ )
658
+ : [],
659
+ })
660
+
661
+ // Cache measured tick-label widths so expensive canvas text measurement
662
+ // only runs when ticks/format change, not on every template rerender.
663
+ let tick_label_widths = $derived({
335
664
  y_max: measure_max_tick_width(ticks.y, y_axis.format ?? ``),
336
665
  y2_max: measure_max_tick_width(ticks.y2, y2_axis.format ?? ``),
337
666
  x2_max: measure_max_tick_width(ticks.x2, x2_axis.format ?? ``),
338
- });
339
- // Zoom drag state
340
- let drag_state = $state({ start: null, current: null, bounds: null });
341
- // Pan state
342
- let is_focused = $state(false);
343
- let shift_held = $state(false);
344
- let pan_drag_state = $state(null);
345
- let touch_state = $state(null);
346
- const on_window_mouse_move = (evt) => {
347
- if (!drag_state.start || !drag_state.bounds)
348
- return;
667
+ })
668
+
669
+ // Zoom drag state
670
+ let drag_state = $state<{
671
+ start: { x: number; y: number } | null
672
+ current: { x: number; y: number } | null
673
+ bounds: DOMRect | null
674
+ }>({ start: null, current: null, bounds: null })
675
+
676
+ // Pan state
677
+ let is_focused = $state(false)
678
+ let shift_held = $state(false)
679
+ let pan_drag_state = $state<
680
+ InitialRanges & { start: { x: number; y: number } } | null
681
+ >(null)
682
+ let touch_state = $state<
683
+ InitialRanges & { start_touches: { x: number; y: number }[] } | null
684
+ >(null)
685
+ const on_window_mouse_move = (evt: MouseEvent) => {
686
+ if (!drag_state.start || !drag_state.bounds) return
349
687
  drag_state.current = {
350
- x: evt.clientX - drag_state.bounds.left,
351
- y: evt.clientY - drag_state.bounds.top,
352
- };
353
- };
354
- const on_window_mouse_up = () => {
688
+ x: evt.clientX - drag_state.bounds.left,
689
+ y: evt.clientY - drag_state.bounds.top,
690
+ }
691
+ }
692
+ const on_window_mouse_up = () => {
355
693
  if (drag_state.start && drag_state.current) {
356
- const x1_raw = scales.x.invert(drag_state.start.x);
357
- const x2_raw = scales.x.invert(drag_state.current.x);
358
- const y1 = scales.y.invert(drag_state.start.y);
359
- const y2 = scales.y.invert(drag_state.current.y);
360
- const y2_1 = scales.y2.invert(drag_state.start.y);
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);
364
- const dx = Math.abs(drag_state.start.x - drag_state.current.x);
365
- const dy = Math.abs(drag_state.start.y - drag_state.current.y);
366
- let xr1, xr2;
367
- if (x1_raw instanceof Date && x2_raw instanceof Date) {
368
- ;
369
- [xr1, xr2] = [x1_raw.getTime(), x2_raw.getTime()];
370
- }
371
- else if (typeof x1_raw === `number` && typeof x2_raw === `number`) {
372
- ;
373
- [xr1, xr2] = [x1_raw, x2_raw];
374
- }
375
- else
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];
388
- if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
389
- // Update axis ranges to trigger reactivity and prevent effect from overriding
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
- }
397
- y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] };
398
- y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] };
694
+ const x1_raw = scales.x.invert(drag_state.start.x) as number | Date
695
+ const x2_raw = scales.x.invert(drag_state.current.x) as number | Date
696
+ const y1 = scales.y.invert(drag_state.start.y)
697
+ const y2 = scales.y.invert(drag_state.current.y)
698
+ const y2_1 = scales.y2.invert(drag_state.start.y)
699
+ const y2_2 = scales.y2.invert(drag_state.current.y)
700
+ const x2a_1_raw = scales.x2.invert(drag_state.start.x) as number | Date
701
+ const x2a_2_raw = scales.x2.invert(drag_state.current.x) as number | Date
702
+ const dx = Math.abs(drag_state.start.x - drag_state.current.x)
703
+ const dy = Math.abs(drag_state.start.y - drag_state.current.y)
704
+
705
+ let xr1: number, xr2: number
706
+ if (x1_raw instanceof Date && x2_raw instanceof Date) {
707
+ ;[xr1, xr2] = [x1_raw.getTime(), x2_raw.getTime()]
708
+ } else if (typeof x1_raw === `number` && typeof x2_raw === `number`) {
709
+ ;[xr1, xr2] = [x1_raw, x2_raw]
710
+ } else [xr1, xr2] = [NaN, NaN] // bail: mixed types
711
+
712
+ let x2r1: number, x2r2: number
713
+ if (x2a_1_raw instanceof Date && x2a_2_raw instanceof Date) {
714
+ ;[x2r1, x2r2] = [x2a_1_raw.getTime(), x2a_2_raw.getTime()]
715
+ } else if (typeof x2a_1_raw === `number` && typeof x2a_2_raw === `number`) {
716
+ ;[x2r1, x2r2] = [x2a_1_raw, x2a_2_raw]
717
+ } else [x2r1, x2r2] = [NaN, NaN]
718
+
719
+ if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
720
+ // Update axis ranges to trigger reactivity and prevent effect from overriding
721
+ x_axis = { ...x_axis, range: [Math.min(xr1, xr2), Math.max(xr1, xr2)] }
722
+ if (x2_series.length > 0 && Number.isFinite(x2r1) && Number.isFinite(x2r2)) {
723
+ x2_axis = {
724
+ ...x2_axis,
725
+ range: [Math.min(x2r1, x2r2), Math.max(x2r1, x2r2)],
726
+ }
399
727
  }
728
+ y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] }
729
+ y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] }
730
+ }
400
731
  }
401
- drag_state = { start: null, current: null, bounds: null };
402
- window.removeEventListener(`mousemove`, on_window_mouse_move);
403
- window.removeEventListener(`mouseup`, on_window_mouse_up);
404
- document.body.style.cursor = `default`;
405
- };
406
- // Pan drag handlers
407
- const on_pan_move = (evt) => {
408
- if (!pan_drag_state)
409
- return;
410
- const dx = evt.clientX - pan_drag_state.start.x;
411
- const dy = evt.clientY - pan_drag_state.start.y;
732
+ drag_state = { start: null, current: null, bounds: null }
733
+ window.removeEventListener(`mousemove`, on_window_mouse_move)
734
+ window.removeEventListener(`mouseup`, on_window_mouse_up)
735
+ document.body.style.cursor = `default`
736
+ }
737
+
738
+ // Pan drag handlers
739
+ const on_pan_move = (evt: MouseEvent) => {
740
+ if (!pan_drag_state) return
741
+ const dx = evt.clientX - pan_drag_state.start.x
742
+ const dy = evt.clientY - pan_drag_state.start.y
743
+
412
744
  // Convert pixel delta to data delta (note: drag direction is inverted for natural pan feel)
413
- const sensitivity = pan?.drag_sensitivity ?? 1;
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);
416
- const y_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y_range, chart_height);
417
- const y2_delta = pixels_to_data_delta(dy * sensitivity, pan_drag_state.initial_y2_range, chart_height);
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);
420
- ranges.current.y = pan_range(pan_drag_state.initial_y_range, y_delta);
421
- ranges.current.y2 = pan_range(pan_drag_state.initial_y2_range, y2_delta);
422
- };
423
- const on_pan_end = () => {
424
- pan_drag_state = null;
425
- document.body.style.cursor = ``;
426
- window.removeEventListener(`mousemove`, on_pan_move);
427
- window.removeEventListener(`mouseup`, on_pan_end);
428
- };
429
- function handle_mouse_down(evt) {
430
- const coords = get_relative_coords(evt);
431
- if (!coords || !svg_element)
432
- return;
745
+ const sensitivity = pan?.drag_sensitivity ?? 1
746
+
747
+ const x_delta = pixels_to_data_delta(
748
+ -dx * sensitivity,
749
+ pan_drag_state.initial_x_range,
750
+ chart_width,
751
+ )
752
+ const x2_delta = pixels_to_data_delta(
753
+ -dx * sensitivity,
754
+ pan_drag_state.initial_x2_range,
755
+ chart_width,
756
+ )
757
+ const y_delta = pixels_to_data_delta(
758
+ dy * sensitivity,
759
+ pan_drag_state.initial_y_range,
760
+ chart_height,
761
+ )
762
+ const y2_delta = pixels_to_data_delta(
763
+ dy * sensitivity,
764
+ pan_drag_state.initial_y2_range,
765
+ chart_height,
766
+ )
767
+
768
+ ranges.current.x = pan_range(pan_drag_state.initial_x_range, x_delta)
769
+ ranges.current.x2 = pan_range(pan_drag_state.initial_x2_range, x2_delta)
770
+ ranges.current.y = pan_range(pan_drag_state.initial_y_range, y_delta)
771
+ ranges.current.y2 = pan_range(pan_drag_state.initial_y2_range, y2_delta)
772
+ }
773
+
774
+ const on_pan_end = () => {
775
+ pan_drag_state = null
776
+ document.body.style.cursor = ``
777
+ window.removeEventListener(`mousemove`, on_pan_move)
778
+ window.removeEventListener(`mouseup`, on_pan_end)
779
+ }
780
+
781
+ function handle_mouse_down(evt: MouseEvent) {
782
+ const coords = get_relative_coords(evt)
783
+ if (!coords || !svg_element) return
784
+
433
785
  // Check if pan is enabled and shift is held for pan mode
434
- const pan_enabled = pan?.enabled !== false;
786
+ const pan_enabled = pan?.enabled !== false
435
787
  if (pan_enabled && evt.shiftKey) {
436
- evt.preventDefault();
437
- pan_drag_state = {
438
- start: { x: evt.clientX, y: evt.clientY },
439
- initial_x_range: [...ranges.current.x],
440
- initial_x2_range: [...ranges.current.x2],
441
- initial_y_range: [...ranges.current.y],
442
- initial_y2_range: [...ranges.current.y2],
443
- };
444
- document.body.style.cursor = `grabbing`;
445
- window.addEventListener(`mousemove`, on_pan_move);
446
- window.addEventListener(`mouseup`, on_pan_end);
447
- return;
788
+ evt.preventDefault()
789
+ pan_drag_state = {
790
+ start: { x: evt.clientX, y: evt.clientY },
791
+ initial_x_range: [...ranges.current.x] as [number, number],
792
+ initial_x2_range: [...ranges.current.x2] as [number, number],
793
+ initial_y_range: [...ranges.current.y] as [number, number],
794
+ initial_y2_range: [...ranges.current.y2] as [number, number],
795
+ }
796
+ document.body.style.cursor = `grabbing`
797
+ window.addEventListener(`mousemove`, on_pan_move)
798
+ window.addEventListener(`mouseup`, on_pan_end)
799
+ return
448
800
  }
801
+
449
802
  drag_state = {
450
- start: coords,
451
- current: coords,
452
- bounds: svg_element.getBoundingClientRect(),
453
- };
454
- window.addEventListener(`mousemove`, on_window_mouse_move);
455
- window.addEventListener(`mouseup`, on_window_mouse_up);
456
- evt.preventDefault();
457
- }
458
- // Wheel handler for pan (requires focus and shift)
459
- function handle_wheel(evt) {
460
- const pan_enabled = pan?.enabled !== false;
803
+ start: coords,
804
+ current: coords,
805
+ bounds: svg_element.getBoundingClientRect(),
806
+ }
807
+ window.addEventListener(`mousemove`, on_window_mouse_move)
808
+ window.addEventListener(`mouseup`, on_window_mouse_up)
809
+ evt.preventDefault()
810
+ }
811
+
812
+ // Wheel handler for pan (requires focus and shift)
813
+ function handle_wheel(evt: WheelEvent) {
814
+ const pan_enabled = pan?.enabled !== false
461
815
  // Only capture wheel when focused AND Shift is held
462
816
  // Use shift_held state (tracked via keydown/keyup) for compatibility with synthetic events
463
- if (!pan_enabled || !is_focused || !shift_held)
464
- return;
465
- evt.preventDefault();
466
- const sensitivity = pan?.wheel_sensitivity ?? 1;
817
+ if (!pan_enabled || !is_focused || !shift_held) return
818
+
819
+ evt.preventDefault()
820
+
821
+ const sensitivity = pan?.wheel_sensitivity ?? 1
822
+
467
823
  // Determine pan direction based on wheel delta
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);
470
- const y_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y, chart_height);
471
- const y2_delta = pixels_to_data_delta(evt.deltaY * sensitivity, ranges.current.y2, chart_height);
824
+ const x_delta = pixels_to_data_delta(
825
+ evt.deltaX * sensitivity,
826
+ ranges.current.x,
827
+ chart_width,
828
+ )
829
+ const x2_delta = pixels_to_data_delta(
830
+ evt.deltaX * sensitivity,
831
+ ranges.current.x2,
832
+ chart_width,
833
+ )
834
+ const y_delta = pixels_to_data_delta(
835
+ evt.deltaY * sensitivity,
836
+ ranges.current.y,
837
+ chart_height,
838
+ )
839
+ const y2_delta = pixels_to_data_delta(
840
+ evt.deltaY * sensitivity,
841
+ ranges.current.y2,
842
+ chart_height,
843
+ )
844
+
472
845
  if (Math.abs(evt.deltaX) > Math.abs(evt.deltaY)) {
473
- ranges.current.x = pan_range(ranges.current.x, x_delta);
474
- ranges.current.x2 = pan_range(ranges.current.x2, x2_delta);
846
+ ranges.current.x = pan_range(ranges.current.x, x_delta)
847
+ ranges.current.x2 = pan_range(ranges.current.x2, x2_delta)
848
+ } else {
849
+ ranges.current.y = pan_range(ranges.current.y, y_delta)
850
+ ranges.current.y2 = pan_range(ranges.current.y2, y2_delta)
475
851
  }
476
- else {
477
- ranges.current.y = pan_range(ranges.current.y, y_delta);
478
- ranges.current.y2 = pan_range(ranges.current.y2, y2_delta);
479
- }
480
- }
481
- // Touch handlers for pinch-zoom and two-finger pan
482
- function handle_touch_start(evt) {
483
- const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false;
484
- if (!touch_enabled || evt.touches.length !== 2)
485
- return;
486
- evt.preventDefault();
487
- const touches = Array.from(evt.touches);
852
+ }
853
+
854
+ // Touch handlers for pinch-zoom and two-finger pan
855
+ function handle_touch_start(evt: TouchEvent) {
856
+ const touch_enabled = pan?.enabled !== false && pan?.touch_enabled !== false
857
+ if (!touch_enabled || evt.touches.length !== 2) return
858
+
859
+ evt.preventDefault()
860
+ const touches = Array.from(evt.touches)
488
861
  touch_state = {
489
- start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
490
- initial_x_range: [...ranges.current.x],
491
- initial_x2_range: [...ranges.current.x2],
492
- initial_y_range: [...ranges.current.y],
493
- initial_y2_range: [...ranges.current.y2],
494
- };
495
- }
496
- function handle_touch_move(evt) {
497
- if (!touch_state || evt.touches.length !== 2)
498
- return;
499
- evt.preventDefault();
500
- const [t1, t2] = Array.from(evt.touches);
501
- const [s1, s2] = touch_state.start_touches;
862
+ start_touches: touches.map((touch) => ({ x: touch.clientX, y: touch.clientY })),
863
+ initial_x_range: [...ranges.current.x] as [number, number],
864
+ initial_x2_range: [...ranges.current.x2] as [number, number],
865
+ initial_y_range: [...ranges.current.y] as [number, number],
866
+ initial_y2_range: [...ranges.current.y2] as [number, number],
867
+ }
868
+ }
869
+
870
+ function handle_touch_move(evt: TouchEvent) {
871
+ if (!touch_state || evt.touches.length !== 2) return
872
+ evt.preventDefault()
873
+
874
+ const [t1, t2] = Array.from(evt.touches)
875
+ const [s1, s2] = touch_state.start_touches
876
+
502
877
  // Calculate center movement for pan
503
- const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 };
878
+ const start_center = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 }
504
879
  const curr_center = {
505
- x: (t1.clientX + t2.clientX) / 2,
506
- y: (t1.clientY + t2.clientY) / 2,
507
- };
508
- const dx = curr_center.x - start_center.x;
509
- const dy = curr_center.y - start_center.y;
880
+ x: (t1.clientX + t2.clientX) / 2,
881
+ y: (t1.clientY + t2.clientY) / 2,
882
+ }
883
+ const dx = curr_center.x - start_center.x
884
+ const dy = curr_center.y - start_center.y
885
+
510
886
  // Calculate pinch scale (curr/start so spread = zoom out, pinch = zoom in)
511
- const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y);
887
+ const start_dist = Math.hypot(s2.x - s1.x, s2.y - s1.y)
512
888
  // Guard against zero-distance pinch to avoid Infinity scale
513
- if (start_dist < Number.EPSILON)
514
- return;
515
- const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
516
- const scale = curr_dist / start_dist;
889
+ if (start_dist < Number.EPSILON) return
890
+ const curr_dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
891
+ const scale = curr_dist / start_dist
892
+
517
893
  // If scale changed significantly, treat as pinch-zoom
518
894
  // Also guard against scale being too small to avoid division by zero
519
895
  if (Math.abs(scale - 1) > PINCH_ZOOM_THRESHOLD && scale > Number.EPSILON) {
520
- // Pinch zoom centered on gesture center
521
- // Divide by scale so spread (scale > 1) = smaller span (zoom in)
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];
525
- const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0];
526
- const y2_span = touch_state.initial_y2_range[1] -
527
- touch_state.initial_y2_range[0];
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;
530
- const y_center = (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2;
531
- const y2_center = (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2;
532
- ranges.current.x = [
533
- x_center - x_span / scale / 2,
534
- x_center + x_span / scale / 2,
535
- ];
536
- ranges.current.x2 = [
537
- x2_center - x2_span / scale / 2,
538
- x2_center + x2_span / scale / 2,
539
- ];
540
- ranges.current.y = [
541
- y_center - y_span / scale / 2,
542
- y_center + y_span / scale / 2,
543
- ];
544
- ranges.current.y2 = [
545
- y2_center - y2_span / scale / 2,
546
- y2_center + y2_span / scale / 2,
547
- ];
548
- }
549
- else {
550
- // Pan
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);
553
- const y_delta = pixels_to_data_delta(dy, touch_state.initial_y_range, chart_height);
554
- const y2_delta = pixels_to_data_delta(dy, touch_state.initial_y2_range, chart_height);
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);
557
- ranges.current.y = pan_range(touch_state.initial_y_range, y_delta);
558
- ranges.current.y2 = pan_range(touch_state.initial_y2_range, y2_delta);
896
+ // Pinch zoom centered on gesture center
897
+ // Divide by scale so spread (scale > 1) = smaller span (zoom in)
898
+ const x_span = touch_state.initial_x_range[1] - touch_state.initial_x_range[0]
899
+ const x2_span = touch_state.initial_x2_range[1] -
900
+ touch_state.initial_x2_range[0]
901
+ const y_span = touch_state.initial_y_range[1] - touch_state.initial_y_range[0]
902
+ const y2_span = touch_state.initial_y2_range[1] -
903
+ touch_state.initial_y2_range[0]
904
+ const x_center =
905
+ (touch_state.initial_x_range[0] + touch_state.initial_x_range[1]) / 2
906
+ const x2_center =
907
+ (touch_state.initial_x2_range[0] + touch_state.initial_x2_range[1]) / 2
908
+ const y_center =
909
+ (touch_state.initial_y_range[0] + touch_state.initial_y_range[1]) / 2
910
+ const y2_center =
911
+ (touch_state.initial_y2_range[0] + touch_state.initial_y2_range[1]) / 2
912
+
913
+ ranges.current.x = [
914
+ x_center - x_span / scale / 2,
915
+ x_center + x_span / scale / 2,
916
+ ]
917
+ ranges.current.x2 = [
918
+ x2_center - x2_span / scale / 2,
919
+ x2_center + x2_span / scale / 2,
920
+ ]
921
+ ranges.current.y = [
922
+ y_center - y_span / scale / 2,
923
+ y_center + y_span / scale / 2,
924
+ ]
925
+ ranges.current.y2 = [
926
+ y2_center - y2_span / scale / 2,
927
+ y2_center + y2_span / scale / 2,
928
+ ]
929
+ } else {
930
+ // Pan
931
+ const x_delta = pixels_to_data_delta(
932
+ -dx,
933
+ touch_state.initial_x_range,
934
+ chart_width,
935
+ )
936
+ const x2_delta = pixels_to_data_delta(
937
+ -dx,
938
+ touch_state.initial_x2_range,
939
+ chart_width,
940
+ )
941
+ const y_delta = pixels_to_data_delta(
942
+ dy,
943
+ touch_state.initial_y_range,
944
+ chart_height,
945
+ )
946
+ const y2_delta = pixels_to_data_delta(
947
+ dy,
948
+ touch_state.initial_y2_range,
949
+ chart_height,
950
+ )
951
+ ranges.current.x = pan_range(touch_state.initial_x_range, x_delta)
952
+ ranges.current.x2 = pan_range(touch_state.initial_x2_range, x2_delta)
953
+ ranges.current.y = pan_range(touch_state.initial_y_range, y_delta)
954
+ ranges.current.y2 = pan_range(touch_state.initial_y2_range, y2_delta)
559
955
  }
560
- }
561
- function handle_touch_end() {
562
- touch_state = null;
563
- }
564
- // Legend data and handlers
565
- let legend_data = $derived.by(() => series.map((srs, idx) => {
566
- const is_line = srs.render_mode === `line`;
567
- const series_markers = srs.markers ?? DEFAULT_MARKERS;
568
- const has_line = series_markers === `line` || series_markers === `line+points`;
569
- const has_points = series_markers === `points` ||
570
- series_markers === `line+points`;
571
- const series_color = srs.color ?? (is_line ? line_state.color : bar_state.color);
572
- // Get point style for symbol color (handle array or single object)
573
- const first_point_style = Array.isArray(srs.point_style)
956
+ }
957
+
958
+ function handle_touch_end() {
959
+ touch_state = null
960
+ }
961
+
962
+ // Legend data and handlers
963
+ let legend_data = $derived.by<LegendItem[]>(() =>
964
+ series.map((srs: BarSeries<Metadata>, idx: number) => {
965
+ const is_line = srs.render_mode === `line`
966
+ const series_markers = srs.markers ?? DEFAULT_MARKERS
967
+ const has_line = series_markers === `line` || series_markers === `line+points`
968
+ const has_points = series_markers === `points` ||
969
+ series_markers === `line+points`
970
+ const series_color = srs.color ?? (is_line ? line_state.color : bar_state.color)
971
+
972
+ // Get point style for symbol color (handle array or single object)
973
+ const first_point_style = Array.isArray(srs.point_style)
574
974
  ? srs.point_style[0]
575
- : srs.point_style;
576
- const first_color_value = srs.color_values?.[0];
577
- const point_color = first_color_value != null
975
+ : srs.point_style
976
+ const first_color_value = srs.color_values?.[0]
977
+ const point_color = first_color_value != null
578
978
  ? color_scale_fn(first_color_value)
579
- : first_point_style?.fill ?? series_color;
580
- if (is_line) {
979
+ : first_point_style?.fill ?? series_color
980
+
981
+ if (is_line) {
581
982
  // Line series: show line and/or symbol based on markers
582
983
  return {
583
- series_idx: idx,
584
- label: srs.label ?? `Series ${idx + 1}`,
585
- visible: srs.visible ?? true,
586
- legend_group: srs.legend_group,
587
- display_style: {
588
- ...(has_line
589
- ? {
590
- line_color: series_color,
591
- line_dash: srs.line_style?.line_dash,
592
- }
593
- : {}),
594
- ...(has_points
595
- ? {
596
- symbol_type: first_point_style?.symbol_type ??
597
- DEFAULTS.scatter.symbol_type,
598
- symbol_color: point_color,
599
- }
600
- : {}),
601
- },
602
- };
603
- }
604
- // Bar series: show square symbol
605
- return {
984
+ series_idx: idx,
985
+ label: srs.label ?? `Series ${idx + 1}`,
986
+ visible: srs.visible ?? true,
987
+ legend_group: srs.legend_group,
988
+ display_style: {
989
+ ...(has_line
990
+ ? {
991
+ line_color: series_color,
992
+ line_dash: srs.line_style?.line_dash,
993
+ }
994
+ : {}),
995
+ ...(has_points
996
+ ? {
997
+ symbol_type: first_point_style?.symbol_type ??
998
+ DEFAULTS.scatter.symbol_type,
999
+ symbol_color: point_color,
1000
+ }
1001
+ : {}),
1002
+ },
1003
+ }
1004
+ }
1005
+ // Bar series: show square symbol
1006
+ return {
606
1007
  series_idx: idx,
607
1008
  label: srs.label ?? `Series ${idx + 1}`,
608
1009
  visible: srs.visible ?? true,
609
1010
  legend_group: srs.legend_group,
610
1011
  display_style: {
611
- symbol_type: `Square`,
612
- symbol_color: series_color,
1012
+ symbol_type: `Square` as const,
1013
+ symbol_color: series_color,
613
1014
  },
614
- };
615
- }));
616
- function toggle_series_visibility(series_idx) {
1015
+ }
1016
+ })
1017
+ )
1018
+
1019
+ function toggle_series_visibility(series_idx: number) {
617
1020
  if (series_idx >= 0 && series_idx < series.length) {
618
- series = series.map((srs, idx) => idx === series_idx ? { ...srs, visible: !(srs.visible ?? true) } : srs);
1021
+ series = series.map((srs, idx) =>
1022
+ idx === series_idx ? { ...srs, visible: !(srs.visible ?? true) } : srs
1023
+ )
619
1024
  }
620
- }
621
- function toggle_group_visibility(_group_name, series_indices) {
1025
+ }
1026
+
1027
+ function toggle_group_visibility(_group_name: string, series_indices: number[]) {
622
1028
  // Filter to valid indices upfront (consistent with shared toggle_group_visibility)
623
- const valid_indices = series_indices.filter((idx) => idx >= 0 && idx < series.length);
624
- if (valid_indices.length === 0)
625
- return;
626
- const idx_set = new Set(valid_indices);
1029
+ const valid_indices = series_indices.filter((idx) =>
1030
+ idx >= 0 && idx < series.length
1031
+ )
1032
+ if (valid_indices.length === 0) return
1033
+
1034
+ const idx_set = new Set(valid_indices)
627
1035
  // Check if all series in the group are currently visible
628
- const all_visible = valid_indices.every((idx) => series[idx].visible ?? true);
1036
+ const all_visible = valid_indices.every((idx) => series[idx].visible ?? true)
629
1037
  // Toggle: if all visible, hide all; otherwise show all
630
- const new_visibility = !all_visible;
631
- series = series.map((srs, idx) => idx_set.has(idx) ? { ...srs, visible: new_visibility } : srs);
632
- }
633
- // Collect bar and line positions for legend placement
634
- let bar_points_for_placement = $derived.by(() => {
635
- if (!width || !height || !visible_series.length)
636
- return [];
1038
+ const new_visibility = !all_visible
1039
+ series = series.map((srs, idx) =>
1040
+ idx_set.has(idx) ? { ...srs, visible: new_visibility } : srs
1041
+ )
1042
+ }
1043
+
1044
+ // Collect bar and line positions for legend placement
1045
+ let bar_points_for_placement = $derived.by(() => {
1046
+ if (!width || !height || !visible_series.length) return []
1047
+
637
1048
  return internal_series.flatMap((srs, series_idx) => {
638
- if (!(srs?.visible ?? true))
639
- return [];
640
- const is_line = srs.render_mode === `line`;
641
- const series_offsets = stacked_offsets[series_idx] ?? [];
642
- const use_y2 = srs.y_axis === `y2`;
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;
646
- return srs.x
647
- .map((x_val, bar_idx) => {
648
- const y_val = srs.y[bar_idx];
649
- const base = !is_line && mode === `stacked`
650
- ? (series_offsets[bar_idx] ?? 0)
651
- : 0;
652
- const [bar_x, bar_y] = orientation === `vertical`
653
- ? [x_scale_pl(x_val), y_scale(base + y_val)]
654
- : [x_scale_pl(base + y_val), scales.y(x_val)];
655
- return { x: bar_x, y: bar_y };
1049
+ if (!(srs?.visible ?? true)) return []
1050
+ const is_line = srs.render_mode === `line`
1051
+ const series_offsets = stacked_offsets[series_idx] ?? []
1052
+ const use_y2 = srs.y_axis === `y2`
1053
+ const y_scale = use_y2 ? scales.y2 : scales.y
1054
+ const use_x2_pl = srs.x_axis === `x2`
1055
+ const x_scale_pl = use_x2_pl ? scales.x2 : scales.x
1056
+ return srs.x
1057
+ .map((x_val, bar_idx) => {
1058
+ const y_val = srs.y[bar_idx]
1059
+ const base = !is_line && mode === `stacked`
1060
+ ? (series_offsets[bar_idx] ?? 0)
1061
+ : 0
1062
+ const [bar_x, bar_y] = orientation === `vertical`
1063
+ ? [x_scale_pl(x_val), y_scale(base + y_val)]
1064
+ : [x_scale_pl(base + y_val), scales.y(x_val)]
1065
+ return { x: bar_x, y: bar_y }
656
1066
  })
657
- .filter(({ x, y }) => isFinite(x) && isFinite(y));
658
- });
659
- });
660
- // Legend placement stability state
661
- let legend_element = $state();
662
- const legend_hover = create_hover_lock();
663
- const dim_tracker = create_dimension_tracker();
664
- let has_initial_legend_placement = $state(false);
665
- // Clear pending hover lock timeout on unmount
666
- $effect(() => () => legend_hover.cleanup());
667
- // Calculate best legend placement using continuous grid sampling
668
- let legend_placement = $derived.by(() => {
669
- const should_show = show_legend !== undefined ? show_legend : series.length > 1;
670
- if (!should_show || !width || !height)
671
- return null;
1067
+ .filter(({ x, y }) => isFinite(x) && isFinite(y))
1068
+ })
1069
+ })
1070
+
1071
+ // Legend placement stability state
1072
+ let legend_element = $state<HTMLDivElement | undefined>()
1073
+ const legend_hover = create_hover_lock()
1074
+ const dim_tracker = create_dimension_tracker()
1075
+ let has_initial_legend_placement = $state(false)
1076
+
1077
+ // Clear pending hover lock timeout on unmount
1078
+ $effect(() => () => legend_hover.cleanup())
1079
+
1080
+ // Calculate best legend placement using continuous grid sampling
1081
+ let legend_placement = $derived.by(() => {
1082
+ const should_show = show_legend !== undefined ? show_legend : series.length > 1
1083
+ if (!should_show || !width || !height) return null
1084
+
672
1085
  // Use measured size if available, otherwise estimate
673
1086
  const legend_size = legend_element
674
- ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
675
- : { width: 120, height: 60 };
1087
+ ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
1088
+ : { width: 120, height: 60 }
1089
+
676
1090
  const result = compute_element_placement({
677
- plot_bounds: { x: pad.l, y: pad.t, width: chart_width, height: chart_height },
678
- element_size: legend_size,
679
- axis_clearance: legend?.axis_clearance,
680
- exclude_rects: [],
681
- points: bar_points_for_placement,
682
- });
683
- return result;
684
- });
685
- // Tweened legend coordinates for smooth animation - create once, update target via effect
686
- // untrack() explicitly captures initial tween config (intentional - config set once at mount)
687
- const tweened_legend_coords = new Tween({ x: 0, y: 0 }, untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })));
688
- // Update legend position with stability checks
689
- $effect(() => {
690
- if (!width || !height || !legend_placement)
691
- return;
1091
+ plot_bounds: { x: pad.l, y: pad.t, width: chart_width, height: chart_height },
1092
+ element_size: legend_size,
1093
+ axis_clearance: legend?.axis_clearance,
1094
+ exclude_rects: [],
1095
+ points: bar_points_for_placement,
1096
+ })
1097
+
1098
+ return result
1099
+ })
1100
+
1101
+ // Tweened legend coordinates for smooth animation - create once, update target via effect
1102
+ // untrack() explicitly captures initial tween config (intentional - config set once at mount)
1103
+ const tweened_legend_coords = new Tween(
1104
+ { x: 0, y: 0 },
1105
+ untrack(() => ({ duration: 400, ...(legend?.tween ?? {}) })),
1106
+ )
1107
+
1108
+ // Update legend position with stability checks
1109
+ $effect(() => {
1110
+ if (!width || !height || !legend_placement) return
1111
+
692
1112
  // Track dimensions for resize detection
693
- const dims_changed = dim_tracker.has_changed(width, height);
694
- if (dims_changed)
695
- dim_tracker.update(width, height);
696
- const is_responsive = legend?.responsive ?? false;
1113
+ const dims_changed = dim_tracker.has_changed(width, height)
1114
+ if (dims_changed) dim_tracker.update(width, height)
1115
+
1116
+ const is_responsive = legend?.responsive ?? false
697
1117
  // Only update if: resize occurred, OR (not hover-locked AND (responsive OR not yet initially placed))
698
1118
  const should_update = dims_changed || (!legend_hover.is_locked.current &&
699
- (is_responsive || !has_initial_legend_placement));
1119
+ (is_responsive || !has_initial_legend_placement))
1120
+
700
1121
  if (should_update) {
701
- tweened_legend_coords.set({ x: legend_placement.x, y: legend_placement.y },
1122
+ tweened_legend_coords.set(
1123
+ { x: legend_placement.x, y: legend_placement.y },
702
1124
  // Skip animation on initial placement to avoid jump from (0, 0)
703
- has_initial_legend_placement ? undefined : { duration: 0 });
704
- // Only lock position after we have actual measured size
705
- if (legend_element) {
706
- has_initial_legend_placement = true;
707
- }
1125
+ has_initial_legend_placement ? undefined : { duration: 0 },
1126
+ )
1127
+ // Only lock position after we have actual measured size
1128
+ if (legend_element) {
1129
+ has_initial_legend_placement = true
1130
+ }
708
1131
  }
709
- });
710
- // Tooltip state
711
- let hover_info = $state(null);
712
- let tooltip_el = $state();
713
- function get_bar_data(series_idx, bar_idx, color) {
714
- const srs = internal_series[series_idx];
715
- const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]];
716
- const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y];
1132
+ })
1133
+
1134
+ // Tooltip state
1135
+ let hover_info = $state<BarHandlerProps<Metadata> | null>(null)
1136
+ let tooltip_el = $state<HTMLDivElement | undefined>()
1137
+
1138
+ function get_bar_data(
1139
+ series_idx: number,
1140
+ bar_idx: number,
1141
+ color: string,
1142
+ ): BarHandlerProps<Metadata> {
1143
+ const srs = internal_series[series_idx]
1144
+ const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]]
1145
+ const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y]
717
1146
  const metadata = Array.isArray(srs.metadata)
718
- ? srs.metadata[bar_idx]
719
- : srs.metadata;
720
- const label = srs.labels?.[bar_idx] ?? null;
721
- const active_y_axis = srs.y_axis ?? `y1`;
722
- const active_x_axis = srs.x_axis ?? `x1`;
723
- const category_label = category_list[x];
1147
+ ? srs.metadata[bar_idx]
1148
+ : srs.metadata
1149
+ const label = srs.labels?.[bar_idx] ?? null
1150
+ const active_y_axis = srs.y_axis ?? `y1`
1151
+ const active_x_axis = srs.x_axis ?? `x1`
1152
+ const category_label = category_list[x]
724
1153
  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
- };
1154
+ x,
1155
+ y,
1156
+ orient_x,
1157
+ orient_y,
1158
+ x_axis: active_x_axis === `x2` ? x2_axis : x_axis,
1159
+ x2_axis,
1160
+ y_axis: active_y_axis === `y2` ? y2_axis : y_axis,
1161
+ y2_axis,
1162
+ }
734
1163
  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;
1164
+ ...coords,
1165
+ metadata,
1166
+ color,
1167
+ label,
1168
+ series_idx,
1169
+ bar_idx,
1170
+ active_y_axis,
1171
+ active_x_axis,
1172
+ category_label,
1173
+ }
1174
+ }
1175
+
1176
+ // Find the point closest to the cursor on a polyline overlay (O(n) scan).
1177
+ function find_closest_point(
1178
+ evt: MouseEvent,
1179
+ points: LineSeriesPoint[],
1180
+ ): LineSeriesPoint | null {
1181
+ const svg_el = (evt.target as Element).closest(`svg`)
1182
+ if (!svg_el) return null
1183
+ const rect = svg_el.getBoundingClientRect()
1184
+ const mx = evt.clientX - rect.left
1185
+ const my = evt.clientY - rect.top
1186
+ let best: LineSeriesPoint | null = null
1187
+ let best_dist = Infinity
756
1188
  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
- }
1189
+ const dist = (pt.x - mx) ** 2 + (pt.y - my) ** 2
1190
+ if (dist < best_dist) {
1191
+ best_dist = dist
1192
+ best = pt
1193
+ }
1194
+ }
1195
+ return best
1196
+ }
1197
+
1198
+ const line_point_fill = (pt: LineSeriesPoint, series_color: string): string =>
1199
+ pt.color_value != null
1200
+ ? color_scale_fn(pt.color_value)
1201
+ : pt.point_style?.fill ?? series_color
1202
+
1203
+ const handle_bar_hover =
1204
+ (series_idx: number, bar_idx: number, color: string) => (event: MouseEvent) => {
1205
+ hovered = true
1206
+ hover_info = get_bar_data(series_idx, bar_idx, color)
1207
+ change(hover_info)
1208
+ on_bar_hover?.({ ...hover_info, event })
762
1209
  }
763
- return best;
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;
768
- const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
769
- hovered = true;
770
- hover_info = get_bar_data(series_idx, bar_idx, color);
771
- change(hover_info);
772
- on_bar_hover?.({ ...hover_info, event });
773
- };
774
- // Stack offsets (only for bar series in stacked mode, grouped by y-axis)
775
- let stacked_offsets = $derived.by(() => {
776
- if (mode !== `stacked`)
777
- return [];
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));
1210
+
1211
+ // Stack offsets (only for bar series in stacked mode, grouped by y-axis)
1212
+ let stacked_offsets = $derived.by(() => {
1213
+ if (mode !== `stacked`) return [] as number[][]
1214
+ const max_len = Math.max(
1215
+ 0,
1216
+ ...internal_series.map((srs) => srs.y.length),
1217
+ )
1218
+ const offsets = internal_series.map(() =>
1219
+ Array.from({ length: max_len }, () => 0)
1220
+ )
1221
+
780
1222
  // Separate accumulators for y1 and y2 axes
781
- const y1_pos_acc = Array.from({ length: max_len }, () => 0);
782
- const y1_neg_acc = Array.from({ length: max_len }, () => 0);
783
- const y2_pos_acc = Array.from({ length: max_len }, () => 0);
784
- const y2_neg_acc = Array.from({ length: max_len }, () => 0);
1223
+ const y1_pos_acc = Array.from({ length: max_len }, () => 0)
1224
+ const y1_neg_acc = Array.from({ length: max_len }, () => 0)
1225
+ const y2_pos_acc = Array.from({ length: max_len }, () => 0)
1226
+ const y2_neg_acc = Array.from({ length: max_len }, () => 0)
1227
+
785
1228
  internal_series.forEach((srs, series_idx) => {
786
- if (!(srs?.visible ?? true) || srs.render_mode === `line`)
787
- return;
788
- const use_y2 = srs.y_axis === `y2`;
789
- const pos_acc = use_y2 ? y2_pos_acc : y1_pos_acc;
790
- const neg_acc = use_y2 ? y2_neg_acc : y1_neg_acc;
791
- for (let bar_idx = 0; bar_idx < max_len; bar_idx++) {
792
- const y_val = srs.y[bar_idx] ?? 0;
793
- const acc = y_val >= 0 ? pos_acc : neg_acc;
794
- offsets[series_idx][bar_idx] = acc[bar_idx];
795
- acc[bar_idx] += y_val;
796
- }
797
- });
798
- return offsets;
799
- });
800
- // Calculate group positions for grouped mode (side-by-side bars)
801
- let group_info = $derived.by(() => {
802
- if (mode !== `grouped`)
803
- return { bar_series_count: 0, bar_series_indices: [] };
1229
+ if (!(srs?.visible ?? true) || srs.render_mode === `line`) return
1230
+
1231
+ const use_y2 = srs.y_axis === `y2`
1232
+ const pos_acc = use_y2 ? y2_pos_acc : y1_pos_acc
1233
+ const neg_acc = use_y2 ? y2_neg_acc : y1_neg_acc
1234
+
1235
+ for (let bar_idx = 0; bar_idx < max_len; bar_idx++) {
1236
+ const y_val = srs.y[bar_idx] ?? 0
1237
+ const acc = y_val >= 0 ? pos_acc : neg_acc
1238
+ offsets[series_idx][bar_idx] = acc[bar_idx]
1239
+ acc[bar_idx] += y_val
1240
+ }
1241
+ })
1242
+ return offsets
1243
+ })
1244
+
1245
+ // Calculate group positions for grouped mode (side-by-side bars)
1246
+ let group_info = $derived.by(() => {
1247
+ if (mode !== `grouped`) return { bar_series_count: 0, bar_series_indices: [] }
804
1248
  const bar_series_indices = internal_series
805
- .map((srs, idx) => (srs?.visible ?? true) && srs.render_mode !== `line` ? idx : -1)
806
- .filter((idx) => idx >= 0);
807
- return { bar_series_count: bar_series_indices.length, bar_series_indices };
808
- });
809
- // Set theme-aware background when entering fullscreen
810
- $effect(() => {
811
- set_fullscreen_bg(wrapper, fullscreen, `--barplot-fullscreen-bg`);
812
- });
813
- // State accessors for shared axis change handler
814
- const axis_state = {
1249
+ .map((srs, idx) =>
1250
+ (srs?.visible ?? true) && srs.render_mode !== `line` ? idx : -1
1251
+ )
1252
+ .filter((idx) => idx >= 0)
1253
+ return { bar_series_count: bar_series_indices.length, bar_series_indices }
1254
+ })
1255
+
1256
+ // Set theme-aware background when entering fullscreen
1257
+ $effect(() => {
1258
+ set_fullscreen_bg(wrapper, fullscreen, `--barplot-fullscreen-bg`)
1259
+ })
1260
+
1261
+ // State accessors for shared axis change handler
1262
+ const axis_state: AxisChangeState<BarSeries<Metadata>> = {
815
1263
  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;
1264
+ if (axis === `x`) return x_axis
1265
+ if (axis === `x2`) return x2_axis
1266
+ if (axis === `y`) return y_axis
1267
+ return y2_axis
823
1268
  },
824
1269
  set_axis: (axis, config) => {
825
- // Spread into existing state to preserve merged type structure
826
- if (axis === `x`)
827
- x_axis = { ...x_axis, ...config };
828
- else if (axis === `x2`)
829
- x2_axis = { ...x2_axis, ...config };
830
- else if (axis === `y`)
831
- y_axis = { ...y_axis, ...config };
832
- else
833
- y2_axis = { ...y2_axis, ...config };
1270
+ // Spread into existing state to preserve merged type structure
1271
+ if (axis === `x`) x_axis = { ...x_axis, ...config }
1272
+ else if (axis === `x2`) x2_axis = { ...x2_axis, ...config }
1273
+ else if (axis === `y`) y_axis = { ...y_axis, ...config }
1274
+ else y2_axis = { ...y2_axis, ...config }
834
1275
  },
835
1276
  get_series: () => series,
836
1277
  set_series: (new_series) => (series = new_series),
837
1278
  get_loading: () => axis_loading,
838
1279
  set_loading: (axis) => (axis_loading = axis),
839
- };
840
- // Create shared handler bound to this component's state
841
- // Using $derived so handler updates when callback props change
842
- const handle_axis_change = $derived(create_axis_change_handler(axis_state, data_loader, on_axis_change, on_error));
843
- let auto_load_attempted = false; // prevent infinite retries on failure
844
- // Auto-load data if series is empty but options exist (runs once)
845
- $effect(() => {
1280
+ }
1281
+
1282
+ // Create shared handler bound to this component's state
1283
+ // Using $derived so handler updates when callback props change
1284
+ const handle_axis_change = $derived(create_axis_change_handler(
1285
+ axis_state,
1286
+ data_loader,
1287
+ on_axis_change,
1288
+ on_error,
1289
+ ))
1290
+
1291
+ let auto_load_attempted = false // prevent infinite retries on failure
1292
+
1293
+ // Auto-load data if series is empty but options exist (runs once)
1294
+ $effect(() => {
846
1295
  if (series.length === 0 && data_loader && !auto_load_attempted) {
847
- // Check x-axis first, then y-axis
848
- if (x_axis.options?.length) {
849
- auto_load_attempted = true;
850
- const first_key = x_axis.selected_key ?? x_axis.options[0].key;
851
- handle_axis_change(`x`, first_key).catch(() => { });
852
- }
853
- else if (y_axis.options?.length) {
854
- auto_load_attempted = true;
855
- const first_key = y_axis.selected_key ?? y_axis.options[0].key;
856
- handle_axis_change(`y`, first_key).catch(() => { });
857
- }
1296
+ // Check x-axis first, then y-axis
1297
+ if (x_axis.options?.length) {
1298
+ auto_load_attempted = true
1299
+ const first_key = x_axis.selected_key ?? x_axis.options[0].key
1300
+ handle_axis_change(`x`, first_key).catch(() => {})
1301
+ } else if (y_axis.options?.length) {
1302
+ auto_load_attempted = true
1303
+ const first_key = y_axis.selected_key ?? y_axis.options[0].key
1304
+ handle_axis_change(`y`, first_key).catch(() => {})
1305
+ }
858
1306
  }
859
- });
1307
+ })
860
1308
  </script>
861
1309
 
862
1310
  {#snippet ref_lines_layer(lines: IndexedRefLine[])}
@@ -1680,13 +2128,13 @@ $effect(() => {
1680
2128
  <div><strong>{series_label}</strong></div>
1681
2129
  {/if}
1682
2130
  <div>
1683
- {@html hover_info.x_axis.label || `x`}: {
2131
+ {@html sanitize_html(hover_info.x_axis.label || `x`)}: {
1684
2132
  (cat_axis === `x` ? hover_info.category_label : undefined) ??
1685
2133
  format_value(hover_info.orient_x, hover_info.x_axis.format || `.3~s`)
1686
2134
  }
1687
2135
  </div>
1688
2136
  <div>
1689
- {@html hover_info.y_axis.label || `y`}: {
2137
+ {@html sanitize_html(hover_info.y_axis.label || `y`)}: {
1690
2138
  (cat_axis === `y` ? hover_info.category_label : undefined) ??
1691
2139
  format_value(hover_info.orient_y, hover_info.y_axis.format || `.3~s`)
1692
2140
  }