matterviz 0.1.12 → 0.1.13

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 (195) hide show
  1. package/dist/ContextMenu.svelte +1 -1
  2. package/dist/ContextMenu.svelte.d.ts +3 -3
  3. package/dist/DraggablePane.svelte +10 -5
  4. package/dist/DraggablePane.svelte.d.ts +3 -3
  5. package/dist/FilePicker.svelte.d.ts +3 -3
  6. package/dist/Icon.svelte.d.ts +3 -3
  7. package/dist/Nav.svelte +76 -31
  8. package/dist/SettingsSection.svelte.d.ts +3 -3
  9. package/dist/Spinner.svelte +18 -29
  10. package/dist/Spinner.svelte.d.ts +3 -3
  11. package/dist/app.css +4 -0
  12. package/dist/bands/Bands.svelte +216 -0
  13. package/dist/bands/Bands.svelte.d.ts +16 -0
  14. package/dist/bands/BandsAndDos.svelte +26 -0
  15. package/dist/bands/BandsAndDos.svelte.d.ts +16 -0
  16. package/dist/bands/Dos.svelte +124 -0
  17. package/dist/bands/Dos.svelte.d.ts +18 -0
  18. package/dist/bands/helpers.d.ts +11 -0
  19. package/dist/bands/helpers.js +195 -0
  20. package/dist/bands/index.d.ts +5 -0
  21. package/dist/bands/index.js +6 -0
  22. package/dist/bands/types.d.ts +54 -0
  23. package/dist/bands/types.js +2 -0
  24. package/dist/brillouin/BrillouinZone.svelte +373 -0
  25. package/dist/brillouin/BrillouinZone.svelte.d.ts +57 -0
  26. package/dist/brillouin/BrillouinZoneControls.svelte +88 -0
  27. package/dist/brillouin/BrillouinZoneControls.svelte.d.ts +14 -0
  28. package/dist/brillouin/BrillouinZoneExportPane.svelte +129 -0
  29. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +15 -0
  30. package/dist/brillouin/BrillouinZoneInfoPane.svelte +115 -0
  31. package/dist/brillouin/BrillouinZoneInfoPane.svelte.d.ts +13 -0
  32. package/dist/brillouin/BrillouinZoneScene.svelte +180 -0
  33. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +34 -0
  34. package/dist/brillouin/compute.d.ts +7 -0
  35. package/dist/brillouin/compute.js +215 -0
  36. package/dist/brillouin/index.d.ts +7 -0
  37. package/dist/brillouin/index.js +7 -0
  38. package/dist/brillouin/types.d.ts +23 -0
  39. package/dist/brillouin/types.js +1 -0
  40. package/dist/composition/BarChart.svelte.d.ts +3 -3
  41. package/dist/composition/BubbleChart.svelte +7 -6
  42. package/dist/composition/BubbleChart.svelte.d.ts +3 -3
  43. package/dist/composition/Composition.svelte.d.ts +3 -3
  44. package/dist/composition/PieChart.svelte +1 -1
  45. package/dist/composition/PieChart.svelte.d.ts +3 -3
  46. package/dist/composition/parse.d.ts +3 -3
  47. package/dist/coordination/CoordinationBarPlot.svelte +241 -0
  48. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +29 -0
  49. package/dist/coordination/calc-coordination.d.ts +15 -0
  50. package/dist/coordination/calc-coordination.js +66 -0
  51. package/dist/coordination/index.d.ts +8 -0
  52. package/dist/coordination/index.js +7 -0
  53. package/dist/element/BohrAtom.svelte.d.ts +3 -3
  54. package/dist/element/ElementHeading.svelte.d.ts +3 -3
  55. package/dist/element/ElementPhoto.svelte.d.ts +3 -3
  56. package/dist/element/ElementStats.svelte.d.ts +3 -3
  57. package/dist/element/ElementTile.svelte +1 -1
  58. package/dist/element/ElementTile.svelte.d.ts +4 -4
  59. package/dist/element/Nucleus.svelte.d.ts +3 -3
  60. package/dist/icons.d.ts +4 -0
  61. package/dist/icons.js +4 -0
  62. package/dist/index.d.ts +5 -1
  63. package/dist/index.js +5 -1
  64. package/dist/io/export.d.ts +1 -6
  65. package/dist/io/export.js +12 -12
  66. package/dist/labels.d.ts +19 -0
  67. package/dist/labels.js +59 -0
  68. package/dist/math.d.ts +7 -2
  69. package/dist/math.js +24 -12
  70. package/dist/periodic-table/PeriodicTable.svelte.d.ts +3 -3
  71. package/dist/periodic-table/PeriodicTableControls.svelte.d.ts +3 -3
  72. package/dist/periodic-table/PropertySelect.svelte +1 -1
  73. package/dist/periodic-table/PropertySelect.svelte.d.ts +5 -4
  74. package/dist/periodic-table/TableInset.svelte.d.ts +3 -3
  75. package/dist/phase-diagram/PhaseDiagram2D.svelte +44 -107
  76. package/dist/phase-diagram/PhaseDiagram2D.svelte.d.ts +7 -27
  77. package/dist/phase-diagram/PhaseDiagram3D.svelte +67 -170
  78. package/dist/phase-diagram/PhaseDiagram3D.svelte.d.ts +7 -31
  79. package/dist/phase-diagram/PhaseDiagram4D.svelte +87 -145
  80. package/dist/phase-diagram/PhaseDiagram4D.svelte.d.ts +5 -28
  81. package/dist/phase-diagram/PhaseDiagramControls.svelte +9 -9
  82. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +6 -6
  83. package/dist/phase-diagram/PhaseDiagramInfoPane.svelte +4 -4
  84. package/dist/phase-diagram/PhaseDiagramInfoPane.svelte.d.ts +5 -5
  85. package/dist/phase-diagram/PhaseDiagramStats.svelte +9 -16
  86. package/dist/phase-diagram/PhaseDiagramStats.svelte.d.ts +3 -3
  87. package/dist/phase-diagram/StructurePopup.svelte.d.ts +3 -3
  88. package/dist/phase-diagram/barycentric-coords.js +13 -24
  89. package/dist/phase-diagram/helpers.d.ts +12 -14
  90. package/dist/phase-diagram/helpers.js +70 -23
  91. package/dist/phase-diagram/index.d.ts +58 -0
  92. package/dist/phase-diagram/index.js +44 -0
  93. package/dist/phase-diagram/thermodynamics.js +2 -3
  94. package/dist/phase-diagram/types.d.ts +3 -8
  95. package/dist/plot/BarPlot.svelte +474 -240
  96. package/dist/plot/BarPlot.svelte.d.ts +16 -37
  97. package/dist/plot/BarPlotControls.svelte +33 -201
  98. package/dist/plot/BarPlotControls.svelte.d.ts +11 -22
  99. package/dist/plot/ColorBar.svelte.d.ts +3 -3
  100. package/dist/plot/ColorScaleSelect.svelte +1 -1
  101. package/dist/plot/ColorScaleSelect.svelte.d.ts +3 -3
  102. package/dist/plot/ElementScatter.svelte +5 -5
  103. package/dist/plot/ElementScatter.svelte.d.ts +8 -8
  104. package/dist/plot/Histogram.svelte +318 -170
  105. package/dist/plot/Histogram.svelte.d.ts +21 -41
  106. package/dist/plot/HistogramControls.svelte +115 -347
  107. package/dist/plot/HistogramControls.svelte.d.ts +13 -28
  108. package/dist/plot/Line.svelte +1 -1
  109. package/dist/plot/Line.svelte.d.ts +4 -4
  110. package/dist/plot/PlotControls.svelte +280 -0
  111. package/dist/plot/PlotControls.svelte.d.ts +4 -0
  112. package/dist/plot/PlotLegend.svelte +9 -24
  113. package/dist/plot/PlotLegend.svelte.d.ts +3 -3
  114. package/dist/plot/ScatterPlot.svelte +536 -571
  115. package/dist/plot/ScatterPlot.svelte.d.ts +18 -90
  116. package/dist/plot/ScatterPlotControls.svelte +108 -316
  117. package/dist/plot/ScatterPlotControls.svelte.d.ts +11 -38
  118. package/dist/plot/ScatterPoint.svelte +1 -1
  119. package/dist/plot/ScatterPoint.svelte.d.ts +5 -5
  120. package/dist/plot/SpacegroupBarPlot.svelte +235 -0
  121. package/dist/plot/SpacegroupBarPlot.svelte.d.ts +9 -0
  122. package/dist/plot/data-transform.d.ts +2 -2
  123. package/dist/plot/data-transform.js +1 -5
  124. package/dist/plot/index.d.ts +12 -200
  125. package/dist/plot/index.js +14 -6
  126. package/dist/plot/layout.d.ts +10 -16
  127. package/dist/plot/layout.js +40 -25
  128. package/dist/plot/scales.d.ts +7 -8
  129. package/dist/plot/scales.js +22 -7
  130. package/dist/plot/types.d.ts +303 -0
  131. package/dist/plot/types.js +1 -0
  132. package/dist/rdf/RdfPlot.svelte +156 -0
  133. package/dist/rdf/RdfPlot.svelte.d.ts +20 -0
  134. package/dist/rdf/calc-rdf.d.ts +4 -0
  135. package/dist/rdf/calc-rdf.js +107 -0
  136. package/dist/rdf/index.d.ts +22 -0
  137. package/dist/rdf/index.js +2 -0
  138. package/dist/settings.d.ts +86 -75
  139. package/dist/settings.js +257 -155
  140. package/dist/structure/Bond.svelte +126 -110
  141. package/dist/structure/Bond.svelte.d.ts +7 -20
  142. package/dist/structure/CanvasTooltip.svelte.d.ts +4 -4
  143. package/dist/structure/Cylinder.svelte +29 -0
  144. package/dist/structure/Cylinder.svelte.d.ts +10 -0
  145. package/dist/structure/Lattice.svelte +1 -2
  146. package/dist/structure/Lattice.svelte.d.ts +3 -3
  147. package/dist/structure/Structure.svelte +23 -8
  148. package/dist/structure/Structure.svelte.d.ts +10 -7
  149. package/dist/structure/StructureControls.svelte +25 -134
  150. package/dist/structure/StructureControls.svelte.d.ts +4 -9
  151. package/dist/structure/StructureExportPane.svelte +188 -0
  152. package/dist/structure/StructureExportPane.svelte.d.ts +17 -0
  153. package/dist/structure/StructureInfoPane.svelte.d.ts +3 -3
  154. package/dist/structure/StructureLegend.svelte +91 -27
  155. package/dist/structure/StructureLegend.svelte.d.ts +5 -4
  156. package/dist/structure/StructureScene.svelte +93 -100
  157. package/dist/structure/StructureScene.svelte.d.ts +15 -12
  158. package/dist/structure/Vector.svelte +19 -25
  159. package/dist/structure/Vector.svelte.d.ts +3 -3
  160. package/dist/structure/bonding.d.ts +16 -14
  161. package/dist/structure/bonding.js +207 -216
  162. package/dist/structure/export.d.ts +3 -0
  163. package/dist/structure/export.js +120 -0
  164. package/dist/structure/index.d.ts +15 -1
  165. package/dist/structure/index.js +2 -0
  166. package/dist/symmetry/WyckoffTable.svelte.d.ts +3 -3
  167. package/dist/symmetry/index.d.ts +1 -0
  168. package/dist/symmetry/index.js +1 -0
  169. package/dist/symmetry/spacegroups.d.ts +9 -0
  170. package/dist/symmetry/spacegroups.js +397 -0
  171. package/dist/theme/ThemeControl.svelte.d.ts +3 -3
  172. package/dist/theme/themes.js +2 -16
  173. package/dist/trajectory/Trajectory.svelte +52 -78
  174. package/dist/trajectory/Trajectory.svelte.d.ts +5 -5
  175. package/dist/trajectory/TrajectoryError.svelte.d.ts +3 -3
  176. package/dist/trajectory/TrajectoryExportPane.svelte +1 -1
  177. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +5 -5
  178. package/dist/trajectory/TrajectoryInfoPane.svelte +1 -1
  179. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +3 -3
  180. package/dist/trajectory/extract.js +2 -1
  181. package/dist/trajectory/index.d.ts +1 -1
  182. package/dist/trajectory/index.js +1 -1
  183. package/dist/trajectory/parse.js +21 -14
  184. package/dist/utils.d.ts +1 -0
  185. package/dist/utils.js +17 -0
  186. package/dist/xrd/XrdPlot.svelte +14 -11
  187. package/dist/xrd/XrdPlot.svelte.d.ts +6 -5
  188. package/package.json +20 -12
  189. package/readme.md +1 -1
  190. package/dist/material/MaterialCard.svelte +0 -123
  191. package/dist/material/MaterialCard.svelte.d.ts +0 -9
  192. package/dist/material/index.d.ts +0 -1
  193. package/dist/material/index.js +0 -1
  194. package/dist/plot/formatting.d.ts +0 -7
  195. package/dist/plot/formatting.js +0 -53
