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,85 +1,82 @@
1
- <script lang="ts">import { DraggablePane, Line, symbol_names } from '..';
2
- import { luminance } from '../colors';
1
+ <script lang="ts">import { luminance } from '../colors';
2
+ import { format_value, symbol_names } from '../labels';
3
3
  import * as math from '../math';
4
- import { ColorBar, find_best_plot_area, PlotLegend, ScatterPlotControls, ScatterPoint, } from './';
4
+ import { ColorBar, DEFAULT_GRID_STYLE, DEFAULT_MARKERS, find_best_plot_area, get_tick_label, Line, PlotLegend, ScatterPlotControls, ScatterPoint, } from './';
5
5
  import { DEFAULTS } from '../settings';
6
6
  import { extent } from 'd3-array';
7
7
  import { forceCollide, forceLink, forceSimulation } from 'd3-force';
8
8
  import { scaleLinear, scaleLog, scaleSequential, scaleSequentialLog, scaleTime, } from 'd3-scale';
9
9
  import * as d3_sc from 'd3-scale-chromatic';
10
10
  import { Tween } from 'svelte/motion';
11
- import { format_value } from './formatting';
11
+ import { SvelteSet } from 'svelte/reactivity';
12
12
  import { get_relative_coords } from './interactions';
13
13
  import { calc_auto_padding } from './layout';
14
14
  import { generate_ticks, get_nice_data_range } from './scales';
15
- let { series = [], x_lim = [null, null], y_lim = [null, null], x_range, y_range, current_x_value = null, y2_lim = [null, null], y2_range, y2_label = ``, y2_label_shift = { y: 60 }, y2_tick_label_shift = { x: 8, y: 0 }, y2_unit = ``, y2_format = $bindable(``), y2_ticks = 5, y2_scale_type = `linear`, y2_grid = true, padding = {}, range_padding = 0.05, // Default padding factor
16
- x_label = ``, x_label_shift = { x: 0, y: -40 }, x_tick_label_shift = { x: 0, y: 20 }, y_label = ``, y_label_shift = { y: 12 }, y_tick_label_shift = { x: -8, y: 0 }, y_unit = ``, tooltip_point = $bindable(null), hovered = $bindable(false), markers = DEFAULTS.scatter.markers, x_format = $bindable(``), y_format = $bindable(``), tooltip, user_content, change = () => { }, x_ticks, y_ticks = 5, x_scale_type = `linear`, y_scale_type = `linear`, show_zero_lines = true, x_grid = true, y_grid = true, color_scale = {
15
+ let { series = [], x_axis = $bindable({}), y_axis = $bindable({}), y2_axis = $bindable({}), display = $bindable(DEFAULTS.scatter.display), styles = $bindable({}), controls = $bindable({}), padding = {}, range_padding = 0.05, current_x_value = null, tooltip_point = $bindable(null), hovered = $bindable(false), tooltip, user_content, change = () => { }, color_scale = {
17
16
  type: `linear`,
18
17
  scheme: `interpolateViridis`,
19
18
  value_range: undefined,
20
- }, color_bar = {}, size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, label_placement_config = {}, hover_config = {}, legend = {}, point_tween, line_tween, point_events, on_point_click, on_point_hover, show_controls = true, controls_open = $bindable(false), plot_controls,
21
- // Style control props
22
- point_size = $bindable(4), point_color = $bindable(`cornflowerblue`), point_opacity = $bindable(1), point_stroke_width = $bindable(1), point_stroke_color = $bindable(`#000000`), point_stroke_opacity = $bindable(1), line_width = $bindable(2), line_color = $bindable(`cornflowerblue`), line_opacity = $bindable(1), line_dash = $bindable(DEFAULTS.scatter.line_dash), show_points = $bindable(true), show_lines = $bindable(true), selected_series_idx = $bindable(0), color_axis_labels = true, controls_toggle_props, children, ...rest } = $props();
19
+ }, color_bar = {}, size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, label_placement_config = {}, hover_config = {}, legend = {}, point_tween, line_tween, point_events, on_point_click, on_point_hover, selected_series_idx = $bindable(0), children, ...rest } = $props();
20
+ // Initialize style overrides with defaults (runs once to avoid infinite loop)
21
+ styles.point = { ...DEFAULTS.scatter.point, ...styles.point };
22
+ styles.line = { ...DEFAULTS.scatter.line, ...styles.line };
23
+ // Initialize default values
24
+ x_axis = {
25
+ format: ``,
26
+ scale_type: `linear`,
27
+ label_shift: { x: 0, y: -40 },
28
+ tick_label_shift: { x: 0, y: 20 },
29
+ range: [null, null],
30
+ ...x_axis,
31
+ };
32
+ y_axis = {
33
+ format: ``,
34
+ scale_type: `linear`,
35
+ label_shift: { x: 0, y: 0 },
36
+ tick_label_shift: { x: -8, y: 0 },
37
+ range: [null, null],
38
+ ...y_axis,
39
+ };
40
+ y2_axis = {
41
+ format: ``,
42
+ scale_type: `linear`,
43
+ ticks: 5,
44
+ label_shift: { x: 0, y: 0 },
45
+ tick_label_shift: { x: 8, y: 0 },
46
+ range: [null, null],
47
+ ...y2_axis,
48
+ };
49
+ display = { ...DEFAULTS.scatter.display, ...display };
50
+ styles = {
51
+ show_points: DEFAULTS.scatter.show_points,
52
+ show_lines: DEFAULTS.scatter.show_lines,
53
+ ...styles,
54
+ };
55
+ controls = { show: true, open: false, ...controls };
23
56
  let [width, height] = $state([0, 0]);
24
57
  let svg_element = $state(null); // Bind the SVG element
25
58
  let svg_bounding_box = $state(null); // Store SVG bounds during drag
26
59
  // Unique component ID to avoid clipPath conflicts between multiple instances
27
60
  let component_id = $state(`scatter-${crypto.randomUUID()}`);
28
61
  let clip_path_id = $derived(`plot-area-clip-${component_id}`);
29
- // Process series to ensure single visible series are always on y1 (left) axis.
30
- // This prevents the scenario where the left y-axis is empty while the right y-axis
31
- // has the only visible series, which would create a confusing plot layout.
32
- let processed_series = $derived.by(() => {
33
- if (series.length === 0)
34
- return [];
35
- // Count visible series (filter out null/undefined series)
36
- const visible_series = series.filter((s) => s && (s.visible ?? true));
37
- // If only one series is visible, ensure it's on y1 axis
38
- if (visible_series.length === 1) {
39
- return series.map((s) => {
40
- if (s && (s.visible ?? true) && s.y_axis === `y2`) {
41
- // Reassign single visible series from y2 to y1
42
- return { ...s, y_axis: `y1` };
43
- }
44
- return s;
45
- });
46
- }
47
- // For multiple visible series, keep original assignments
48
- return series;
49
- });
50
- // Stable ID assignment for series - computed once and cached
51
- let next_id = 0;
52
- const series_id_cache = new WeakMap();
53
- let series_with_ids = $derived.by(() => {
54
- return processed_series.map((s) => {
55
- if (!s || typeof s !== `object`)
56
- return s;
57
- if (`_id` in s && typeof s._id === `number`)
58
- return s; // Already has stable ID
59
- // Check cache first
60
- if (series_id_cache.has(s)) {
61
- return { ...s, _id: series_id_cache.get(s) };
62
- }
63
- // Assign and cache new stable ID
64
- const new_id = next_id++;
65
- series_id_cache.set(s, new_id);
66
- return { ...s, _id: new_id };
67
- });
68
- });
62
+ // Assign stable IDs to series for keying
63
+ let series_with_ids = $derived(series.map((s, idx) => {
64
+ if (!s || typeof s !== `object`)
65
+ return s;
66
+ // Use series.id if provided, otherwise fall back to index
67
+ // prevents re-mounts when series are reordered if stable IDs are provided
68
+ return { ...s, _id: s.id ?? idx };
69
+ }));
69
70
  // State for rectangle zoom selection
70
71
  let drag_start_coords = $state(null);
71
72
  let drag_current_coords = $state(null);
72
- let initial_x_range = $state([0, 1]);
73
- let initial_y_range = $state([0, 1]);
74
- let initial_y2_range = $state([0, 1]);
75
- let current_x_range = $state([0, 1]);
76
- let current_y_range = $state([0, 1]);
77
- let current_y2_range = $state([0, 1]);
78
- let previous_series_visibility = $state(null); // State to store visibility before isolation
73
+ // Zoom/pan state - single range state used for both initial and current
74
+ let zoom_x_range = $state([0, 1]);
75
+ let zoom_y_range = $state([0, 1]);
76
+ let zoom_y2_range = $state([0, 1]);
77
+ let previous_series_visibility = $state(null);
79
78
  // State to hold the calculated label positions after simulation
80
79
  let label_positions = $state({});
81
- // State for initial (non-responsive) legend placement
82
- let initial_legend_placement = $state(null);
83
80
  // State for legend dragging
84
81
  let legend_is_dragging = $state(false);
85
82
  let legend_drag_offset = $state({ x: 0, y: 0 });
@@ -110,16 +107,14 @@ const default_padding = { t: 5, b: 50, l: 50, r: 20 };
110
107
  let pad = $state({ ...default_padding, ...padding });
111
108
  // Update padding when format or ticks change, but prevent infinite loop