@@ -1,100 +1,143 @@
1
- <script lang="ts">import { DraggablePane } from '..';
2
- import { find_best_plot_area, PlotLegend } from './';
3
- import { format_value } from './formatting';
1
+ <script lang="ts">import { format_value } from '../labels';
2
+ import { BarPlotControls, DEFAULT_GRID_STYLE, find_best_plot_area, PlotLegend, } from './';
4
3
  import { get_relative_coords } from './interactions';
5
4
  import { create_scale, generate_ticks, get_nice_data_range } from './scales';
5
+ import { DEFAULTS } from '../settings';
6
6
  import { SvelteMap } from 'svelte/reactivity';
7
- import BarPlotControls from './BarPlotControls.svelte';
8
7
  import { calc_auto_padding, measure_text_width } from './layout';
9
- let { series = $bindable([]), orientation = $bindable(`vertical`), mode = $bindable(`overlay`), x_lim = [null, null], y_lim = [null, null], x_range, y_range, range_padding = 0.05, x_label = $bindable(``), x_label_shift = $bindable({ x: 0, y: 0 }), y_label = $bindable(``), y_label_shift = $bindable({ x: 0, y: 0 }), x_format = $bindable(``), y_format = $bindable(``), x_ticks = $bindable(8), y_ticks = $bindable(6), x_grid = $bindable(true), y_grid = $bindable(true), show_zero_lines = $bindable(true), legend = {}, padding = { t: 10, b: 60, l: 60, r: 30 }, default_bar_color = `var(--bar-color, #4682b4)`, tooltip, hovered = $bindable(false), change = () => { }, on_bar_click, on_bar_hover, show_controls = $bindable(true), controls_open = $bindable(false), plot_controls, controls_toggle_props, children, ...rest } = $props();
8
+ let { series = $bindable([]), orientation = $bindable(`vertical`), mode = $bindable(`overlay`), x_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.bar.display), x_range = [null, null], y_range = [null, null], y2_range = [null, null], range_padding = 0.05, padding = { t: 20, b: 60, l: 60, r: 20 }, legend = {}, show_legend, bar = $bindable({}), line = $bindable({}), tooltip, user_content, hovered = $bindable(false), change = () => { }, on_bar_click, on_bar_hover, show_controls = $bindable(true), controls_open = $bindable(false), controls_toggle_props, controls_pane_props, children, ...rest } = $props();
9
+ // Initialize bar and line styles with defaults (runs once)
10
+ bar = { ...DEFAULTS.bar.bar, ...bar };
11
+ line = { ...DEFAULTS.bar.line, ...line };
12
+ y2_axis = {
13
+ format: ``,
14
+ scale_type: `linear`,
15
+ ticks: 5,
16
+ label_shift: { y: 60 },
17
+ tick_label_shift: { x: 8, y: 0 },
18
+ range: [null, null],
19
+ ...y2_axis,
20
+ };
10
21
  let [width, height] = $state([0, 0]);
11
22
  let svg_element = $state(null);
12
- let clip_path_id = `chart-clip-${Math.random().toString(36).slice(2)}`;
23
+ let clip_path_id = `chart-clip-${crypto?.randomUUID?.()}`;
13
24
  // Compute auto ranges from visible series
14
- let visible_series = $derived(series.filter((s) => s?.visible ?? true));
25
+ let visible_series = $derived(series.filter((srs) => srs?.visible ?? true));
26
+ // Separate series by y-axis
27
+ let y1_series = $derived(visible_series.filter((srs) => (srs.y_axis ?? `y1`) === `y1`));
28
+ let y2_series = $derived(visible_series.filter((srs) => srs.y_axis === `y2`));
15
29
  let auto_ranges = $derived.by(() => {
16
- let all_points = visible_series.flatMap((s) => s.x.map((x_val, idx) => ({ x: x_val, y: s.y[idx] })));
17
- // In stacked mode, calculate stacked totals for accurate range
18
- if (mode === `stacked`) {
19
- const stacked_totals = new SvelteMap();
20
- // Only include visible bar series (not lines) in stacking
21
- visible_series
22
- .filter((srs) => srs.render_mode !== `line`)
23
- .forEach((srs) => srs.x.forEach((x_val, idx) => {
24
- const y_val = srs.y[idx] ?? 0;
25
- const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 };
26
- if (y_val >= 0)
27
- totals.pos += y_val;
28
- else
29
- totals.neg += y_val;
30
- stacked_totals.set(x_val, totals);
31
- }));
32
- // Replace points with stacked totals + line series (which don't stack)
33
- all_points = [
34
- ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
35
- ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
36
- ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
37
- ]),
38
- ...visible_series
39
- .filter((srs) => srs.render_mode === `line`)
40
- .flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))),
41
- ];
42
- }
43
- if (!all_points.length) {
44
- return { x: [0, 1], y: [0, 1] };
45
- }
46
- // Compute data-driven ranges first (categories from x, magnitudes from y)
47
- const x_range = get_nice_data_range(all_points, (p) => p.x, x_lim, `linear`, range_padding, x_format?.startsWith(`%`) || false);
48
- let y_range = get_nice_data_range(all_points, (p) => p.y, y_lim, `linear`, range_padding, false);
49
- // For bar plots, ensure the value axis starts at 0 unless there are negative values
50
- // This prevents bars from starting at arbitrary values
51
- const has_negative = all_points.some((p) => p.y < 0);
52
- const has_positive = all_points.some((p) => p.y > 0);
53
- // Only adjust if no explicit y_lim is set
54
- if (!y_lim?.[0] && !y_lim?.[1]) {
55
- if (has_positive && !has_negative) {
56
- // All positive/zero values: always start from 0
57
- y_range = [0, y_range[1]];
30
+ // Calculate separate ranges for y1 and y2 axes
31
+ const calc_y_range = (series_list, y_limit, scale_type) => {
32
+ let points = series_list.flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] })));
33
+ // In stacked mode, calculate stacked totals for accurate range (only for bars on the same axis)
34
+ if (mode === `stacked`) {
35
+ const stacked_totals = new SvelteMap();
36
+ // Only include visible bar series (not lines) in stacking
37
+ series_list
38
+ .filter((srs) => srs.render_mode !== `line`)
39
+ .forEach((srs) => srs.x.forEach((x_val, idx) => {
40
+ const y_val = srs.y[idx] ?? 0;
41
+ const totals = stacked_totals.get(x_val) ?? { pos: 0, neg: 0 };
42
+ if (y_val >= 0)
43
+ totals.pos += y_val;
44
+ else
45
+ totals.neg += y_val;
46
+ stacked_totals.set(x_val, totals);
47
+ }));
48
+ // Replace points with stacked totals + line series (which don't stack)
49
+ points = [
50
+ ...Array.from(stacked_totals).flatMap(([x_val, { pos, neg }]) => [
51
+ ...(pos > 0 ? [{ x: x_val, y: pos }] : []),
52
+ ...(neg < 0 ? [{ x: x_val, y: neg }] : []),
53
+ ]),
54
+ ...series_list
55
+ .filter((srs) => srs.render_mode === `line`)
56
+ .flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] }))),
57
+ ];
58
58
  }
59
- else if (has_negative && !has_positive) {
60
- // All negative values: end at 0
61
- y_range = [y_range[0], 0];
59
+ if (!points.length) {
60
+ return [0, 1];
62
61
  }
63
- // Mixed positive/negative: keep natural range (will include 0)
64
- }
62
+ let y_range = get_nice_data_range(points, (p) => p.y, y_limit, scale_type, range_padding, false);
63
+ // For bar plots, ensure the value axis starts at 0 unless there are negative values
64
+ // Only apply zero-clamping for linear scales
65
+ if (scale_type === `linear`) {
66
+ const has_negative = points.some((p) => p.y < 0);
67
+ const has_positive = points.some((p) => p.y > 0);
68
+ // Only adjust if no explicit y_range is set
69
+ if (y_limit?.[0] == null && y_limit?.[1] == null) {
70
+ if (has_positive && !has_negative)
71
+ y_range = [0, y_range[1]];
72
+ else if (has_negative && !has_positive)
73
+ y_range = [y_range[0], 0];
74
+ }
75
+ }
76
+ return y_range;
77
+ };
78
+ // Get all x values for x_range calculation
79
+ const all_x_points = visible_series.flatMap((s) => s.x.map((x_val) => ({ x: x_val, y: 0 })));
80
+ const x_scale_type = x_axis.scale_type ?? `linear`;
81
+ const x_auto_range = all_x_points.length
82
+ ? get_nice_data_range(all_x_points, (p) => p.x, x_range, x_scale_type, range_padding, x_axis.format?.startsWith(`%`) || false)
83
+ : [0, 1];
84
+ const y1_range = calc_y_range(y1_series, y_range, y_axis.scale_type ?? `linear`);
85
+ const y2_auto_range = calc_y_range(y2_series, y2_range, y2_axis.scale_type ?? `linear`);
65
86
  // Map data ranges to axis ranges depending on orientation
66
87
  return orientation === `horizontal`
67
- ? ({ x: y_range, y: x_range })
68
- : ({ x: x_range, y: y_range });
88
+ ? ({ x: y1_range, y: x_auto_range, y2: y2_auto_range })
89
+ : ({ x: x_auto_range, y: y1_range, y2: y2_auto_range });
69
90
  });
70
91
  // Initialize and current ranges
71
92
  let ranges = $state({
72
- initial: { x: [0, 1], y: [0, 1] },
73
- current: { x: [0, 1], y: [0, 1] },
93
+ initial: { x: [0, 1], y: [0, 1], y2: [0, 1] },
94
+ current: { x: [0, 1], y: [0, 1], y2: [0, 1] },
74
95
  });