112
109
  $effect(() => {
113
- const base_pad = { ...default_padding, ...padding };
114
110
  const new_pad = width && height && (y_tick_values.length || y2_tick_values.length)
115
111
  ? calc_auto_padding({
116
- base_padding: base_pad,
117
- y_ticks: y_tick_values,
118
- y_format,
119
- y2_ticks: y2_tick_values,
120
- y2_format,
112
+ padding,
113
+ default_padding,
114
+ y_axis: { ...y_axis, tick_values: y_tick_values },
115
+ y2_axis: { ...y2_axis, tick_values: y2_tick_values },
121
116
  })
122
- : base_pad;
117
+ : { ...default_padding, ...padding };
123
118
  // Only update if padding actually changed (prevents infinite loop)
124
119
  if (pad.t !== new_pad.t || pad.b !== new_pad.b || pad.l !== new_pad.l ||
125
120
  pad.r !== new_pad.r)
@@ -138,36 +133,36 @@ let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2);
138
133
  // Compute data color values for color scaling
139
134
  let all_color_values = $derived(series_with_ids.filter(Boolean).flatMap((srs) => srs.color_values?.filter(Boolean) || []));
140
135
  // Compute auto ranges based on data and limits
141
- let auto_x_range = $derived(get_nice_data_range(all_points, (point) => point.x, x_lim, x_scale_type, range_padding, x_format?.startsWith(`%`) || false));
142
- let auto_y_range = $derived(get_nice_data_range(y1_points, (point) => point.y, y_lim, y_scale_type, range_padding, false));
143
- let auto_y2_range = $derived(get_nice_data_range(y2_points, (point) => point.y, y2_lim, y2_scale_type, range_padding, false));
144
- // Store initial ranges and initialize current ranges
136
+ let auto_x_range = $derived(get_nice_data_range(all_points, (point) => point.x, (x_axis.range ?? [null, null]), x_axis.scale_type, range_padding, x_axis.format?.startsWith(`%`) || false));
137
+ let auto_y_range = $derived(get_nice_data_range(y1_points, (point) => point.y, (y_axis.range ?? [null, null]), y_axis.scale_type, range_padding, false));
138
+ let auto_y2_range = $derived(get_nice_data_range(y2_points, (point) => point.y, (y2_axis.range ?? [null, null]), y2_axis.scale_type, range_padding, false));
139
+ // Update zoom ranges when auto ranges or explicit ranges change
145
140
  $effect(() => {
146
- const new_init_x = [
147
- x_range?.[0] ?? auto_x_range[0],
148
- x_range?.[1] ?? auto_x_range[1],
141
+ const new_x = [
142
+ x_axis.range?.[0] ?? auto_x_range[0],
143
+ x_axis.range?.[1] ?? auto_x_range[1],
149
144
  ];
150
- const new_init_y = [
151
- y_range?.[0] ?? auto_y_range[0],
152
- y_range?.[1] ?? auto_y_range[1],
145
+ const new_y = [
146
+ y_axis.range?.[0] ?? auto_y_range[0],
147
+ y_axis.range?.[1] ?? auto_y_range[1],
153
148
  ];
154
- const new_init_y2 = [
155
- y2_range?.[0] ?? auto_y2_range[0],
156
- y2_range?.[1] ?? auto_y2_range[1],
149
+ const new_y2 = [
150
+ y2_axis.range?.[0] ?? auto_y2_range[0],
151
+ y2_axis.range?.[1] ?? auto_y2_range[1],
157
152
  ];
158
- // Only update if the initial range fundamentally changes
159
- if (new_init_x[0] !== initial_x_range[0] || new_init_x[1] !== initial_x_range[1])
160
- [initial_x_range, current_x_range] = [new_init_x, new_init_x];
161
- if (new_init_y[0] !== initial_y_range[0] || new_init_y[1] !== initial_y_range[1])
162
- [initial_y_range, current_y_range] = [new_init_y, new_init_y];
163
- if (new_init_y2[0] !== initial_y2_range[0] || new_init_y2[1] !== initial_y2_range[1]) {
164
- initial_y2_range = new_init_y2;
165
- current_y2_range = new_init_y2;
153
+ if (new_x[0] !== zoom_x_range[0] || new_x[1] !== zoom_x_range[1]) {
154
+ zoom_x_range = new_x;
155
+ }
156
+ if (new_y[0] !== zoom_y_range[0] || new_y[1] !== zoom_y_range[1]) {
157
+ zoom_y_range = new_y;
158
+ }
159
+ if (new_y2[0] !== zoom_y2_range[0] || new_y2[1] !== zoom_y2_range[1]) {
160
+ zoom_y2_range = new_y2;
166
161
  }
167
162
  });
168
- let [x_min, x_max] = $derived(current_x_range); // Use current range for scales/axes
169
- let [y_min, y_max] = $derived(current_y_range); // Use current range for scales/axes
170
- let [y2_min, y2_max] = $derived(current_y2_range); // Use current range for scales/axes
163
+ let [x_min, x_max] = $derived(zoom_x_range);
164
+ let [y_min, y_max] = $derived(zoom_y_range);
165
+ let [y2_min, y2_max] = $derived(zoom_y2_range);
171
166
  // Create auto color range
172
167
  let auto_color_range = $derived(
173
168
  // Ensure we only calculate extent on actual numbers, filtering out nulls/undefined
@@ -175,25 +170,25 @@ all_color_values.length > 0
175
170
  ? extent(all_color_values.filter((val) => typeof val === `number`))
176
171
  : [0, 1]);
177
172
  // Create scale functions
178
- let x_scale_fn = $derived(x_format?.startsWith(`%`)
173
+ let x_scale_fn = $derived(x_axis.format?.startsWith(`%`)
179
174
  ? scaleTime()
180
175
  .domain([new Date(x_min), new Date(x_max)])
181
176
  .range([pad.l, width - pad.r])
182
- : x_scale_type === `log`
177
+ : x_axis.scale_type === `log`
183
178
  ? scaleLog()
184
179
  .domain([x_min, x_max])
185
180
  .range([pad.l, width - pad.r])
186
181
  : scaleLinear()
187
182
  .domain([x_min, x_max])
188
183
  .range([pad.l, width - pad.r]));
189
- let y_scale_fn = $derived(y_scale_type === `log`
184
+ let y_scale_fn = $derived(y_axis.scale_type === `log`
190
185
  ? scaleLog()
191
186
  .domain([y_min, y_max])
192
187
  .range([height - pad.b, pad.t])
193
188
  : scaleLinear()
194
189
  .domain([y_min, y_max])
195
190
  .range([height - pad.b, pad.t]));
196
- let y2_scale_fn = $derived(y2_scale_type === `log`
191
+ let y2_scale_fn = $derived(y2_axis.scale_type === `log`
197
192
  ? scaleLog()
198
193
  .domain([y2_min, y2_max])
199
194
  .range([height - pad.b, pad.t])
@@ -231,13 +226,16 @@ let size_scale_fn = $derived.by(() => {
231
226
  });
232
227
  // Color scale function
233
228
  let color_scale_fn = $derived.by(() => {
234
- const color_func_name = color_scale.scheme;
229
+ const color_func_name = (typeof color_scale === `string`
230
+ ? color_scale
231
+ : color_scale.scheme);
235
232
  const interpolator = typeof d3_sc[color_func_name] === `function`
236
233
  ? d3_sc[color_func_name]
237
234
  : d3_sc.interpolateViridis;
238
- const [min_val, max_val] = color_scale.value_range ??
235
+ const [min_val, max_val] = (typeof color_scale === `string` ? undefined : color_scale.value_range) ??
239
236
  auto_color_range;
240
- return color_scale.type === `log`
237
+ const scale_type = typeof color_scale === `string` ? undefined : color_scale.type;
238
+ return scale_type === `log`
241
239
  ? scaleSequentialLog(interpolator).domain([
242
240
  Math.max(min_val, math.LOG_EPS),
243
241
  Math.max(max_val, min_val * 1.1),
@@ -248,21 +246,10 @@ let color_scale_fn = $derived.by(() => {
248
246
  let filtered_series = $derived(series_with_ids
249
247
  .map((data_series, series_idx) => {
250
248
  if (!(data_series?.visible ?? true)) {
251
- return {
252
- ...data_series,
253
- visible: false,
254
- filtered_data: [],
255
- };
249
+ return { ...data_series, visible: false, filtered_data: [] };
256
250
  }
257
- if (!data_series) {
258
- // Return empty data consistent with DataSeries structure
259
- return {
260
- x: [],
261
- y: [],
262
- visible: true, // Assume visible if undefined but we somehow process it
263
- filtered_data: [],
264
- _id: next_id++,
265
- };
251
+ if (!data_series) { // Return empty data consistent with DataSeries structure
252
+ return { x: [], y: [], visible: true, filtered_data: [], _id: series_idx };
266
253
  }
267
254
  const { x: xs, y: ys, color_values, size_values, ...rest } = data_series;
268
255
  // Process points internally, adding properties beyond the base Point type
@@ -309,43 +296,6 @@ let filtered_series = $derived(series_with_ids
309
296
  })
310
297
  // Filter series end up completely empty after point filtering
311
298
  .filter((series_data) => series_data.filtered_data.length > 0));
312
- // Determine axis colors based on visible series
313
- let axis_colors = $derived.by(() => {
314
- // Handle explicit color overrides
315
- if (typeof color_axis_labels === `object`) {
316
- return { y1: color_axis_labels.y1 ?? null, y2: color_axis_labels.y2 ?? null };
317
- }
318
- // Check if axis coloring is disabled
319
- if (!color_axis_labels)
320
- return { y1: null, y2: null };
321
- const visible_series = filtered_series.filter((s) => s.visible !== false);
322
- // Only apply axis colors if not using a color scale and both y axes are populated
323
- const is_using_color_scale = all_color_values.length > 0;
324
- const both_axes_populated = y1_points.length > 0 && y2_points.length > 0;
325
- if (is_using_color_scale || !both_axes_populated)
326
- return { y1: null, y2: null };
327
- // Count series by axis and get their colors
328
- const y1_series = visible_series.filter((s) => (s.y_axis ?? `y1`) === `y1`);
329
- const y2_series = visible_series.filter((s) => s.y_axis === `y2`);
330
- // Helper to get series color
331
- const get_series_color = (series) => {
332
- // Check line color first, then point color
333
- if (series.line_style?.stroke)
334
- return series.line_style.stroke;
335
- const first_point_style = Array.isArray(series.point_style)
336
- ? series.point_style[0]
337
- : series.point_style;
338
- if (first_point_style?.fill)
339
- return first_point_style.fill;
340
- if (first_point_style?.stroke)
341
- return first_point_style.stroke;
342
- return null; // No color found
343
- };
344
- return {
345
- y1: y1_series.length === 1 ? get_series_color(y1_series[0]) : null,
346
- y2: y2_series.length >= 1 ? get_series_color(y2_series[0]) : null,
347
- };
348
- });
349
299
  // Collect all plot points for legend placement calculation
350
300
  let plot_points_for_placement = $derived.by(() => {
351
301
  if (!width || !height || !filtered_series)
@@ -355,7 +305,7 @@ let plot_points_for_placement = $derived.by(() => {
355
305
  if (!series_data?.filtered_data)
356
306
  continue;
357
307
  for (const point of series_data.filtered_data) {
358
- const point_x_coord = x_format?.startsWith(`%`)
308
+ const point_x_coord = x_axis.format?.startsWith(`%`)
359
309
  ? x_scale_fn(new Date(point.x))
360
310
  : x_scale_fn(point.x);
361
311
  const point_y_coord = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
@@ -368,23 +318,25 @@ let plot_points_for_placement = $derived.by(() => {
368
318
  });
369
319
  // Prepare data needed for the legend component
370
320
  let legend_data = $derived.by(() => {
371
- return series_with_ids.map((data_series, series_idx) => {
321
+ const items = series_with_ids.map((data_series, series_idx) => {
372
322
  const is_visible = data_series?.visible ?? true;
373
- // Prefer top-level label, fallback to metadata label, then default
374
- const label = data_series?.label ??
323
+ // Prefer top-level label, fallback to metadata label
324
+ const explicit_label = data_series?.label ??
375
325
  (typeof data_series?.metadata === `object` &&
376
326
  data_series.metadata !== null &&
377
327
  `label` in data_series.metadata &&
378
328
  typeof data_series.metadata.label === `string`
379
329
  ? data_series.metadata.label
380
- : null) ??
381
- `Series ${series_idx + 1}`;
330
+ : null);
331
+ // Use explicit label or generate default
332
+ const label = explicit_label ?? `Series ${series_idx + 1}`;
333
+ const has_explicit_label = explicit_label != null;
382
334
  const display_style = {
383
335
  symbol_type: DEFAULTS.scatter.symbol_type,
384
336
  symbol_color: `black`, // Default marker color
385
337
  line_color: `black`, // Default line color
386
338
  };
387
- const series_markers = (data_series?.markers ?? markers) ?? ``;
339
+ const series_markers = data_series?.markers ?? DEFAULT_MARKERS;
388
340
  // Check point_style (could be object or array)
389
341
  const first_point_style = Array.isArray(data_series?.point_style)
390
342
  ? data_series.point_style[0] // Handle potential undefined
@@ -394,10 +346,9 @@ let legend_data = $derived.by(() => {
394
346
  // Assign shape only if it's one of the allowed types, else default to DEFAULTS.scatter.symbol_type
395
347
  let final_shape = DEFAULTS.scatter.symbol_type;
396
348
  if (Array.isArray(symbol_names) &&
397
- typeof first_point_style.shape === `string` &&
398
- symbol_names.includes(first_point_style.shape)) {
399
- final_shape = first_point_style.shape;
400
- }
349
+ typeof first_point_style.symbol_type === `string` &&
350
+ symbol_names.includes(first_point_style.symbol_type))
351
+ final_shape = first_point_style.symbol_type;
401
352
  display_style.symbol_type = final_shape;
402
353
  display_style.symbol_color = first_point_style.fill ??
403
354
  display_style.symbol_color; // Use default if nullish
@@ -406,9 +357,8 @@ let legend_data = $derived.by(() => {
406
357
  if (!display_style.symbol_color ||
407
358
  display_style.symbol_color === `none` ||
408
359
  display_style.symbol_color.startsWith(`rgba(`, 0) // Check if transparent
409
- ) {
360
+ )
410
361
  display_style.symbol_color = first_point_style.stroke;
411
- }
412
362
  }
413
363
  }
414
364
  // else: keep default display_style.symbol_type/color if no point_style
@@ -422,10 +372,17 @@ let legend_data = $derived.by(() => {
422
372
  if (series_markers?.includes(`line`)) {
423
373
  // Prefer explicit line stroke
424
374
  let legend_line_color = data_series?.line_style?.stroke;
425
- if (!legend_line_color) { // If no explicit stroke, inherit a reasonable point color even when points aren't shown
426
- // Order of preference: point fill -> point stroke -> symbol_color -> black
427
- legend_line_color = first_point_style?.fill || first_point_style?.stroke ||
428
- display_style.symbol_color || `black`;
375
+ if (!legend_line_color) {
376
+ // Try color scale if available
377
+ const first_cv = Array.isArray(data_series?.color_values)
378
+ ? data_series.color_values.find((v) => v != null)
379
+ : undefined;
380
+ legend_line_color =
381
+ (first_cv != null ? color_scale_fn(first_cv) : undefined) ||
382
+ first_point_style?.fill ||
383
+ first_point_style?.stroke ||
384
+ display_style.symbol_color ||
385
+ `black`;
429
386
  }
430
387
  display_style.line_color = legend_line_color;
431
388
  display_style.line_dash = data_series?.line_style?.line_dash;
@@ -440,8 +397,17 @@ let legend_data = $derived.by(() => {
440
397
  label,
441
398
  visible: is_visible,
442
399
  display_style,
400
+ has_explicit_label,
443
401
  };
444
402
  });
403
+ // Deduplicate by label - keep first occurrence of each unique label
404
+ const seen_labels = new SvelteSet();
405
+ return items.filter((item) => {
406
+ if (seen_labels.has(item.label))
407
+ return false;
408
+ seen_labels.add(item.label);
409
+ return true;
410
+ });
445
411
  });
446
412
  // Calculate best legend placement using new simple system
447
413
  let legend_placement = $derived.by(() => {
@@ -479,19 +445,15 @@ let color_bar_placement = $derived.by(() => {
479
445
  legend_size: estimated_size,
480
446
  });
481
447
  });
482
- // Use responsive or fixed initial placement
448
+ // Active legend placement (null if user set explicit position)
483
449
  let active_legend_placement = $derived.by(() => {
484
450
  if (!legend_placement)
485
451
  return null;
486
- // Skip auto-placement if user set explicit position
452
+ // Skip auto-placement if user set explicit position in style
487
453
  const style = legend?.wrapper_style ?? ``;
488
454
  if (/(^|[;{]\s*)(top|bottom|left|right)\s*:|position\s*:\s*absolute/.test(style))
489
455
  return null;
490
- // Responsive mode: always use current best placement
491
- if (legend?.responsive)
492
- return legend_placement;
493
- // Fixed mode: use initial placement (set in effect below)
494
- return initial_legend_placement ?? legend_placement;
456
+ return legend_placement;
495
457
  });
496
458
  // Initialize tweened values for color bar position
497
459
  const tweened_colorbar_coords = new Tween({ x: 0, y: 0 }, {
@@ -504,18 +466,12 @@ const tweened_legend_coords = new Tween({ x: 0, y: 0 }, { duration: 400, ...(leg
504
466
  $effect(() => {
505
467
  if (!width || !height)
506
468
  return;
507
- // Store initial placement for non-responsive mode
508
- if (legend_placement && !initial_legend_placement && !legend?.responsive) {
509
- initial_legend_placement = legend_placement;
510
- }
511
- // Update color bar
512
469
  if (color_bar_placement) {
513
470
  tweened_colorbar_coords.set({
514
471
  x: color_bar_placement.x,
515
472
  y: color_bar_placement.y,
516
473
  });
517
474
  }
518
- // Update legend (unless manually positioned)
519
475
  if (legend_manual_position && !legend_is_dragging) {
520
476
  tweened_legend_coords.set(legend_manual_position);
521
477
  }
@@ -530,26 +486,28 @@ $effect(() => {
530
486
  let x_tick_values = $derived.by(() => {
531
487
  if (!width || !height)
532
488
  return [];
533
- // Use a numeric scale for tick generation to satisfy typings;
534
- // time formatting is handled via the format option inside generate_ticks
535
- const x_scale_for_ticks = x_scale_type === `log`
536
- ? scaleLog().domain([x_min, x_max])
537
- : scaleLinear().domain([x_min, x_max]);
538
- return generate_ticks([x_min, x_max], x_scale_type, x_ticks, x_scale_for_ticks, {
539
- format: x_format,
489
+ // Choose appropriate scale for tick generation
490
+ // Time scales (format starts with %) use scaleTime for better tick placement
491
+ const x_scale_for_ticks = x_axis.format?.startsWith(`%`)
492
+ ? scaleTime().domain([new Date(x_min), new Date(x_max)])
493
+ : x_axis.scale_type === `log`
494
+ ? scaleLog().domain([x_min, x_max])
495
+ : scaleLinear().domain([x_min, x_max]);
496
+ return generate_ticks([x_min, x_max], x_axis.scale_type, x_axis.ticks, x_scale_for_ticks, {
497
+ format: x_axis.format,
540
498
  });
541
499
  });
542
500
  let y_tick_values = $derived.by(() => {
543
501
  if (!width || !height)
544
502
  return [];
545
- return generate_ticks([y_min, y_max], y_scale_type, y_ticks, y_scale_fn, {
503
+ return generate_ticks([y_min, y_max], y_axis.scale_type, y_axis.ticks, y_scale_fn, {
546
504
  default_count: 5,
547
505
  });
548
506
  });
549
507
  let y2_tick_values = $derived.by(() => {
550
508
  if (!width || !height || y2_points.length === 0)
551
509
  return [];
552
- return generate_ticks([y2_min, y2_max], y2_scale_type, y2_ticks, y2_scale_fn, {
510
+ return generate_ticks([y2_min, y2_max], y2_axis.scale_type, y2_axis.ticks, y2_scale_fn, {
553
511
  default_count: 5,
554
512
  });
555
513
  });
@@ -614,15 +572,14 @@ const on_window_mouse_up = (_evt) => {
614
572
  dy > min_zoom_size &&
615
573
  next_x_range[0] !== next_x_range[1] &&
616
574
  next_y_range[0] !== next_y_range[1]) {
617
- current_x_range = next_x_range;
618
- current_y_range = next_y_range;
575
+ zoom_x_range = next_x_range;
576
+ zoom_y_range = next_y_range;
619
577
  }
620
- // If the box is too small, we just reset without zooming (effectively ignoring the drag)
621
578
  }
622
579
  // Reset states and remove listeners
623
580
  drag_start_coords = null;
624
581
  drag_current_coords = null;
625
- svg_bounding_box = null; // Clear stored bounds
582
+ svg_bounding_box = null;
626
583
  window.removeEventListener(`mousemove`, on_window_mouse_move);
627
584
  window.removeEventListener(`mouseup`, on_window_mouse_up);
628
585
  document.body.style.cursor = `default`;
@@ -632,25 +589,31 @@ function handle_mouse_down(evt) {
632
589
  if (!coords || !svg_element)
633
590
  return;
634
591
  drag_start_coords = coords;
635
- drag_current_coords = coords; // Initialize current coords
636
- svg_bounding_box = svg_element.getBoundingClientRect(); // Store bounds on drag start
637
- // Add listeners to window
592
+ drag_current_coords = coords;
593
+ svg_bounding_box = svg_element.getBoundingClientRect();
638
594
  window.addEventListener(`mousemove`, on_window_mouse_move);
639
595
  window.addEventListener(`mouseup`, on_window_mouse_up);
640
- // Prevent text selection during drag
641
596
  evt.preventDefault();
642
597
  }
643
598
  function handle_mouse_leave() {
644
- // Reset drag state if mouse leaves plot area
645
599
  hovered = false;
646
600
  tooltip_point = null;
647
- on_point_hover?.({ point: null });
601
+ on_point_hover?.({ point: null, event: undefined });
648
602
  }
649
603
  function handle_double_click() {
650
- // Reset zoom/pan to initial ranges
651
- current_x_range = [...initial_x_range];
652
- current_y_range = [...initial_y_range];
653
- current_y2_range = [...initial_y2_range];
604
+ // Reset zoom to auto ranges
605
+ zoom_x_range = [
606
+ x_axis.range?.[0] ?? auto_x_range[0],
607
+ x_axis.range?.[1] ?? auto_x_range[1],
608
+ ];
609
+ zoom_y_range = [
610
+ y_axis.range?.[0] ?? auto_y_range[0],
611
+ y_axis.range?.[1] ?? auto_y_range[1],
612
+ ];
613
+ zoom_y2_range = [
614
+ y2_axis.range?.[0] ?? auto_y2_range[0],
615
+ y2_axis.range?.[1] ?? auto_y2_range[1],
616
+ ];
654
617
  }
655
618
  // tooltip logic: find closest point and update tooltip state
656
619
  function update_tooltip_point(x_rel, y_rel, evt) {
@@ -667,7 +630,7 @@ function update_tooltip_point(x_rel, y_rel, evt) {
667
630
  continue;
668
631
  for (const point of series_data.filtered_data) {
669
632
  // Calculate screen coordinates of the point
670
- const point_cx = x_format?.startsWith(`%`)
633
+ const point_cx = x_axis.format?.startsWith(`%`)
671
634
  ? x_scale_fn(new Date(point.x))
672
635
  : x_scale_fn(point.x);
673
636
  const point_cy = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
@@ -722,109 +685,81 @@ let actual_label_config = $derived({
722
685
  $effect(() => {
723
686
  if (!width || !height)
724
687
  return;
725
- // 1. Collect nodes for simulation (only those with auto_placement)
726
- const nodes_to_simulate = [];
688
+ // Collect auto-placed labels and their anchors
689
+ const label_nodes = [];
727
690
  const anchor_nodes = [];
728
691
  const links = [];
729
- filtered_series.forEach((series_data) => {
730
- series_data.filtered_data.forEach((point) => {
731
- if (point.point_label?.auto_placement && point.point_label.text) {
732
- const anchor_x = x_format?.startsWith(`%`)
733
- ? x_scale_fn(new Date(point.x))
734
- : x_scale_fn(point.x);
735
- const anchor_y = y_scale_fn(point.y);
736
- const id = `${point.series_idx}-${point.point_idx}`;
737
- // Estimate label size (simple approximation)
738
- const label_width = point.point_label.text.length * 6 + 10; // Approx 6px per char + padding
739
- const label_height = 14; // Approx font height + padding
740
- const label_node = {
741
- id,
742
- anchor_x,
743
- anchor_y,
744
- point_node: point,
745
- label_width,
746
- label_height,
747
- x: anchor_x + (point.point_label.offset?.x ?? 5), // Start at default offset
748
- y: anchor_y + (point.point_label.offset?.y ?? 0),
749
- };
750
- nodes_to_simulate.push(label_node);
751
- // Create a fixed anchor node for the link force
752
- const fixed_anchor_id = `anchor-${id}`;
753
- // Get the radius for the point, default if not specified
754
- const point_radius = point.point_style?.radius ?? 3; // Default radius 3
755
- anchor_nodes.push({
756
- id: fixed_anchor_id,
757
- fx: anchor_x,
758
- fy: anchor_y,
759
- point_radius,
760
- });
761
- // Link label to its fixed anchor
762
- links.push({ source: id, target: fixed_anchor_id });
763
- }
764
- });
765
- });
766
- if (nodes_to_simulate.length === 0) {
692
+ for (const series_data of filtered_series) {
693
+ for (const point of series_data.filtered_data) {
694
+ if (!point.point_label?.auto_placement || !point.point_label.text)
695
+ continue;
696
+ const anchor_x = x_axis.format?.startsWith(`%`)
697
+ ? x_scale_fn(new Date(point.x))
698
+ : x_scale_fn(point.x);
699
+ const anchor_y = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
700
+ const id = `${point.series_idx}-${point.point_idx}`;
701
+ const anchor_id = `anchor-${id}`;
702
+ label_nodes.push({
703
+ id,
704
+ anchor_x,
705
+ anchor_y,
706
+ point_node: point,
707
+ label_width: point.point_label.text.length * 6 + 10,
708
+ label_height: 14,
709
+ x: anchor_x + (point.point_label.offset?.x ?? 5),
710
+ y: anchor_y + (point.point_label.offset?.y ?? 0),
711
+ });
712
+ anchor_nodes.push({
713
+ id: anchor_id,
714
+ fx: anchor_x,
715
+ fy: anchor_y,
716
+ point_radius: point.point_style?.radius ?? 3,
717
+ });
718
+ links.push({ source: id, target: anchor_id });
719
+ }
720
+ }
721
+ if (label_nodes.length === 0) {
767
722
  label_positions = {};
768
- return; // No labels to place
723
+ return;
769
724
  }
770
- // Combine nodes for the simulation
771
- const all_simulation_nodes = [
772
- ...nodes_to_simulate,
773
- ...anchor_nodes,
774
- ];
775
- // 2. Setup and run the simulation
776
- const simulation = forceSimulation(all_simulation_nodes)
725
+ // Run force simulation
726
+ forceSimulation([...label_nodes, ...anchor_nodes])
777
727
  .force(`link`, forceLink(links)
778
728
  .id((d) => d.id)
779
729
  .distance(actual_label_config.link_distance)
780
- .strength(actual_label_config.link_strength)) // Cast d to ensure id exists
781
- .force(`collide`, forceCollide()
782
- .radius((d_node) => {
783
- const node_as_label = d_node;
784
- const node_as_anchor = d_node; // Use defined AnchorNode type
785
- if (node_as_label.label_width) {
786
- const size = Math.max(node_as_label.label_width, node_as_label.label_height) / 2;
787
- // Check if it's a LabelNode via a unique property
788
- // Collision radius based on label dimensions
789
- return size + 2; // +2 buffer
790
- }
791
- else if (node_as_anchor.point_radius !== undefined) {
792
- // Check if it's our AnchorNode
793
- // Collision radius based on the point's visual radius
794
- return node_as_anchor.point_radius + 2; // +2 buffer
795
- }
796
- return 0; // Should not happen if nodes are constructed correctly
797
- })
798
- .strength(actual_label_config.collision_strength))
799
- .stop();
800
- // Run simulation for a fixed number of ticks
801
- simulation.tick(actual_label_config.placement_ticks);
802
- // 3. Store the final positions, applying link_distance_range constraint
803
- nodes_to_simulate.forEach((node) => {
730
+ .strength(actual_label_config.link_strength))
731
+ .force(`collide`, forceCollide().radius((node) => {
732
+ const label = node;
733
+ const anchor = node;
734
+ return label.label_width
735
+ ? Math.max(label.label_width, label.label_height) / 2 + 2
736
+ : (anchor.point_radius ?? 0) + 2;
737
+ }).strength(actual_label_config.collision_strength))
738
+ .stop()
739
+ .tick(actual_label_config.placement_ticks);
740
+ // Apply distance constraints and store final positions
741
+ const [min_dist, max_dist] = actual_label_config.link_distance_range ??
742
+ [null, null];
743
+ for (const node of label_nodes) {
804
744
  let final_x = node.x;
805
745
  let final_y = node.y;
806
- const dist_range = actual_label_config.link_distance_range;
807
- if (dist_range) {
808
- const [min_dist, max_dist] = dist_range;
746
+ if (min_dist || max_dist) {
809
747
  const dx = final_x - node.anchor_x;
810
748
  const dy = final_y - node.anchor_y;
811
- const dist_sq = dx * dx + dy * dy;
812
- const current_dist = Math.sqrt(dist_sq);
813
- if (max_dist && current_dist > max_dist) {
814
- // Clamp to max distance
815
- const scale_factor = max_dist / current_dist;
816
- final_x = node.anchor_x + dx * scale_factor;
817
- final_y = node.anchor_y + dy * scale_factor;
749
+ const dist = Math.sqrt(dx * dx + dy * dy);
750
+ if (max_dist && dist > max_dist) {
751
+ const scale = max_dist / dist;
752
+ final_x = node.anchor_x + dx * scale;
753
+ final_y = node.anchor_y + dy * scale;
818
754
  }
819
- else if (min_dist && current_dist < min_dist && current_dist > 0) {
820
- // Clamp to min distance (if not directly at anchor point)
821
- const scale_factor = min_dist / current_dist;
822
- final_x = node.anchor_x + dx * scale_factor;
823
- final_y = node.anchor_y + dy * scale_factor;
755
+ else if (min_dist && dist > 0 && dist < min_dist) {
756
+ const scale = min_dist / dist;
757
+ final_x = node.anchor_x + dx * scale;
758
+ final_y = node.anchor_y + dy * scale;
824
759
  }
825
760
  }
826
761
  label_positions[node.id] = { x: final_x, y: final_y };
827
- });
762
+ }
828
763
  });
829
764
  // Helper function to check if two series have compatible units
830
765
  function have_compatible_units(series1, series2) {
@@ -842,10 +777,15 @@ function toggle_series_visibility(series_idx) {
842
777
  const toggled_series = series[series_idx];
843
778
  const new_visibility = !(toggled_series.visible ?? true);
844
779
  const target_axis = toggled_series.y_axis ?? `y1`;
780
+ const toggled_label = toggled_series.label;
845
781
  // Only create new objects for series that need to change to preserve series IDs
846
782
  series = series.map((s, idx) => {
847
- if (idx === series_idx) {
848
- // Toggle the clicked series
783
+ // Toggle all series with the same label (for grouped series like band structures)
784
+ if (toggled_label && s.label === toggled_label) {
785
+ return { ...s, visible: new_visibility };
786
+ }
787
+ // Also toggle the specific series if it has no label
788
+ if (idx === series_idx && !toggled_label) {
849
789
  return { ...s, visible: new_visibility };
850
790
  }
851
791
  // If we're showing a series, hide incompatible series on same axis
@@ -863,11 +803,20 @@ function toggle_series_visibility(series_idx) {
863
803
  }
864
804
  // Function to handle double-click on legend item
865
805
  function handle_legend_double_click(double_clicked_idx) {
866
- const current_visibility = processed_series.map((s) => s?.visible ?? true);
867
- const visible_count = current_visibility.filter((v) => v).length;
868
- const is_currently_isolated = visible_count === 1 &&
869
- current_visibility[double_clicked_idx];
870
- if (is_currently_isolated && previous_series_visibility) {
806
+ const double_clicked_series = series[double_clicked_idx];
807
+ const double_clicked_label = double_clicked_series?.label;
808
+ const current_visibility = series.map((s) => s?.visible ?? true);
809
+ const visible_count = current_visibility.filter((visible) => visible).length;
810
+ // Check if the group is currently isolated (all series with this label visible, others hidden)
811
+ const group_is_isolated = series.every((srs, idx) => {
812
+ // For unlabeled series, use index comparison; for labeled, use label comparison
813
+ const is_in_group = double_clicked_label
814
+ ? srs.label === double_clicked_label
815
+ : idx === double_clicked_idx;
816
+ const is_visible = srs?.visible ?? true;
817
+ return is_in_group ? is_visible : !is_visible;
818
+ });
819
+ if (group_is_isolated && previous_series_visibility) {
871
820
  // Restore previous visibility state
872
821
  // Only create new objects for series whose visibility actually changes
873
822
  series = series.map((s, idx) => {
@@ -881,14 +830,18 @@ function handle_legend_double_click(double_clicked_idx) {
881
830
  previous_series_visibility = null; // Clear memory
882
831
  }
883
832
  else {
884
- // Isolate the double-clicked series
833
+ // Isolate the double-clicked series group
885
834
  // Only store previous state if we are actually isolating (more than one series visible)
886
835
  if (visible_count > 1) {
887
836
  previous_series_visibility = [...current_visibility]; // Store current state
888
837
  }
889
838
  // Only create new objects for series whose visibility needs to change
890
839
  series = series.map((s, idx) => {
891
- const target_visibility = idx === double_clicked_idx;
840
+ // For unlabeled series, use index comparison; for labeled, use label comparison
841
+ const is_in_group = double_clicked_label
842
+ ? s.label === double_clicked_label
843
+ : idx === double_clicked_idx;
844
+ const target_visibility = is_in_group;
892
845
  const current_visibility = s?.visible ?? true;
893
846
  if (current_visibility !== target_visibility) {
894
847
  return { ...s, visible: target_visibility };
@@ -928,7 +881,7 @@ function handle_legend_drag_end(_event) {
928
881
  }
929
882
  function get_screen_coords(point, series) {
930
883
  // convert data coordinates to potentially non-finite screen coordinates
931
- const screen_x = x_format?.startsWith(`%`)
884
+ const screen_x = x_axis.format?.startsWith(`%`)
932
885
  ? x_scale_fn(new Date(point.x))
933
886
  : x_scale_fn(point.x);
934
887
  const y_val = point.y;
@@ -936,19 +889,19 @@ function get_screen_coords(point, series) {
936
889
  const use_y2 = series?.y_axis === `y2`;
937
890
  const y_scale = use_y2 ? y2_scale_fn : y_scale_fn;
938
891
  const min_domain_y = use_y2
939
- ? y2_scale_type === `log` ? y_scale.domain()[0] : -Infinity
940
- : y_scale_type === `log`
892
+ ? y2_axis.scale_type === `log` ? y_scale.domain()[0] : -Infinity
893
+ : y_axis.scale_type === `log`
941
894
  ? y_scale.domain()[0]
942
895
  : -Infinity;
943
896
  const safe_y_val = use_y2
944
- ? y2_scale_type === `log` ? Math.max(y_val, min_domain_y) : y_val
945
- : y_scale_type === `log`
897
+ ? y2_axis.scale_type === `log` ? Math.max(y_val, min_domain_y) : y_val
898
+ : y_axis.scale_type === `log`
946
899
  ? Math.max(y_val, min_domain_y)
947
900
  : y_val;
948
901
  const screen_y = y_scale(safe_y_val); // This might be non-finite
949
902
  return [screen_x, screen_y];
950
903
  }
951
- let using_controls = $derived(show_controls);
904
+ let using_controls = $derived(controls.show);
952
905
  let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
953
906
  </script>
954
907
 
@@ -978,43 +931,242 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
978
931
  width,
979
932
  x_scale_fn,
980
933
  y_scale_fn,
981
- x_min,
982
- x_max,
983
- y_min,
984
- y_max,
934
+ y2_scale_fn,
985
935
  pad,
936
+ x_range: [x_min, x_max],
937
+ y_range: [y_min, y_max],
938
+ y2_range: [y2_min, y2_max],
986
939
  })}
940
+ <g class="x-axis">
941
+ {#if width > 0 && height > 0}
942
+ {#each x_tick_values as tick (tick)}
943
+ {@const tick_pos_raw = x_axis.format?.startsWith(`%`)
944
+ ? x_scale_fn(new Date(tick))
945
+ : x_scale_fn(tick)}
946
+ {#if isFinite(tick_pos_raw)}
947
+ // Check if tick position is finite
948
+ {@const tick_pos = tick_pos_raw}
949
+ {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
950
+ <g class="tick" transform="translate({tick_pos}, {height - pad.b})">
951
+ {#if display.x_grid}
952
+ <line
953
+ y1={-(height - pad.b - pad.t)}
954
+ y2="0"
955
+ {...DEFAULT_GRID_STYLE}
956
+ {...(x_axis.grid_style ?? {})}
957
+ />
958
+ {/if}
987
959
 
988
- <!-- Guide lines -->
989
- <!-- Zero lines -->
990
- {#if show_zero_lines}
991
- {#if x_min <= 0 && x_max >= 0}
992
- {@const zero_x_pos = x_format?.startsWith(`%`)
993
- ? x_scale_fn(new Date(0))
994
- : x_scale_fn(0)}
995
- {#if isFinite(zero_x_pos)}
996
- <line
997
- y1={pad.t}
998
- y2={height - pad.b}
999
- x1={zero_x_pos}
1000
- x2={zero_x_pos}
1001
- stroke="gray"
1002
- stroke-width="0.5"
1003
- />
960
+ {#if tick >= x_min && tick <= x_max}
961
+ {@const { x, y } = x_axis.tick_label_shift ?? { x: 0, y: 20 }}
962
+ {@const custom_label = get_tick_label(tick, x_axis.ticks)}
963
+ <text {x} {y}>
964
+ {custom_label ?? format_value(tick, x_axis.format ?? ``)}
965
+ </text>
966
+ {/if}
967
+ </g>
968
+ {/if}
969
+ {/if}
970
+ {/each}
971
+ {/if}
972
+
973
+ <!-- Current frame indicator -->
974
+ {#if current_x_value !== null && current_x_value !== undefined}
975
+ {@const current_pos_raw = x_axis.format?.startsWith(`%`)
976
+ ? x_scale_fn(new Date(current_x_value))
977
+ : x_scale_fn(current_x_value)}
978
+ {#if isFinite(current_pos_raw)}
979
+ {@const current_pos = current_pos_raw}
980
+ {#if current_pos >= pad.l && current_pos <= width - pad.r}
981
+ {@const active_tick_height = 7}
982
+ <rect
983
+ x={current_pos - 1.5}
984
+ y={height - pad.b - active_tick_height / 2}
985
+ width="3"
986
+ height={active_tick_height}
987
+ fill="var(--scatter-current-frame-color, #ff6b35)"
988
+ stroke="white"
989
+ stroke-width="1"
990
+ class="current-frame-indicator"
991
+ />
992
+ {/if}
1004
993
  {/if}
1005
994
  {/if}
1006
- {#if y_scale_type === `linear` && y_min < 0 && y_max > 0}
1007
- {@const zero_y_pos = y_scale_fn(0)}
1008
- {#if isFinite(zero_y_pos)}
1009
- <line
1010
- x1={pad.l}
1011
- x2={width - pad.r}
1012
- y1={zero_y_pos}
1013
- y2={zero_y_pos}
1014
- stroke="gray"
1015
- stroke-width="0.5"
1016
- />
995
+
996
+ {#if x_axis.label}
997
+ <foreignObject
998
+ x={width / 2 + (x_axis.label_shift?.x ?? 0) - 100}
999
+ y={height - pad.b - (x_axis.label_shift?.y ?? -40) - 10}
1000
+ width="200"
1001
+ height="20"
1002
+ >
1003
+ <div class="axis-label x-label">
1004
+ {@html x_axis.label ?? ``}
1005
+ </div>
1006
+ </foreignObject>
1007
+ {/if}
1008
+ </g>
1009
+
1010
+ <g class="y-axis">
1011
+ {#if width > 0 && height > 0}
1012
+ {#each y_tick_values as tick, idx (tick)}
1013
+ {@const tick_pos_raw = y_scale_fn(tick)}
1014
+ {#if isFinite(tick_pos_raw)}
1015
+ // Check if tick position is finite
1016
+ {@const tick_pos = tick_pos_raw}
1017
+ {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1018
+ <g class="tick" transform="translate({pad.l}, {tick_pos})">
1019
+ {#if display.y_grid}
1020
+ <line
1021
+ x1="0"
1022
+ x2={width - pad.l - pad.r}
1023
+ {...DEFAULT_GRID_STYLE}
1024
+ {...(y_axis.grid_style ?? {})}
1025
+ />
1026
+ {/if}
1027
+
1028
+ {#if tick >= y_min && tick <= y_max}
1029
+ {@const { x, y } = y_axis.tick_label_shift ?? { x: -8, y: 0 }}
1030
+ {@const custom_label = get_tick_label(tick, y_axis.ticks)}
1031
+ <text {x} {y} text-anchor="end" fill={y_axis.color}>
1032
+ {custom_label ?? format_value(tick, y_axis.format ?? ``)}
1033
+ {#if y_axis.unit && idx === 0}
1034
+ &zwnj;&ensp;{y_axis.unit}
1035
+ {/if}
1036
+ </text>
1037
+ {/if}
1038
+ </g>
1039
+ {/if}
1040
+ {/if}
1041
+ {/each}
1042
+ {/if}
1043
+
1044
+ {#if height > 0 && y_axis.label}
1045
+ <foreignObject
1046
+ x={-100}
1047
+ y={-10}
1048
+ width="200"
1049
+ height="20"
1050
+ transform="rotate(-90, {(y_axis.label_shift?.y ?? 12)}, {pad.t +
1051
+ (height - pad.t - pad.b) / 2 +
1052
+ ((y_axis.label_shift?.x ?? 0))}) translate({(y_axis.label_shift?.y ?? 12)}, {pad.t +
1053
+ (height - pad.t - pad.b) / 2 +
1054
+ ((y_axis.label_shift?.x ?? 0))})"
1055
+ >
1056
+ <div class="axis-label y-label" style:color={y_axis.color}>
1057
+ {@html y_axis.label ?? ``}
1058
+ </div>
1059
+ </foreignObject>
1060
+ {/if}
1061
+ </g>
1062
+
1063
+ <!-- Y2-axis (Right) -->
1064
+ {#if y2_points.length > 0}
1065
+ <g class="y2-axis">
1066
+ {#if width > 0 && height > 0}
1067
+ {#each y2_tick_values as tick, idx (tick)}
1068
+ {@const tick_pos_raw = y2_scale_fn(tick)}
1069
+ {#if isFinite(tick_pos_raw)}
1070
+ // Check if tick position is finite
1071
+ {@const tick_pos = tick_pos_raw}
1072
+ {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1073
+ <g class="tick" transform="translate({width - pad.r}, {tick_pos})">
1074
+ {#if display.y2_grid}
1075
+ <line
1076
+ x1={-(width - pad.l - pad.r)}
1077
+ x2="0"
1078
+ {...DEFAULT_GRID_STYLE}
1079
+ {...(y2_axis.grid_style ?? {})}
1080
+ />
1081
+ {/if}
1082
+
1083
+ {#if tick >= y2_min && tick <= y2_max}
1084
+ {@const { x, y } = y2_axis.tick_label_shift ?? { x: 8, y: 0 }}
1085
+ {@const custom_label = get_tick_label(tick, y2_axis.ticks)}
1086
+ <text {x} {y} text-anchor="start" fill={y2_axis.color}>
1087
+ {custom_label ?? format_value(tick, y2_axis.format ?? ``)}
1088
+ {#if y2_axis.unit && idx === 0}
1089
+ &zwnj;&ensp;{y2_axis.unit}
1090
+ {/if}
1091
+ </text>
1092
+ {/if}
1093
+ </g>
1094
+ {/if}
1095
+ {/if}
1096
+ {/each}
1097
+ {/if}
1098
+
1099
+ {#if height > 0 && y2_axis.label}
1100
+ <foreignObject
1101
+ x={-100}
1102
+ y={-10}
1103
+ width="200"
1104
+ height="20"
1105
+ transform="rotate(-90, {width - pad.r + ((y2_axis.label_shift?.y ?? 0))}, {pad.t +
1106
+ (height - pad.t - pad.b) / 2 +
1107
+ ((y2_axis.label_shift?.x ?? 0))}) translate({width -
1108
+ pad.r +
1109
+ ((y2_axis.label_shift?.y ?? 0))}, {pad.t +
1110
+ (height - pad.t - pad.b) / 2 +
1111
+ ((y2_axis.label_shift?.x ?? 0))})"
1112
+ >
1113
+ <div class="axis-label y2-label" style:color={y2_axis.color}>
1114
+ {@html y2_axis.label ?? ``}
1115
+ </div>
1116
+ </foreignObject>
1017
1117
  {/if}
1118
+ </g>
1119
+ {/if}
1120
+
1121
+ <!-- Tooltip rendered inside overlay (moved outside SVG for stacking above colorbar) -->
1122
+
1123
+ <!-- Zoom Selection Rectangle -->
1124
+ {#if drag_start_coords && drag_current_coords}
1125
+ {@const x = Math.min(drag_start_coords.x, drag_current_coords.x)}
1126
+ {@const y = Math.min(drag_start_coords.y, drag_current_coords.y)}
1127
+ {@const rect_width = Math.abs(drag_start_coords.x - drag_current_coords.x)}
1128
+ {@const rect_height = Math.abs(drag_start_coords.y - drag_current_coords.y)}
1129
+ <rect class="zoom-rect" {x} {y} width={rect_width} height={rect_height} />
1130
+ {/if}
1131
+
1132
+ <!-- Zero lines -->
1133
+ {#if display.x_zero_line && x_axis.scale_type === `linear` &&
1134
+ !x_axis.format?.startsWith(`%`) && x_min <= 0 && x_max >= 0}
1135
+ {@const zero_x_pos = x_scale_fn(0)}
1136
+ {#if isFinite(zero_x_pos)}
1137
+ <line
1138
+ class="zero-line"
1139
+ x1={zero_x_pos}
1140
+ x2={zero_x_pos}
1141
+ y1={pad.t}
1142
+ y2={height - pad.b}
1143
+ />
1144
+ {/if}
1145
+ {/if}
1146
+ {#if display.y_zero_line && y_axis.scale_type === `linear` && y_min <= 0 &&
1147
+ y_max >= 0}
1148
+ {@const zero_y_pos = y_scale_fn(0)}
1149
+ {#if isFinite(zero_y_pos)}
1150
+ <line
1151
+ class="zero-line"
1152
+ x1={pad.l}
1153
+ x2={width - pad.r}
1154
+ y1={zero_y_pos}
1155
+ y2={zero_y_pos}
1156
+ />
1157
+ {/if}
1158
+ {/if}
1159
+ {#if display.y_zero_line && y2_points.length > 0 &&
1160
+ y2_axis.scale_type === `linear` && y2_min <= 0 && y2_max >= 0}
1161
+ {@const zero_y2_pos = y2_scale_fn(0)}
1162
+ {#if isFinite(zero_y2_pos)}
1163
+ <line
1164
+ class="zero-line"
1165
+ x1={pad.l}
1166
+ x2={width - pad.r}
1167
+ y1={zero_y2_pos}
1168
+ y2={zero_y2_pos}
1169
+ />
1018
1170
  {/if}
1019
1171
  {/if}
1020
1172
 
@@ -1030,9 +1182,9 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1030
1182
  </defs>
1031
1183
 
1032
1184
  <!-- Lines -->
1033
- {#if markers?.includes(`line`) && show_lines}
1185
+ {#if styles.show_lines}
1034
1186
  {#each filtered_series ?? [] as series_data (series_data._id)}
1035
- {@const series_markers = series_data.markers ?? markers}
1187
+ {@const series_markers = series_data.markers ?? DEFAULT_MARKERS}
1036
1188
  <g data-series-id={series_data._id} clip-path="url(#{clip_path_id})">
1037
1189
  {#if series_markers?.includes(`line`)}
1038
1190
  {@const all_line_points = series_data.x.map((x, idx) => ({
@@ -1048,13 +1200,13 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1048
1200
  <Line
1049
1201
  points={finite_screen_points}
1050
1202
  origin={[
1051
- x_format?.startsWith(`%`)
1203
+ x_axis.format?.startsWith(`%`)
1052
1204
  ? x_scale_fn(new Date(x_min))
1053
1205
  : x_scale_fn(x_min),
1054
1206
  series_data.y_axis === `y2` ? y2_scale_fn(y2_min) : y_scale_fn(y_min),
1055
1207
  ]}
1056
1208
  line_color={apply_line_controls
1057
- ? line_color ?? `cornflowerblue`
1209
+ ? styles.line?.color ?? `cornflowerblue`
1058
1210
  : series_data.line_style?.stroke ??
1059
1211
  (Array.isArray(series_data.point_style)
1060
1212
  ? series_data.point_style[0]?.fill
@@ -1063,9 +1215,11 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1063
1215
  ? color_scale_fn(series_data.color_values[0])
1064
1216
  : `cornflowerblue`)}
1065
1217
  line_width={apply_line_controls
1066
- ? line_width ?? 2
1218
+ ? styles.line?.width ?? 2
1067
1219
  : series_data.line_style?.stroke_width ?? 2}
1068
- line_dash={apply_line_controls ? line_dash : series_data.line_style?.line_dash}
1220
+ line_dash={apply_line_controls
1221
+ ? styles.line?.dash
1222
+ : series_data.line_style?.line_dash}
1069
1223
  area_color="transparent"
1070
1224
  {line_tween}
1071
1225
  />
@@ -1075,9 +1229,9 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1075
1229
  {/if}
1076
1230
 
1077
1231
  <!-- Points -->
1078
- {#if markers?.includes(`points`) && show_points}
1232
+ {#if styles.show_points}
1079
1233
  {#each filtered_series ?? [] as series_data (series_data._id)}
1080
- {@const series_markers = series_data.markers ?? markers}
1234
+ {@const series_markers = series_data.markers ?? DEFAULT_MARKERS}
1081
1235
  <g data-series-id={series_data._id}>
1082
1236
  {#if series_markers?.includes(`points`)}
1083
1237
  {#each series_data.filtered_data as
@@ -1092,7 +1246,7 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1092
1246
  ...label_style,
1093
1247
  offset: {
1094
1248
  x: calculated_label_pos.x -
1095
- (x_format?.startsWith(`%`)
1249
+ (x_axis.format?.startsWith(`%`)
1096
1250
  ? x_scale_fn(new Date(point.x))
1097
1251
  : x_scale_fn(point.x)),
1098
1252
  y: calculated_label_pos.y - (series_data.y_axis === `y2`
@@ -1118,26 +1272,26 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1118
1272
  style={{
1119
1273
  ...point.point_style,
1120
1274
  radius: apply_controls
1121
- ? point_size ?? (point.size_value != null
1275
+ ? styles.point?.size ?? (point.size_value != null
1122
1276
  ? size_scale_fn(point.size_value)
1123
1277
  : point.point_style?.radius ?? 4)
1124
1278
  : point.size_value != null
1125
1279
  ? size_scale_fn(point.size_value)
1126
1280
  : point.point_style?.radius ?? 4,
1127
1281
  stroke_width: apply_controls
1128
- ? point_stroke_width ??
1282
+ ? styles.point?.stroke_width ??
1129
1283
  point.point_style?.stroke_width ?? 1
1130
1284
  : point.point_style?.stroke_width ?? 1,
1131
1285
  stroke: apply_controls
1132
- ? point_stroke_color ??
1286
+ ? styles.point?.stroke_color ??
1133
1287
  point.point_style?.stroke ?? `#000`
1134
1288
  : point.point_style?.stroke ?? `#000`,
1135
1289
  stroke_opacity: apply_controls
1136
- ? point_stroke_opacity ??
1290
+ ? styles.point?.stroke_opacity ??
1137
1291
  point.point_style?.stroke_opacity ?? 1
1138
1292
  : point.point_style?.stroke_opacity ?? 1,
1139
1293
  fill_opacity: apply_controls
1140
- ? point_opacity ??
1294
+ ? styles.point?.opacity ??
1141
1295
  point.point_style?.fill_opacity ?? 1
1142
1296
  : point.point_style?.fill_opacity ?? 1,
1143
1297
  cursor: on_point_click ? `pointer` : undefined,
@@ -1150,7 +1304,7 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1150
1304
  --point-fill-color={point.color_value != null
1151
1305
  ? color_scale_fn(point.color_value)
1152
1306
  : apply_controls
1153
- ? point_color ?? point.point_style?.fill ??
1307
+ ? styles.point?.color ?? point.point_style?.fill ??
1154
1308
  `cornflowerblue`
1155
1309
  : point.point_style?.fill ?? `cornflowerblue`}
1156
1310
  {...point_events &&
@@ -1166,188 +1320,6 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1166
1320
  </g>
1167
1321
  {/each}
1168
1322
  {/if}
1169
-
1170
- <g class="x-axis">
1171
- {#if width > 0 && height > 0}
1172
- {#each x_tick_values as tick (tick)}
1173
- {@const tick_pos_raw = x_format?.startsWith(`%`)
1174
- ? x_scale_fn(new Date(tick))
1175
- : x_scale_fn(tick)}
1176
- {#if isFinite(tick_pos_raw)}
1177
- // Check if tick position is finite
1178
- {@const tick_pos = tick_pos_raw}
1179
- {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
1180
- <g class="tick" transform="translate({tick_pos}, {height - pad.b})">
1181
- {#if x_grid}
1182
- <line
1183
- y1={-(height - pad.b - pad.t)}
1184
- y2="0"
1185
- {...typeof x_grid === `object` ? x_grid : {}}
1186
- />
1187
- {/if}
1188
-
1189
- {#if tick >= x_min && tick <= x_max}
1190
- {@const { x, y } = x_tick_label_shift}
1191
- <text {x} {y}>{format_value(tick, x_format)}</text>
1192
- {/if}
1193
- </g>
1194
- {/if}
1195
- {/if}
1196
- {/each}
1197
- {/if}
1198
-
1199
- <!-- Current frame indicator -->
1200
- {#if current_x_value !== null && current_x_value !== undefined}
1201
- {@const current_pos_raw = x_format?.startsWith(`%`)
1202
- ? x_scale_fn(new Date(current_x_value))
1203
- : x_scale_fn(current_x_value)}
1204
- {#if isFinite(current_pos_raw)}
1205
- {@const current_pos = current_pos_raw}
1206
- {#if current_pos >= pad.l && current_pos <= width - pad.r}
1207
- {@const active_tick_height = 7}
1208
- <rect
1209
- x={current_pos - 1.5}
1210
- y={height - pad.b - active_tick_height / 2}
1211
- width="3"
1212
- height={active_tick_height}
1213
- fill="var(--scatter-current-frame-color, #ff6b35)"
1214
- stroke="white"
1215
- stroke-width="0.5"
1216
- class="current-frame-indicator"
1217
- />
1218
- {/if}
1219
- {/if}
1220
- {/if}
1221
-
1222
- <foreignObject
1223
- x={width / 2 + (x_label_shift.x ?? 0) - 100}
1224
- y={height - pad.b - (x_label_shift.y ?? 0) - 10}
1225
- width="200"
1226
- height="20"
1227
- >
1228
- <div class="axis-label x-label">
1229
- {@html x_label ?? ``}
1230
- </div>
1231
- </foreignObject>
1232
- </g>
1233
-
1234
- <g class="y-axis">
1235
- {#if width > 0 && height > 0}
1236
- {#each y_tick_values as tick, idx (tick)}
1237
- {@const tick_pos_raw = y_scale_fn(tick)}
1238
- {#if isFinite(tick_pos_raw)}
1239
- // Check if tick position is finite
1240
- {@const tick_pos = tick_pos_raw}
1241
- {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1242
- <g class="tick" transform="translate({pad.l}, {tick_pos})">
1243
- {#if y_grid}
1244
- <line
1245
- x1="0"
1246
- x2={width - pad.l - pad.r}
1247
- {...typeof y_grid === `object` ? y_grid : {}}
1248
- />
1249
- {/if}
1250
-
1251
- {#if tick >= y_min && tick <= y_max}
1252
- {@const { x, y } = y_tick_label_shift}
1253
- <text {x} {y} text-anchor="end" fill={axis_colors.y1 || undefined}>
1254
- {format_value(tick, y_format)}
1255
- {#if y_unit && idx === 0}
1256
- &zwnj;&ensp;{y_unit}
1257
- {/if}
1258
- </text>
1259
- {/if}
1260
- </g>
1261
- {/if}
1262
- {/if}
1263
- {/each}
1264
- {/if}
1265
-
1266
- {#if height > 0}
1267
- <foreignObject
1268
- x={-100}
1269
- y={-10}
1270
- width="200"
1271
- height="20"
1272
- transform="rotate(-90, {y_label_shift.y ?? 20}, {pad.t +
1273
- (height - pad.t - pad.b) / 2 +
1274
- (y_label_shift.x ?? 0)}) translate({y_label_shift.y ?? 20}, {pad.t +
1275
- (height - pad.t - pad.b) / 2 +
1276
- (y_label_shift.x ?? 0)})"
1277
- >
1278
- <div class="axis-label y-label" style:color={axis_colors.y1 || undefined}>
1279
- {@html y_label ?? ``}
1280
- </div>
1281
- </foreignObject>
1282
- {/if}
1283
- </g>
1284
-
1285
- <!-- Y2-axis (Right) -->
1286
- {#if y2_points.length > 0}
1287
- <g class="y2-axis">
1288
- {#if width > 0 && height > 0}
1289
- {#each y2_tick_values as tick, idx (tick)}
1290
- {@const tick_pos_raw = y2_scale_fn(tick)}
1291
- {#if isFinite(tick_pos_raw)}
1292
- // Check if tick position is finite
1293
- {@const tick_pos = tick_pos_raw}
1294
- {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1295
- <g class="tick" transform="translate({width - pad.r}, {tick_pos})">
1296
- {#if y2_grid}
1297
- <line
1298
- x1={-(width - pad.l - pad.r)}
1299
- x2="0"
1300
- {...typeof y2_grid === `object` ? y2_grid : {}}
1301
- />
1302
- {/if}
1303
-
1304
- {#if tick >= y2_min && tick <= y2_max}
1305
- {@const { x, y } = y2_tick_label_shift}
1306
- <text {x} {y} text-anchor="start" fill={axis_colors.y2}>
1307
- {format_value(tick, y2_format)}
1308
- {#if y2_unit && idx === 0}
1309
- &zwnj;&ensp;{y2_unit}
1310
- {/if}
1311
- </text>
1312
- {/if}
1313
- </g>
1314
- {/if}
1315
- {/if}
1316
- {/each}
1317
- {/if}
1318
-
1319
- {#if height > 0 && y2_label}
1320
- <foreignObject
1321
- x={-100}
1322
- y={-10}
1323
- width="200"
1324
- height="20"
1325
- transform="rotate(-90, {width - pad.r + (y2_label_shift.y ?? 0)}, {pad.t +
1326
- (height - pad.t - pad.b) / 2 +
1327
- (y2_label_shift.x ?? 0)}) translate({width -
1328
- pad.r +
1329
- (y2_label_shift.y ?? 0)}, {pad.t +
1330
- (height - pad.t - pad.b) / 2 +
1331
- (y2_label_shift.x ?? 0)})"
1332
- >
1333
- <div class="axis-label y2-label" style:color={axis_colors.y2}>
1334
- {@html y2_label ?? ``}
1335
- </div>
1336
- </foreignObject>
1337
- {/if}
1338
- </g>
1339
- {/if}
1340
-
1341
- <!-- Tooltip rendered inside overlay (moved outside SVG for stacking above colorbar) -->
1342
-
1343
- <!-- Zoom Selection Rectangle -->
1344
- {#if drag_start_coords && drag_current_coords}
1345
- {@const x = Math.min(drag_start_coords.x, drag_current_coords.x)}
1346
- {@const y = Math.min(drag_start_coords.y, drag_current_coords.y)}
1347
- {@const rect_width = Math.abs(drag_start_coords.x - drag_current_coords.x)}
1348
- {@const rect_height = Math.abs(drag_start_coords.y - drag_current_coords.y)}
1349
- <rect class="zoom-rect" {x} {y} width={rect_width} height={rect_height} />
1350
- {/if}
1351
1323
  </svg>
1352
1324
 
1353
1325
  <!-- Tooltip overlay above all plot overlays (legend, colorbar) -->
@@ -1355,7 +1327,7 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1355
1327
  {@const { x, y, metadata, color_value, point_label, point_style, series_idx } =
1356
1328
  tooltip_point}
1357
1329
  {@const hovered_series = series_with_ids[series_idx]}
1358
- {@const series_markers = hovered_series?.markers ?? markers}
1330
+ {@const series_markers = hovered_series?.markers ?? DEFAULT_MARKERS}
1359
1331
  {@const is_transparent_or_none = (color: string | undefined | null): boolean =>
1360
1332
  !color || color === `none` || color === `transparent` ||
1361
1333
  (color.startsWith(`rgba(`) && color.endsWith(`, 0)`))}
@@ -1390,10 +1362,13 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1390
1362
  }
1391
1363
  return `rgba(0, 0, 0, 0.7)`
1392
1364
  })()}
1393
- {@const cx = x_format?.startsWith(`%`) ? x_scale_fn(new Date(x)) : x_scale_fn(x)}
1365
+ {@const cx = x_axis.format?.startsWith(`%`) ? x_scale_fn(new Date(x)) : x_scale_fn(x)}
1394
1366
  {@const cy = (hovered_series?.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(y)}
1395
- {@const x_formatted = format_value(x, x_format)}
1396
- {@const y_formatted = format_value(y, y_format)}
1367
+ {@const x_formatted = format_value(x, x_axis.format || `.3~s`)}
1368
+ {@const y_formatted = format_value(
1369
+ y,
1370
+ (hovered_series?.y_axis === `y2` ? y2_axis.format : y_axis.format) || `.3~s`,
1371
+ )}
1397
1372
  {@const label = point_label?.text ?? null}
1398
1373
  {@const tooltip_lum = luminance(tooltip_bg_color ?? `rgba(0, 0, 0, 0.7)`)}
1399
1374
  {@const tooltip_text_color = tooltip_lum > 0.5 ? `#000000` : `#ffffff`}
@@ -1412,41 +1387,22 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1412
1387
  </div>
1413
1388
  {/if}
1414
1389
 
1415
- <!-- Control Pane positioned in top-right corner -->
1416
- {#if show_controls}
1390
+ <!-- Control Pane -->
1391
+ {#if controls.show}
1417
1392
  <ScatterPlotControls
1418
- toggle_props={controls_toggle_props}
1419
- bind:show_controls
1420
- bind:controls_open
1421
- bind:markers
1422
- bind:show_zero_lines
1423
- bind:x_grid
1424
- bind:y_grid
1425
- bind:y2_grid
1426
- bind:x_range
1427
- bind:y_range
1428
- bind:y2_range
1393
+ toggle_props={controls.toggle_props}
1394
+ pane_props={controls.pane_props}
1395
+ bind:x_axis
1396
+ bind:y_axis
1397
+ bind:y2_axis
1398
+ bind:display
1399
+ bind:styles
1400
+ bind:controls
1429
1401
  {auto_x_range}
1430
1402
  {auto_y_range}
1431
1403
  {auto_y2_range}
1432
- bind:point_size
1433
- bind:point_color
1434
- bind:point_opacity
1435
- bind:point_stroke_width
1436
- bind:point_stroke_color
1437
- bind:point_stroke_opacity
1438
- bind:line_width
1439
- bind:line_color
1440
- bind:line_opacity
1441
- bind:line_dash
1442
- bind:show_points
1443
- bind:show_lines
1444
1404
  bind:selected_series_idx
1445
- bind:x_format
1446
- bind:y_format
1447
- bind:y2_format
1448
1405
  series={series_with_ids}
1449
- {plot_controls}
1450
1406
  has_y2_points={y2_points.length > 0}
1451
1407
  />
1452
1408
  {/if}
@@ -1454,15 +1410,17 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1454
1410
  <!-- Color Bar -->
1455
1411
  {#if color_bar && all_color_values.length > 0 && color_bar_placement}
1456
1412
  {@const color_domain = [
1457
- color_scale.value_range?.[0] ?? auto_color_range[0],
1458
- color_scale.value_range?.[1] ?? auto_color_range[1],
1413
+ (typeof color_scale === `string` ? undefined : color_scale.value_range)?.[0] ??
1414
+ auto_color_range[0],
1415
+ (typeof color_scale === `string` ? undefined : color_scale.value_range)?.[1] ??
1416
+ auto_color_range[1],
1459
1417
  ] as [number, number]}
1460
1418
  <ColorBar
1461
1419
  tick_labels={4}
1462
1420
  tick_side="primary"
1463
1421
  {color_scale_fn}
1464
1422
  color_scale_domain={color_domain}
1465
- scale_type={color_scale.type}
1423
+ scale_type={typeof color_scale === `string` ? undefined : color_scale.type}
1466
1424
  range={color_domain?.every((val) => val != null) ? color_domain : undefined}
1467
1425
  wrapper_style={`
1468
1426
  position: absolute;
@@ -1519,15 +1477,16 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1519
1477
  <style>
1520
1478
  div.scatter {
1521
1479
  position: relative; /* Needed for absolute positioning of children like ColorBar */
1522
- width: 100%;
1523
- height: 100%;
1524
- min-height: var(--scatter-min-height, 100px);
1480
+ width: var(--scatter-width, 100%);
1481
+ height: var(--scatter-height, 100%);
1482
+ min-height: var(--scatter-min-height, 350px);
1525
1483
  container-type: size; /* enable cqh for panes */
1526
1484
  container-name: scatter-plot;
1527
1485
  z-index: var(--scatter-z-index);
1528
1486
  }
1529
1487
  svg {
1530
1488
  width: 100%;
1489
+ height: 100%;
1531
1490
  fill: var(--text-color);
1532
1491
  font-weight: var(--scatter-font-weight);
1533
1492
  overflow: visible;
@@ -1588,4 +1547,10 @@ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1588
1547
  stroke-width: var(--scatter-zoom-rect-stroke-width, 1);
1589
1548
  pointer-events: none; /* Prevent rect from interfering with mouse events */
1590
1549
  }
1550
+ .zero-line {
1551
+ stroke: var(--scatter-zero-line-color, light-dark(black, white));
1552
+ stroke-width: var(--scatter-zero-line-width, 1);
1553
+ stroke-dasharray: none;
1554
+ opacity: var(--scatter-zero-line-opacity, 0.3);
1555
+ }
1591
1556
  </style>