75
96
  $effect(() => {
76
97
  const new_x = [
77
- x_range?.[0] ?? auto_ranges.x[0],
78
- x_range?.[1] ?? auto_ranges.x[1],
98
+ x_axis.range?.[0] ?? auto_ranges.x[0],
99
+ x_axis.range?.[1] ?? auto_ranges.x[1],
79
100
  ];
80
101
  const new_y = [
81
- y_range?.[0] ?? auto_ranges.y[0],
82
- y_range?.[1] ?? auto_ranges.y[1],
102
+ y_axis.range?.[0] ?? auto_ranges.y[0],
103
+ y_axis.range?.[1] ?? auto_ranges.y[1],
83
104
  ];
84
- ranges = {
85
- initial: { x: new_x, y: new_y },
86
- current: { x: new_x, y: new_y },
87
- };
105
+ const new_y2 = [
106
+ y2_axis.range?.[0] ?? auto_ranges.y2[0],
107
+ y2_axis.range?.[1] ?? auto_ranges.y2[1],
108
+ ];
109
+ // Only update if ranges actually changed
110
+ if (ranges.current.x[0] !== new_x[0] ||
111
+ ranges.current.x[1] !== new_x[1] ||
112
+ ranges.current.y[0] !== new_y[0] ||
113
+ ranges.current.y[1] !== new_y[1] ||
114
+ ranges.current.y2[0] !== new_y2[0] ||
115
+ ranges.current.y2[1] !== new_y2[1]) {
116
+ ranges = {
117
+ initial: { x: new_x, y: new_y, y2: new_y2 },
118
+ current: { x: new_x, y: new_y, y2: new_y2 },
119
+ };
120
+ }
88
121
  });
89
122
  // Layout: dynamic padding based on tick label widths
90
123
  const default_padding = { t: 20, b: 60, l: 60, r: 20 };
91
124
  let pad = $state({ ...default_padding, ...padding });
92
125
  // Update padding when format or ticks change, but prevent infinite loop
93
126
  $effect(() => {
94
- const base_pad = { ...default_padding, ...padding };
95
127
  const new_pad = width && height && ticks.y.length
96
- ? calc_auto_padding({ base_padding: base_pad, y_ticks: ticks.y, y_format })
97
- : base_pad;
128
+ ? calc_auto_padding({
129
+ padding,
130
+ default_padding,
131
+ y_axis: { ...y_axis, tick_values: ticks.y },
132
+ y2_axis: { ...y2_axis, tick_values: ticks.y2 },
133
+ })
134
+ : { ...default_padding, ...padding };
135
+ // Expand right padding if y2 ticks are shown (only for vertical orientation)
136
+ if (width && height && y2_series.length && ticks.y2.length &&
137
+ orientation === `vertical`) {
138
+ const y2_tick_width = Math.max(0, ...ticks.y2.map((tick) => measure_text_width(format_value(tick, y2_axis.format), `12px sans-serif`)));
139
+ new_pad.r = Math.max(new_pad.r, 10 + y2_tick_width + (y2_axis.label ? 40 : 0));
140
+ }
98
141
  // Only update if padding actually changed (prevents infinite loop)
99
142
  if (pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
100
143
  pad.r !== new_pad.r)
@@ -102,20 +145,35 @@ $effect(() => {
102
145
  });
103
146
  const chart_width = $derived(Math.max(1, width - pad.l - pad.r));
104
147
  const chart_height = $derived(Math.max(1, height - pad.t - pad.b));
105
- // Scales (linear only for now)
148
+ // Scales
106
149
  let scales = $derived({
107
- x: create_scale(`linear`, ranges.current.x, [pad.l, width - pad.r]),
108
- y: create_scale(`linear`, ranges.current.y, [height - pad.b, pad.t]),
150
+ x: create_scale(x_axis.scale_type ?? `linear`, ranges.current.x, [
151
+ pad.l,
152
+ width - pad.r,
153
+ ]),
154
+ y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [
155
+ height - pad.b,
156
+ pad.t,
157
+ ]),
158
+ y2: create_scale(y2_axis.scale_type ?? `linear`, ranges.current.y2, [
159
+ height - pad.b,
160
+ pad.t,
161
+ ]),
109
162
  });
110
163
  // Ticks
111
164
  let ticks = $derived({
112
165
  x: width && height
113
- ? generate_ticks(ranges.current.x, `linear`, x_ticks, scales.x, {
166
+ ? generate_ticks(ranges.current.x, x_axis.scale_type ?? `linear`, x_axis.ticks, scales.x, {
114
167
  default_count: 8,
115
168
  })
116
169
  : [],
117
170
  y: width && height
118
- ? generate_ticks(ranges.current.y, `linear`, y_ticks, scales.y, {
171
+ ? generate_ticks(ranges.current.y, y_axis.scale_type ?? `linear`, y_axis.ticks, scales.y, {
172
+ default_count: 6,
173
+ })
174
+ : [],
175
+ y2: width && height && y2_series.length > 0 && orientation === `vertical`
176
+ ? generate_ticks(ranges.current.y2, y2_axis.scale_type ?? `linear`, y2_axis.ticks, scales.y2, {
119
177
  default_count: 6,
120
178
  })
121
179
  : [],
@@ -132,15 +190,30 @@ const on_window_mouse_move = (evt) => {
132
190
  };
133
191
  const on_window_mouse_up = () => {
134
192
  if (drag_state.start && drag_state.current) {
135
- const x1 = scales.x.invert(drag_state.start.x);
136
- const x2 = scales.x.invert(drag_state.current.x);
193
+ const x1_raw = scales.x.invert(drag_state.start.x);
194
+ const x2_raw = scales.x.invert(drag_state.current.x);
137
195
  const y1 = scales.y.invert(drag_state.start.y);
138
196
  const y2 = scales.y.invert(drag_state.current.y);
197
+ const y2_1 = scales.y2.invert(drag_state.start.y);
198
+ const y2_2 = scales.y2.invert(drag_state.current.y);
139
199
  const dx = Math.abs(drag_state.start.x - drag_state.current.x);
140
200
  const dy = Math.abs(drag_state.start.y - drag_state.current.y);
141
- if (dx > 5 && dy > 5 && typeof x1 === `number` && typeof x2 === `number`) {
142
- ranges.current.x = [Math.min(x1, x2), Math.max(x1, x2)];
143
- ranges.current.y = [Math.min(y1, y2), Math.max(y1, y2)];
201
+ let xr1, xr2;
202
+ if (x1_raw instanceof Date && x2_raw instanceof Date) {
203
+ ;
204
+ [xr1, xr2] = [x1_raw.getTime(), x2_raw.getTime()];
205
+ }
206
+ else if (typeof x1_raw === `number` && typeof x2_raw === `number`) {
207
+ ;
208
+ [xr1, xr2] = [x1_raw, x2_raw];
209
+ }
210
+ else
211
+ [xr1, xr2] = [NaN, NaN]; // bail: mixed types
212
+ if (dx > 5 && dy > 5 && Number.isFinite(xr1) && Number.isFinite(xr2)) {
213
+ // Update axis ranges to trigger reactivity and prevent effect from overriding
214
+ x_axis = { ...x_axis, range: [Math.min(xr1, xr2), Math.max(xr1, xr2)] };
215
+ y_axis = { ...y_axis, range: [Math.min(y1, y2), Math.max(y1, y2)] };
216
+ y2_axis = { ...y2_axis, range: [Math.min(y2_1, y2_2), Math.max(y2_1, y2_2)] };
144
217
  }
145
218
  }
146
219
  drag_state = { start: null, current: null, bounds: null };
@@ -162,7 +235,10 @@ function handle_mouse_down(evt) {
162
235
  evt.preventDefault();
163
236
  }
164
237
  function handle_double_click() {
165
- ranges.current = { x: [...ranges.initial.x], y: [...ranges.initial.y] };
238
+ // Clear axis ranges to reset to auto ranges
239
+ x_axis = { ...x_axis, range: undefined };
240
+ y_axis = { ...y_axis, range: undefined };
241
+ y2_axis = { ...y2_axis, range: undefined };
166
242
  }
167
243
  // Legend data and handlers
168
244
  let legend_data = $derived.by(() => series.map((srs, idx) => ({
@@ -171,12 +247,12 @@ let legend_data = $derived.by(() => series.map((srs, idx) => ({
171
247
  visible: srs.visible ?? true,
172
248
  display_style: srs.render_mode === `line`
173
249
  ? {
174
- line_color: srs.color ?? default_bar_color,
250
+ line_color: srs.color ?? line.color,
175
251
  line_dash: srs.line_style?.line_dash,
176
252
  }
177
253
  : {
178
254
  symbol_type: `Square`,
179
- symbol_color: srs.color ?? default_bar_color,
255
+ symbol_color: srs.color ?? bar.color,
180
256
  },
181
257
  })));
182
258
  function toggle_series_visibility(series_idx) {
@@ -193,34 +269,43 @@ let bar_points_for_placement = $derived.by(() => {
193
269
  // Use original series index to look up stacked_offsets
194
270
  const series_idx = series.indexOf(srs);
195
271
  const series_offsets = stacked_offsets[series_idx] ?? [];
272
+ const use_y2 = srs.y_axis === `y2`;
273
+ const y_scale = use_y2 ? scales.y2 : scales.y;
196
274
  return srs.x.map((x_val, bar_idx) => {
197
275
  const y_val = srs.y[bar_idx];
198
276
  const base = !is_line && mode === `stacked`
199
277
  ? (series_offsets[bar_idx] ?? 0)
200
278
  : 0;
201
279
  const [bar_x, bar_y] = orientation === `vertical`
202
- ? [scales.x(x_val), scales.y(base + y_val)]
280
+ ? [scales.x(x_val), y_scale(base + y_val)]
203
281
  : [scales.x(base + y_val), scales.y(x_val)];
204
282
  return { x: bar_x, y: bar_y };
205
283
  }).filter((p) => isFinite(p.x) && isFinite(p.y));
206
284
  });
207
285
  });
208
286
  // Calculate best legend placement
209
- let legend_placement = $derived.by(() => series.length > 1 && width && height
210
- ? find_best_plot_area(bar_points_for_placement, {
211
- plot_width: chart_width,
212
- plot_height: chart_height,
213
- padding: pad,
214
- margin: 10,
215
- legend_size: { width: 120, height: 60 },
216
- })
217
- : null);
287
+ let legend_placement = $derived.by(() => {
288
+ const should_show = show_legend !== undefined ? show_legend : series.length > 1;
289
+ return should_show && width && height
290
+ ? find_best_plot_area(bar_points_for_placement, {
291
+ plot_width: chart_width,
292
+ plot_height: chart_height,
293
+ padding: pad,
294
+ margin: 10,
295
+ legend_size: { width: 120, height: 60 },
296
+ })
297
+ : null;
298
+ });
218
299
  // Tooltip state
219
300
  let hover_info = $state(null);
220
301
  function get_bar_data(series_idx, bar_idx, color) {
221
302
  const srs = series[series_idx];
222
303
  const [x, y] = [srs.x[bar_idx], srs.y[bar_idx]];
223
304
  const [orient_x, orient_y] = orientation === `horizontal` ? [y, x] : [x, y];
305
+ const metadata = Array.isArray(srs.metadata)
306
+ ? srs.metadata[bar_idx]
307
+ : srs.metadata;
308
+ const label = srs.labels?.[bar_idx] ?? null;
224
309
  return {
225
310
  x,
226
311
  y,
@@ -228,9 +313,10 @@ function get_bar_data(series_idx, bar_idx, color) {
228
313
  orient_y,
229
314
  series_idx,
230
315
  bar_idx,
231
- label: srs.labels?.[bar_idx] ?? null,
232
- metadata: Array.isArray(srs.metadata) ? srs.metadata[bar_idx] : srs.metadata,
316
+ metadata,
233
317
  color,
318
+ label,
319
+ y_axis: srs.y_axis ?? `y1`,
234
320
  };
235
321
  }
236
322
  const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
@@ -239,17 +325,23 @@ const handle_bar_hover = (series_idx, bar_idx, color) => (event) => {
239
325
  change(hover_info);
240
326
  on_bar_hover?.({ ...hover_info, event });
241
327
  };
242
- // Stack offsets (only for bar series in stacked mode)
328
+ // Stack offsets (only for bar series in stacked mode, grouped by y-axis)
243
329
  let stacked_offsets = $derived.by(() => {
244
330
  if (mode !== `stacked`)
245
331
  return [];
246
- const max_len = Math.max(0, ...series.map((s) => s.y.length));
332
+ const max_len = Math.max(0, ...series.map((srs) => srs.y.length));
247
333
  const offsets = series.map(() => Array.from({ length: max_len }, () => 0));
248
- const pos_acc = Array.from({ length: max_len }, () => 0);
249
- const neg_acc = Array.from({ length: max_len }, () => 0);
334
+ // Separate accumulators for y1 and y2 axes
335
+ const y1_pos_acc = Array.from({ length: max_len }, () => 0);
336
+ const y1_neg_acc = Array.from({ length: max_len }, () => 0);
337
+ const y2_pos_acc = Array.from({ length: max_len }, () => 0);
338
+ const y2_neg_acc = Array.from({ length: max_len }, () => 0);
250
339
  series.forEach((srs, series_idx) => {
251
340
  if (!(srs?.visible ?? true) || srs.render_mode === `line`)
252
341
  return;
342
+ const use_y2 = srs.y_axis === `y2`;
343
+ const pos_acc = use_y2 ? y2_pos_acc : y1_pos_acc;
344
+ const neg_acc = use_y2 ? y2_neg_acc : y1_neg_acc;
253
345
  for (let bar_idx = 0; bar_idx < max_len; bar_idx++) {
254
346
  const y_val = srs.y[bar_idx] ?? 0;
255
347
  const acc = y_val >= 0 ? pos_acc : neg_acc;
@@ -292,6 +384,228 @@ let group_info = $derived.by(() => {
292
384
  tabindex="0"
293
385
  aria-label="Interactive bar plot with zoom and tooltip"
294
386
  >
387
+ <!-- Zoom rectangle -->
388
+ {#if drag_state.start && drag_state.current}
389
+ {@const x = Math.min(drag_state.start.x, drag_state.current.x)}
390
+ {@const y = Math.min(drag_state.start.y, drag_state.current.y)}
391
+ {@const rect_w = Math.abs(drag_state.start.x - drag_state.current.x)}
392
+ {@const rect_h = Math.abs(drag_state.start.y - drag_state.current.y)}
393
+ <rect class="zoom-rect" {x} {y} width={rect_w} height={rect_h} />
394
+ {/if}
395
+
396
+ <!-- User content (custom overlays, reference lines, etc.) -->
397
+ {@render user_content?.({
398
+ height,
399
+ width,
400
+ x_scale_fn: scales.x,
401
+ y_scale_fn: scales.y,
402
+ y2_scale_fn: scales.y2,
403
+ pad,
404
+ x_range: ranges.current.x,
405
+ y_range: ranges.current.y,
406
+ y2_range: ranges.current.y2,
407
+ })}
408
+
409
+ <!-- X-axis -->
410
+ <g class="x-axis">
411
+ <line
412
+ x1={pad.l}
413
+ x2={width - pad.r}
414
+ y1={height - pad.b}
415
+ y2={height - pad.b}
416
+ stroke={x_axis.color || `var(--border-color, gray)`}
417
+ stroke-width="1"
418
+ />
419
+ {#each ticks.x as tick (tick)}
420
+ {@const tick_x = scales.x(tick as number)}
421
+ {#if isFinite(tick_x)}
422
+ {@const rotation = x_axis.tick_rotation ?? 0}
423
+ {@const shift_x = x_axis.tick_label_shift?.x ?? 0}
424
+ {@const shift_y = x_axis.tick_label_shift?.y ?? 0}
425
+ {@const text_y = rotation !== 0 ? 8 + shift_y : 18 + shift_y}
426
+ {@const text_anchor = rotation !== 0 ? `start` : `middle`}
427
+ <g class="tick" transform="translate({tick_x}, {height - pad.b})">
428
+ {#if display.x_grid}
429
+ <line
430
+ y1={-(height - pad.b - pad.t)}
431
+ y2="0"
432
+ {...DEFAULT_GRID_STYLE}
433
+ {...(x_axis.grid_style ?? {})}
434
+ />
435
+ {/if}
436
+ <line
437
+ y1="0"
438
+ y2="5"
439
+ stroke={x_axis.color || `var(--border-color, gray)`}
440
+ stroke-width="1"
441
+ />
442
+ <text
443
+ x={shift_x}
444
+ y={text_y}
445
+ text-anchor={text_anchor}
446
+ fill={x_axis.color || `var(--text-color)`}
447
+ transform={rotation !== 0
448
+ ? `rotate(${rotation}, ${shift_x}, ${text_y})`
449
+ : undefined}
450
+ >
451
+ {format_value(tick, x_axis.format)}
452
+ </text>
453
+ </g>
454
+ {/if}
455
+ {/each}
456
+ {#if x_axis.label}
457
+ {@const shift_x = x_axis.label_shift?.x ?? 0}
458
+ {@const shift_y = x_axis.label_shift?.y ?? 0}
459
+ <text
460
+ x={pad.l + chart_width / 2 + shift_x}
461
+ y={height - (pad.b / 3) + shift_y}
462
+ text-anchor="middle"
463
+ fill={x_axis.color || `var(--text-color)`}
464
+ >
465
+ {@html x_axis.label}
466
+ </text>
467
+ {/if}
468
+ </g>
469
+
470
+ <!-- Y-axis -->
471
+ <g class="y-axis">
472
+ <line
473
+ x1={pad.l}
474
+ x2={pad.l}
475
+ y1={pad.t}
476
+ y2={height - pad.b}
477
+ stroke={y_axis.color || `var(--border-color, gray)`}
478
+ stroke-width="1"
479
+ />
480
+ {#each ticks.y as tick (tick)}
481
+ {@const tick_y = scales.y(tick as number)}
482
+ {#if isFinite(tick_y)}
483
+ {@const rotation = y_axis.tick_rotation ?? 0}
484
+ {@const shift_x = y_axis.tick_label_shift?.x ?? 0}
485
+ {@const shift_y = y_axis.tick_label_shift?.y ?? 0}
486
+ {@const text_x = -10 + shift_x}
487
+ <g class="tick" transform="translate({pad.l}, {tick_y})">
488
+ {#if display.y_grid}
489
+ <line
490
+ x1="0"
491
+ x2={width - pad.l - pad.r}
492
+ {...DEFAULT_GRID_STYLE}
493
+ {...(y_axis.grid_style ?? {})}
494
+ />
495
+ {/if}
496
+ <line
497
+ x1="-5"
498
+ x2="0"
499
+ stroke={y_axis.color || `var(--border-color, gray)`}
500
+ stroke-width="1"
501
+ />
502
+ <text
503
+ x={text_x}
504
+ y={shift_y}
505
+ text-anchor="end"
506
+ dominant-baseline="central"
507
+ fill={y_axis.color || `var(--text-color)`}
508
+ transform={rotation !== 0
509
+ ? `rotate(${rotation}, ${text_x}, ${shift_y})`
510
+ : undefined}
511
+ >
512
+ {format_value(tick, y_axis.format)}
513
+ </text>
514
+ </g>
515
+ {/if}
516
+ {/each}
517
+ {#if y_axis.label}
518
+ {@const max_y_tick_width = Math.max(
519
+ 0,
520
+ ...ticks.y.map((tick) =>
521
+ measure_text_width(
522
+ format_value(tick, y_axis.format),
523
+ `12px sans-serif`,
524
+ )
525
+ ),
526
+ )}
527
+ {@const shift_x = y_axis.label_shift?.x ?? 0}
528
+ {@const shift_y = y_axis.label_shift?.y ?? 0}
529
+ {@const y_label_x = Math.max(15, pad.l - max_y_tick_width - 35) + shift_x}
530
+ {@const y_label_y = pad.t + chart_height / 2 + shift_y}
531
+ <text
532
+ x={y_label_x}
533
+ y={y_label_y}
534
+ text-anchor="middle"
535
+ fill={y_axis.color || `var(--text-color)`}
536
+ transform="rotate(-90, {y_label_x}, {y_label_y})"
537
+ >
538
+ {@html y_axis.label}
539
+ </text>
540
+ {/if}
541
+ </g>
542
+
543
+ <!-- Y2-axis (Right) -->
544
+ <!-- Note: y2 axis is only supported for vertical orientation. Implementing x2 for horizontal mode requires additional complexity. -->
545
+ {#if y2_series.length > 0 && orientation === `vertical`}
546
+ <g class="y2-axis">
547
+ <line
548
+ x1={width - pad.r}
549
+ x2={width - pad.r}
550
+ y1={pad.t}
551
+ y2={height - pad.b}
552
+ stroke={y2_axis.color || `var(--border-color, gray)`}
553
+ stroke-width="1"
554
+ />
555
+ {#each ticks.y2 as tick (tick)}
556
+ {@const tick_y = scales.y2(tick as number)}
557
+ {#if isFinite(tick_y)}
558
+ {@const rotation = y2_axis.tick_rotation ?? 0}
559
+ {@const shift_x = y2_axis.tick_label_shift?.x ?? 8}
560
+ {@const shift_y = y2_axis.tick_label_shift?.y ?? 0}
561
+ <g class="tick" transform="translate({width - pad.r}, {tick_y})">
562
+ {#if display.y2_grid}
563
+ <line
564
+ x1={-(width - pad.l - pad.r)}
565
+ x2="0"
566
+ {...DEFAULT_GRID_STYLE}
567
+ {...(y2_axis.grid_style ?? {})}
568
+ />
569
+ {/if}
570
+ <line
571
+ x1="0"
572
+ x2="5"
573
+ stroke={y2_axis.color || `var(--border-color, gray)`}
574
+ stroke-width="1"
575
+ />
576
+ <text
577
+ x={shift_x}
578
+ y={shift_y}
579
+ text-anchor="start"
580
+ dominant-baseline="central"
581
+ fill={y2_axis.color || `var(--text-color)`}
582
+ transform={rotation !== 0
583
+ ? `rotate(${rotation}, ${shift_x}, ${shift_y})`
584
+ : undefined}
585
+ >
586
+ {format_value(tick, y2_axis.format)}
587
+ </text>
588
+ </g>
589
+ {/if}
590
+ {/each}
591
+ {#if y2_axis.label}
592
+ {@const shift_x = y2_axis.label_shift?.x ?? 0}
593
+ {@const shift_y = y2_axis.label_shift?.y ?? 0}
594
+ {@const y2_label_x = width - pad.r + 50 + shift_x}
595
+ {@const y2_label_y = pad.t + chart_height / 2 + shift_y}
596
+ <text
597
+ x={y2_label_x}
598
+ y={y2_label_y}
599
+ text-anchor="middle"
600
+ fill={y2_axis.color || `var(--text-color)`}
601
+ transform="rotate(-90, {y2_label_x}, {y2_label_y})"
602
+ >
603
+ {@html y2_axis.label}
604
+ </text>
605
+ {/if}
606
+ </g>
607
+ {/if}
608
+
295
609
  <!-- Define clip path for chart area -->
296
610
  <defs>
297
611
  <clipPath id={clip_path_id}>
@@ -302,33 +616,37 @@ let group_info = $derived.by(() => {
302
616
  <!-- Clipped content: zero lines, bars, and lines -->
303
617
  <g clip-path="url(#{clip_path_id})">
304
618
  <!-- Zero lines -->
305
- {#if show_zero_lines}
619
+ {#if display.x_zero_line && (x_axis.scale_type ?? `linear`) === `linear` &&
620
+ ranges.current.x[0] <= 0 && ranges.current.x[1] >= 0}
306
621
  {@const zx = scales.x(0)}
622
+ {#if isFinite(zx)}
623
+ <line class="zero-line" x1={zx} x2={zx} y1={pad.t} y2={height - pad.b} />
624
+ {/if}
625
+ {/if}
626
+ {#if display.y_zero_line && (y_axis.scale_type ?? `linear`) === `linear` &&
627
+ ranges.current.y[0] <= 0 && ranges.current.y[1] >= 0}
307
628
  {@const zy = scales.y(0)}
308
- {#if ranges.current.x[0] <= 0 && ranges.current.x[1] >= 0 && isFinite(zx)}
309
- <line
310
- x1={zx}
311
- x2={zx}
312
- y1={pad.t}
313
- y2={height - pad.b}
314
- stroke="gray"
315
- stroke-width="0.5"
316
- />
629
+ {#if isFinite(zy)}
630
+ <line class="zero-line" x1={pad.l} x2={width - pad.r} y1={zy} y2={zy} />
317
631
  {/if}
318
- {#if ranges.current.y[0] < 0 && ranges.current.y[1] > 0 && isFinite(zy)}
632
+ {/if}
633
+ {#if display.y_zero_line && y2_series.length > 0 &&
634
+ (y2_axis.scale_type ?? `linear`) === `linear` &&
635
+ ranges.current.y2[0] <= 0 && ranges.current.y2[1] >= 0}
636
+ {@const zero_y2 = scales.y2(0)}
637
+ {#if isFinite(zero_y2)}
319
638
  <line
639
+ class="zero-line"
320
640
  x1={pad.l}
321
641
  x2={width - pad.r}
322
- y1={zy}
323
- y2={zy}
324
- stroke="gray"
325
- stroke-width="0.5"
642
+ y1={zero_y2}
643
+ y2={zero_y2}
326
644
  />
327
645
  {/if}
328
646
  {/if}
329
647
 
330
648
  <!-- Bars and Lines -->
331
- {#each series as srs, series_idx (series_idx)}
649
+ {#each series as srs, series_idx (srs?.id ?? series_idx)}
332
650
  {#if srs?.visible ?? true}
333
651
  {@const is_line = srs.render_mode === `line`}
334
652
  <g
@@ -337,9 +655,11 @@ let group_info = $derived.by(() => {
337
655
  >
338
656
  {#if is_line}
339
657
  <!-- Render as line -->
340
- {@const color = srs.color ?? default_bar_color}
341
- {@const stroke_width = srs.line_style?.stroke_width ?? 2}
658
+ {@const color = srs.color ?? line.color ?? `steelblue`}
659
+ {@const stroke_width = srs.line_style?.stroke_width ?? line.width ?? 2}
342
660
  {@const line_dash = srs.line_style?.line_dash ?? `none`}
661
+ {@const use_y2 = srs.y_axis === `y2`}
662
+ {@const y_scale = use_y2 ? scales.y2 : scales.y}
343
663
  {@const points = srs.x.map((x_val, idx) => {
344
664
  const y_val = srs.y[idx]
345
665
  // Lines don't stack - they show absolute values (useful for totals/trends)
@@ -347,7 +667,7 @@ let group_info = $derived.by(() => {
347
667
  ? scales.x(x_val)
348
668
  : scales.x(y_val)
349
669
  const plot_y = orientation === `vertical`
350
- ? scales.y(y_val)
670
+ ? y_scale(y_val)
351
671
  : scales.y(x_val)
352
672
  return { x: plot_x, y: plot_y, idx }
353
673
  }).filter((p) => isFinite(p.x) && isFinite(p.y))}
@@ -423,23 +743,28 @@ let group_info = $derived.by(() => {
423
743
  {@const base = mode === `stacked`
424
744
  ? (stacked_offsets[series_idx]?.[bar_idx] ?? 0)
425
745
  : 0}
426
- {@const color = srs.color ?? default_bar_color}
746
+ {@const color = srs.color ?? bar.color ?? `steelblue`}
427
747
  {@const bar_width_val = Array.isArray(srs.bar_width)
428
748
  ? (srs.bar_width[bar_idx] ?? 0.5)
429
749
  : (srs.bar_width ?? 0.5)}
430
750
  {@const half = mode === `grouped` && group_info.bar_series_count > 1
431
751
  ? bar_width_val / (2 * group_info.bar_series_count)
432
752
  : bar_width_val / 2}
753
+ {@const calculate_group_offset = (idx: number) => {
754
+ const position = group_info.bar_series_indices.indexOf(idx)
755
+ const offset = position - (group_info.bar_series_count - 1) / 2
756
+ return offset * (bar_width_val / group_info.bar_series_count)
757
+ }}
433
758
  {@const group_offset = mode === `grouped` && group_info.bar_series_count > 1
434
- ? ((pos = group_info.bar_series_indices.indexOf(series_idx)) =>
435
- (pos - (group_info.bar_series_count - 1) / 2) *
436
- (bar_width_val / group_info.bar_series_count))()
759
+ ? calculate_group_offset(series_idx)
437
760
  : 0}
438
761
  {@const is_vertical = orientation === `vertical`}
439
762
  {@const cat_val = x_val}
440
763
  {@const val = y_val}
764
+ {@const use_y2 = srs.y_axis === `y2`}
765
+ {@const y_scale = use_y2 ? scales.y2 : scales.y}
441
766
  {@const [cat_scale, val_scale] = is_vertical
442
- ? [scales.x, scales.y]
767
+ ? [scales.x, y_scale]
443
768
  : [scales.y, scales.x]}
444
769
  {@const c0 = cat_scale(cat_val + group_offset - half)}
445
770
  {@const c1 = cat_scale(cat_val + group_offset + half)}
@@ -458,7 +783,7 @@ let group_info = $derived.by(() => {
458
783
  width={rect_w}
459
784
  height={rect_h}
460
785
  fill={color}
461
- opacity={mode === `overlay` ? 0.6 : 1}
786
+ opacity={mode === `overlay` ? bar.opacity : 1}
462
787
  role="button"
463
788
  tabindex="0"
464
789
  aria-label={`bar ${bar_idx + 1} of ${srs.label ?? `series`}`}
@@ -502,110 +827,6 @@ let group_info = $derived.by(() => {
502
827
  {/if}
503
828
  {/each}
504
829
  </g>
505
-
506
- <!-- Zoom rectangle -->
507
- {#if drag_state.start && drag_state.current}
508
- {@const x = Math.min(drag_state.start.x, drag_state.current.x)}
509
- {@const y = Math.min(drag_state.start.y, drag_state.current.y)}
510
- {@const rect_w = Math.abs(drag_state.start.x - drag_state.current.x)}
511
- {@const rect_h = Math.abs(drag_state.start.y - drag_state.current.y)}
512
- <rect class="zoom-rect" {x} {y} width={rect_w} height={rect_h} />
513
- {/if}
514
-
515
- <!-- X-axis -->
516
- <g class="x-axis">
517
- <line
518
- x1={pad.l}
519
- x2={width - pad.r}
520
- y1={height - pad.b}
521
- y2={height - pad.b}
522
- stroke="var(--border-color, gray)"
523
- stroke-width="1"
524
- />
525
- {#each ticks.x as tick (tick)}
526
- {@const tick_x = scales.x(tick as number)}
527
- {#if isFinite(tick_x)}
528
- <g class="tick" transform="translate({tick_x}, {height - pad.b})">
529
- {#if x_grid}
530
- <line
531
- y1={-(height - pad.b - pad.t)}
532
- y2="0"
533
- {...typeof x_grid === `object` ? x_grid : {}}
534
- />
535
- {/if}
536
- <line y1="0" y2="5" stroke="var(--border-color, gray)" stroke-width="1" />
537
- <text y="18" text-anchor="middle" fill="var(--text-color)">
538
- {format_value(tick, x_format)}
539
- </text>
540
- </g>
541
- {/if}
542
- {/each}
543
- {#if x_label}
544
- <text
545
- x={pad.l + chart_width / 2 + (x_label_shift.x ?? 0)}
546
- y={height - (pad.b / 3) + (x_label_shift.y ?? 0)}
547
- text-anchor="middle"
548
- fill="var(--text-color)"
549
- >
550
- {x_label}
551
- </text>
552
- {/if}
553
- </g>
554
-
555
- <!-- Y-axis -->
556
- <g class="y-axis">
557
- <line
558
- x1={pad.l}
559
- x2={pad.l}
560
- y1={pad.t}
561
- y2={height - pad.b}
562
- stroke="var(--border-color, gray)"
563
- stroke-width="1"
564
- />
565
- {#each ticks.y as tick (tick)}
566
- {@const tick_y = scales.y(tick as number)}
567
- {#if isFinite(tick_y)}
568
- <g class="tick" transform="translate({pad.l}, {tick_y})">
569
- {#if y_grid}
570
- <line
571
- x1="0"
572
- x2={width - pad.l - pad.r}
573
- {...typeof y_grid === `object` ? y_grid : {}}
574
- />
575
- {/if}
576
- <line x1="-5" x2="0" stroke="var(--border-color, gray)" stroke-width="1" />
577
- <text
578
- x="-10"
579
- text-anchor="end"
580
- dominant-baseline="central"
581
- fill="var(--text-color)"
582
- >
583
- {format_value(tick, y_format)}
584
- </text>
585
- </g>
586
- {/if}
587
- {/each}
588
- {#if y_label}
589
- {@const max_y_tick_width = Math.max(
590
- 0,
591
- ...ticks.y.map((tick) =>
592
- measure_text_width(format_value(tick, y_format), `12px sans-serif`)
593
- ),
594
- )}
595
- {@const y_label_x = Math.max(15, pad.l - max_y_tick_width - 35) +
596
- (y_label_shift.x ?? 0)}
597
- {@const y_label_y = pad.t + chart_height / 2 + (y_label_shift.y ?? 0)}
598
- <text
599
- x={y_label_x}
600
- y={y_label_y}
601
- text-anchor="middle"
602
- fill="var(--text-color)"
603
- transform="rotate(-90, {y_label_x}, {y_label_y})"
604
- >
605
- {y_label}
606
- </text>
607
- {/if}
608
- </g>
609
830
  </svg>
610
831
 
611
832
  <!-- Legend -->
@@ -619,8 +840,11 @@ let group_info = $derived.by(() => {
619
840
  {/if}
620
841
 
621
842
  {#if hover_info && hovered}
843
+ {@const srs = series[hover_info.series_idx]}
844
+ {@const use_y2 = srs?.y_axis === `y2`}
845
+ {@const active_y_axis = use_y2 ? y2_axis : y_axis}
622
846
  {@const cx = scales.x(hover_info.orient_x)}
623
- {@const cy = scales.y(hover_info.orient_y)}
847
+ {@const cy = (use_y2 ? scales.y2 : scales.y)(hover_info.orient_y)}
624
848
  <div
625
849
  class="tooltip overlay"
626
850
  style={`position: absolute; left: ${cx + 6}px; top: ${cy}px; pointer-events: none;`}
@@ -628,8 +852,16 @@ let group_info = $derived.by(() => {
628
852
  {#if tooltip}
629
853
  {@render tooltip(hover_info)}
630
854
  {:else}
631
- <div>{x_label || `x`}: {format_value(hover_info.orient_x, x_format)}</div>
632
- <div>{y_label || `y`}: {format_value(hover_info.orient_y, y_format)}</div>
855
+ <div>
856
+ {x_axis.label || `x`}: {
857
+ format_value(hover_info.orient_x, x_axis.format || `.3~s`)
858
+ }
859
+ </div>
860
+ <div>
861
+ {active_y_axis.label || `y`}: {
862
+ format_value(hover_info.orient_y, active_y_axis.format || `.3~s`)
863
+ }
864
+ </div>
633
865
  {/if}
634
866
  </div>
635
867
  {/if}
@@ -637,21 +869,18 @@ let group_info = $derived.by(() => {
637
869
  {#if show_controls}
638
870
  <BarPlotControls
639
871
  toggle_props={controls_toggle_props}
872
+ pane_props={controls_pane_props}
640
873
  bind:show_controls
641
874
  bind:controls_open
642
875
  bind:orientation
643
876
  bind:mode
644
- bind:x_grid
645
- bind:y_grid
646
- bind:x_ticks
647
- bind:y_ticks
648
- bind:x_format
649
- bind:y_format
650
- bind:x_range
651
- bind:y_range
877
+ bind:x_axis
878
+ bind:y_axis
879
+ bind:y2_axis
880
+ bind:display
652
881
  auto_x_range={auto_ranges.x as [number, number]}
653
882
  auto_y_range={auto_ranges.y as [number, number]}
654
- {plot_controls}
883
+ auto_y2_range={auto_ranges.y2 as [number, number]}
655
884
  />
656
885
  {/if}
657
886
  {/if}
@@ -679,7 +908,7 @@ let group_info = $derived.by(() => {
679
908
  height: 100%;
680
909
  overflow: var(--svg-overflow, visible);
681
910
  }
682
- g:is(.x-axis, .y-axis) .tick text {
911
+ g:is(.x-axis, .y-axis, .y2-axis) .tick text {
683
912
  font-size: var(--tick-font-size, 0.8em);
684
913
  }
685
914
  .zoom-rect {
@@ -700,4 +929,9 @@ let group_info = $derived.by(() => {
700
929
  fill: var(--text-color);
701
930
  font-size: 11px;
702
931
  }
932
+ .zero-line {
933
+ stroke: var(--barplot-zero-line-color, light-dark(black, white));
934
+ stroke-width: var(--barplot-zero-line-width, 1);
935
+ opacity: var(--barplot-zero-line-opacity, 0.3);
936
+ }
703
937
  </style>