matterviz 0.1.0

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 (131) hide show
  1. package/dist/BohrAtom.svelte +105 -0
  2. package/dist/BohrAtom.svelte.d.ts +21 -0
  3. package/dist/ControlPanel.svelte +158 -0
  4. package/dist/ControlPanel.svelte.d.ts +18 -0
  5. package/dist/Icon.svelte +23 -0
  6. package/dist/Icon.svelte.d.ts +8 -0
  7. package/dist/InfoCard.svelte +79 -0
  8. package/dist/InfoCard.svelte.d.ts +23 -0
  9. package/dist/Nucleus.svelte +64 -0
  10. package/dist/Nucleus.svelte.d.ts +16 -0
  11. package/dist/Spinner.svelte +44 -0
  12. package/dist/Spinner.svelte.d.ts +7 -0
  13. package/dist/api.d.ts +6 -0
  14. package/dist/api.js +30 -0
  15. package/dist/colors/alloy-colors.json +111 -0
  16. package/dist/colors/dark-mode-colors.json +111 -0
  17. package/dist/colors/index.d.ts +26 -0
  18. package/dist/colors/index.js +72 -0
  19. package/dist/colors/jmol-colors.json +111 -0
  20. package/dist/colors/muted-colors.json +111 -0
  21. package/dist/colors/pastel-colors.json +111 -0
  22. package/dist/colors/vesta-colors.json +111 -0
  23. package/dist/composition/BarChart.svelte +260 -0
  24. package/dist/composition/BarChart.svelte.d.ts +33 -0
  25. package/dist/composition/BubbleChart.svelte +166 -0
  26. package/dist/composition/BubbleChart.svelte.d.ts +30 -0
  27. package/dist/composition/Composition.svelte +73 -0
  28. package/dist/composition/Composition.svelte.d.ts +27 -0
  29. package/dist/composition/PieChart.svelte +236 -0
  30. package/dist/composition/PieChart.svelte.d.ts +36 -0
  31. package/dist/composition/index.d.ts +5 -0
  32. package/dist/composition/index.js +5 -0
  33. package/dist/composition/parse.d.ts +14 -0
  34. package/dist/composition/parse.js +307 -0
  35. package/dist/element/ElementHeading.svelte +21 -0
  36. package/dist/element/ElementHeading.svelte.d.ts +8 -0
  37. package/dist/element/ElementPhoto.svelte +56 -0
  38. package/dist/element/ElementPhoto.svelte.d.ts +9 -0
  39. package/dist/element/ElementStats.svelte +73 -0
  40. package/dist/element/ElementStats.svelte.d.ts +8 -0
  41. package/dist/element/ElementTile.svelte +449 -0
  42. package/dist/element/ElementTile.svelte.d.ts +25 -0
  43. package/dist/element/data.d.ts +4958 -0
  44. package/dist/element/data.js +5628 -0
  45. package/dist/element/index.d.ts +4 -0
  46. package/dist/element/index.js +4 -0
  47. package/dist/icons.d.ts +435 -0
  48. package/dist/icons.js +435 -0
  49. package/dist/index.d.ts +82 -0
  50. package/dist/index.js +43 -0
  51. package/dist/io/decompress.d.ts +16 -0
  52. package/dist/io/decompress.js +78 -0
  53. package/dist/io/export.d.ts +9 -0
  54. package/dist/io/export.js +205 -0
  55. package/dist/io/parse.d.ts +53 -0
  56. package/dist/io/parse.js +747 -0
  57. package/dist/labels.d.ts +31 -0
  58. package/dist/labels.js +209 -0
  59. package/dist/material/MaterialCard.svelte +135 -0
  60. package/dist/material/MaterialCard.svelte.d.ts +10 -0
  61. package/dist/material/SymmetryCard.svelte +23 -0
  62. package/dist/material/SymmetryCard.svelte.d.ts +9 -0
  63. package/dist/material/index.d.ts +2 -0
  64. package/dist/material/index.js +2 -0
  65. package/dist/math.d.ts +24 -0
  66. package/dist/math.js +216 -0
  67. package/dist/periodic-table/PeriodicTable.svelte +284 -0
  68. package/dist/periodic-table/PeriodicTable.svelte.d.ts +50 -0
  69. package/dist/periodic-table/PropertySelect.svelte +20 -0
  70. package/dist/periodic-table/PropertySelect.svelte.d.ts +13 -0
  71. package/dist/periodic-table/TableInset.svelte +18 -0
  72. package/dist/periodic-table/TableInset.svelte.d.ts +9 -0
  73. package/dist/periodic-table/index.d.ts +9 -0
  74. package/dist/periodic-table/index.js +3 -0
  75. package/dist/plot/ColorBar.svelte +414 -0
  76. package/dist/plot/ColorBar.svelte.d.ts +22 -0
  77. package/dist/plot/ColorScaleSelect.svelte +31 -0
  78. package/dist/plot/ColorScaleSelect.svelte.d.ts +15 -0
  79. package/dist/plot/ElementScatter.svelte +38 -0
  80. package/dist/plot/ElementScatter.svelte.d.ts +14 -0
  81. package/dist/plot/Line.svelte +42 -0
  82. package/dist/plot/Line.svelte.d.ts +15 -0
  83. package/dist/plot/PlotLegend.svelte +206 -0
  84. package/dist/plot/PlotLegend.svelte.d.ts +18 -0
  85. package/dist/plot/ScatterPlot.svelte +1753 -0
  86. package/dist/plot/ScatterPlot.svelte.d.ts +114 -0
  87. package/dist/plot/ScatterPlotControls.svelte +505 -0
  88. package/dist/plot/ScatterPlotControls.svelte.d.ts +33 -0
  89. package/dist/plot/ScatterPoint.svelte +72 -0
  90. package/dist/plot/ScatterPoint.svelte.d.ts +17 -0
  91. package/dist/plot/index.d.ts +168 -0
  92. package/dist/plot/index.js +46 -0
  93. package/dist/state.svelte.d.ts +12 -0
  94. package/dist/state.svelte.js +11 -0
  95. package/dist/structure/Bond.svelte +68 -0
  96. package/dist/structure/Bond.svelte.d.ts +13 -0
  97. package/dist/structure/Lattice.svelte +115 -0
  98. package/dist/structure/Lattice.svelte.d.ts +15 -0
  99. package/dist/structure/Structure.svelte +298 -0
  100. package/dist/structure/Structure.svelte.d.ts +28 -0
  101. package/dist/structure/StructureCard.svelte +26 -0
  102. package/dist/structure/StructureCard.svelte.d.ts +9 -0
  103. package/dist/structure/StructureControls.svelte +383 -0
  104. package/dist/structure/StructureControls.svelte.d.ts +23 -0
  105. package/dist/structure/StructureLegend.svelte +130 -0
  106. package/dist/structure/StructureLegend.svelte.d.ts +17 -0
  107. package/dist/structure/StructureScene.svelte +331 -0
  108. package/dist/structure/StructureScene.svelte.d.ts +47 -0
  109. package/dist/structure/bonding.d.ts +16 -0
  110. package/dist/structure/bonding.js +150 -0
  111. package/dist/structure/index.d.ts +98 -0
  112. package/dist/structure/index.js +114 -0
  113. package/dist/structure/pbc.d.ts +6 -0
  114. package/dist/structure/pbc.js +72 -0
  115. package/dist/trajectory/Sidebar.svelte +412 -0
  116. package/dist/trajectory/Sidebar.svelte.d.ts +14 -0
  117. package/dist/trajectory/Trajectory.svelte +1084 -0
  118. package/dist/trajectory/Trajectory.svelte.d.ts +49 -0
  119. package/dist/trajectory/TrajectoryError.svelte +120 -0
  120. package/dist/trajectory/TrajectoryError.svelte.d.ts +12 -0
  121. package/dist/trajectory/extract.d.ts +5 -0
  122. package/dist/trajectory/extract.js +157 -0
  123. package/dist/trajectory/index.d.ts +16 -0
  124. package/dist/trajectory/index.js +49 -0
  125. package/dist/trajectory/parse.d.ts +13 -0
  126. package/dist/trajectory/parse.js +1093 -0
  127. package/dist/trajectory/plotting.d.ts +12 -0
  128. package/dist/trajectory/plotting.js +148 -0
  129. package/license +21 -0
  130. package/package.json +131 -0
  131. package/readme.md +95 -0
@@ -0,0 +1,1753 @@
1
+ <script lang="ts">import { cells_3x3, corner_cells, Line, symbol_names } from '..';
2
+ import { luminance } from '../labels';
3
+ import { ColorBar, LOG_MIN_EPS, PlotLegend, ScatterPlotControls, ScatterPoint, } from './';
4
+ import { extent, range } from 'd3-array';
5
+ import { forceCollide, forceLink, forceSimulation } from 'd3-force';
6
+ import { format } from 'd3-format';
7
+ import { scaleLinear, scaleLog, scaleSequential, scaleSequentialLog, scaleTime, } from 'd3-scale';
8
+ import * as d3_sc from 'd3-scale-chromatic';
9
+ import { timeFormat } from 'd3-time-format';
10
+ import { Tween } from 'svelte/motion';
11
+ let { series = [], style = ``, 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
12
+ 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 = `line+points`, x_format = $bindable(``), y_format = $bindable(``), tooltip, 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 = {
13
+ type: `linear`,
14
+ scheme: `interpolateViridis`,
15
+ value_range: undefined,
16
+ }, 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, show_controls = false, controls_open = $bindable(false), plot_controls,
17
+ // Style control props
18
+ point_size = $bindable(4), point_color = $bindable(`#4682b4`), 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(`#4682b4`), line_opacity = $bindable(1), line_dash = $bindable(undefined), show_points = $bindable(true), show_lines = $bindable(true), selected_series_idx = $bindable(0), } = $props();
19
+ let width = $state(0);
20
+ let height = $state(0);
21
+ let svg_element = $state(null); // Bind the SVG element
22
+ let svg_bounding_box = $state(null); // Store SVG bounds during drag
23
+ // Process series to ensure single visible series are always on y1 (left) axis.
24
+ // This prevents the scenario where the left y-axis is empty while the right y-axis
25
+ // has the only visible series, which would create a confusing plot layout.
26
+ let processed_series = $derived.by(() => {
27
+ if (series.length === 0)
28
+ return [];
29
+ // Count visible series (filter out null/undefined series)
30
+ const visible_series = series.filter((s) => s && (s.visible ?? true));
31
+ // If only one series is visible, ensure it's on y1 axis
32
+ if (visible_series.length === 1) {
33
+ return series.map((s) => {
34
+ if (s && (s.visible ?? true) && s.y_axis === `y2`) {
35
+ // Reassign single visible series from y2 to y1
36
+ return { ...s, y_axis: `y1` };
37
+ }
38
+ return s;
39
+ });
40
+ }
41
+ // For multiple visible series, keep original assignments
42
+ return series;
43
+ });
44
+ // Stable ID assignment for series - computed once and cached
45
+ let next_id = 0;
46
+ const series_id_cache = new WeakMap();
47
+ let series_with_ids = $derived.by(() => {
48
+ return processed_series.map((s) => {
49
+ if (!s || typeof s !== `object`)
50
+ return s;
51
+ if (`_id` in s && typeof s._id === `number`)
52
+ return s; // Already has stable ID
53
+ // Check cache first
54
+ if (series_id_cache.has(s)) {
55
+ return { ...s, _id: series_id_cache.get(s) };
56
+ }
57
+ // Assign and cache new stable ID
58
+ const new_id = next_id++;
59
+ series_id_cache.set(s, new_id);
60
+ return { ...s, _id: new_id };
61
+ });
62
+ });
63
+ // Controls component reference to access internal states
64
+ let controls_component = $state(undefined);
65
+ // State for rectangle zoom selection
66
+ let drag_start_coords = $state(null);
67
+ let drag_current_coords = $state(null);
68
+ let initial_x_range = $state([0, 1]);
69
+ let initial_y_range = $state([0, 1]);
70
+ let initial_y2_range = $state([0, 1]);
71
+ let current_x_range = $state([0, 1]);
72
+ let current_y_range = $state([0, 1]);
73
+ let current_y2_range = $state([0, 1]);
74
+ let previous_series_visibility = $state(null); // State to store visibility before isolation
75
+ // State to hold the calculated label positions after simulation
76
+ let label_positions = $state({});
77
+ // State for initial (non-responsive) legend placement
78
+ let initial_legend_cell = $state(null);
79
+ let is_initial_legend_placement_calculated = $state(false);
80
+ // State for legend dragging
81
+ let legend_is_dragging = $state(false);
82
+ let legend_drag_offset = $state({ x: 0, y: 0 });
83
+ let legend_manual_position = $state(null);
84
+ // Module-level constants to avoid repeated allocations
85
+ const DEFAULT_MARGIN = { t: 10, l: 10, b: 10, r: 10 };
86
+ const X_FACTORS = {
87
+ left: { anchor: 0, transform: `0` },
88
+ center: { anchor: 0.5, transform: `-50%` },
89
+ right: { anchor: 1, transform: `-100%` },
90
+ };
91
+ const Y_FACTORS = {
92
+ top: { anchor: 0, transform: `0` },
93
+ middle: { anchor: 0.5, transform: `-50%` },
94
+ bottom: { anchor: 1, transform: `-100%` },
95
+ };
96
+ function normalize_margin(margin) {
97
+ if (typeof margin === `number`) {
98
+ return { t: margin, l: margin, b: margin, r: margin };
99
+ }
100
+ return { ...DEFAULT_MARGIN, ...margin };
101
+ }
102
+ function get_placement_styles(// based on grid cell
103
+ cell, item_type) {
104
+ if (!cell || !width || !height)
105
+ return { left: 0, top: 0, transform: `` };
106
+ const effective_pad = { t: 0, b: 0, l: 0, r: 0, ...padding };
107
+ const plot_width = width - effective_pad.l - effective_pad.r;
108
+ const plot_height = height - effective_pad.t - effective_pad.b;
109
+ const margin = normalize_margin(item_type === `legend` ? legend?.margin : color_bar?.margin);
110
+ const [y_part, x_part] = cell.split(`-`);
111
+ const x_factor = X_FACTORS[x_part];
112
+ const y_factor = Y_FACTORS[y_part];
113
+ const base_x = effective_pad.l + plot_width * x_factor.anchor;
114
+ const base_y = effective_pad.t + plot_height * y_factor.anchor;
115
+ // Adjust base position by margin depending on anchor point
116
+ const target_x = base_x +
117
+ (x_part === `left` ? margin.l : x_part === `right` ? -margin.r : 0);
118
+ const target_y = base_y +
119
+ (y_part === `top` ? margin.t : y_part === `bottom` ? -margin.b : 0);
120
+ const transform = x_factor.transform !== `0` || y_factor.transform !== `0`
121
+ ? `translate(${x_factor.transform}, ${y_factor.transform})`
122
+ : ``;
123
+ return { left: target_x, top: target_y, transform };
124
+ }
125
+ // Create raw data points from all series
126
+ let all_points = $derived(series_with_ids
127
+ .filter(Boolean)
128
+ .flatMap(({ x: xs, y: ys }) => xs.map((x, idx) => ({ x, y: ys[idx] }))));
129
+ // Separate points by y-axis for range calculations
130
+ let y1_points = $derived(series_with_ids
131
+ .filter(Boolean)
132
+ .filter((s) => (s.visible ?? true) && (s.y_axis ?? `y1`) === `y1`) // Only visible y1 series
133
+ .flatMap(({ x: xs, y: ys }) => xs.map((x, idx) => ({ x, y: ys[idx] }))));
134
+ let y2_points = $derived(series_with_ids
135
+ .filter(Boolean)
136
+ .filter((s) => (s.visible ?? true) && s.y_axis === `y2`) // Only visible y2 series
137
+ .flatMap(({ x: xs, y: ys }) => xs.map((x, idx) => ({ x, y: ys[idx] }))));
138
+ let pad = $derived({ t: 5, b: 50, l: 50, r: 20, ...padding });
139
+ // Calculate plot area center coordinates
140
+ let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2);
141
+ let plot_center_y = $derived(pad.t + (height - pad.b - pad.t) / 2);
142
+ // Compute data color values for color scaling
143
+ let all_color_values = $derived(series_with_ids.filter(Boolean).flatMap((srs) => srs.color_values?.filter(Boolean) || []));
144
+ // Helper for computing nice data ranges with D3's nice() function
145
+ function get_nice_data_range(points, get_value, lim, scale_type, is_time = false, padding_factor) {
146
+ const [min_lim, max_lim] = lim;
147
+ const [min_ext, max_ext] = extent(points, get_value);
148
+ let data_min = min_lim ?? min_ext ?? 0;
149
+ let data_max = max_lim ?? max_ext ?? 1;
150
+ // Apply padding *only if* limits were NOT provided
151
+ if (min_lim === null && max_lim === null && points.length > 0) {
152
+ if (data_min !== data_max) {
153
+ // Apply percentage padding based on scale type if there's a range
154
+ const span = data_max - data_min;
155
+ if (is_time) {
156
+ const padding_ms = span * padding_factor;
157
+ data_min = data_min - padding_ms;
158
+ data_max = data_max + padding_ms;
159
+ }
160
+ else if (scale_type === `log`) {
161
+ const log_min = Math.log10(Math.max(data_min, 1e-10));
162
+ const log_max = Math.log10(Math.max(data_max, 1e-10));
163
+ const log_span = log_max - log_min;
164
+ data_min = Math.pow(10, log_min - log_span * padding_factor);
165
+ data_max = Math.pow(10, log_max + log_span * padding_factor);
166
+ }
167
+ else {
168
+ // Linear scale
169
+ const padding_abs = span * padding_factor;
170
+ data_min = data_min - padding_abs;
171
+ data_max = data_max + padding_abs;
172
+ }
173
+ }
174
+ else {
175
+ // Handle single data point case with fixed relative padding
176
+ if (is_time) {
177
+ const one_day = 86_400_000; // milliseconds in a day
178
+ data_min = data_min - one_day;
179
+ data_max = data_max + one_day;
180
+ }
181
+ else if (scale_type === `log`) {
182
+ data_min = Math.max(1e-10, data_min / 1.1); // 10% multiplicative padding
183
+ data_max = data_max * 1.1;
184
+ }
185
+ else {
186
+ const padding_abs = data_min === 0 ? 1 : Math.abs(data_min * 0.1); // 10% additive padding, or 1 if value is 0
187
+ data_min = data_min - padding_abs;
188
+ data_max = data_max + padding_abs;
189
+ }
190
+ }
191
+ }
192
+ // If time or no range after padding, return the (potentially padded) domain directly
193
+ if (is_time || data_min === data_max)
194
+ return [data_min, data_max];
195
+ // Use D3's nice() to create pretty boundaries
196
+ // Create the scale with the *padded* data domain
197
+ const scale = scale_type === `log`
198
+ ? scaleLog().domain([
199
+ Math.max(data_min, LOG_MIN_EPS),
200
+ Math.max(data_max, data_min * 1.1),
201
+ ]) // Ensure log domain > 0
202
+ : scaleLinear().domain([data_min, data_max]);
203
+ scale.nice();
204
+ return scale.domain();
205
+ }
206
+ // Compute auto ranges based on data and limits
207
+ let auto_x_range = $derived(get_nice_data_range(all_points, (point) => point.x, x_lim, x_scale_type, x_format?.startsWith(`%`) || false, range_padding));
208
+ let auto_y_range = $derived(get_nice_data_range(y1_points, (point) => point.y, y_lim, y_scale_type, false, range_padding));
209
+ let auto_y2_range = $derived(y2_points.length > 0
210
+ ? get_nice_data_range(y2_points, (point) => point.y, y2_lim, y2_scale_type, false, range_padding)
211
+ : [0, 1]);
212
+ // Store initial ranges and initialize current ranges
213
+ $effect(() => {
214
+ const new_init_x = x_range ?? auto_x_range;
215
+ const new_init_y = y_range ?? auto_y_range;
216
+ const new_init_y2 = y2_range ?? auto_y2_range;
217
+ // Only update if the initial range fundamentally changes, force type
218
+ if (new_init_x[0] !== initial_x_range[0] || new_init_x[1] !== initial_x_range[1]) {
219
+ initial_x_range = new_init_x;
220
+ current_x_range = new_init_x;
221
+ }
222
+ if (new_init_y[0] !== initial_y_range[0] || new_init_y[1] !== initial_y_range[1]) {
223
+ initial_y_range = new_init_y;
224
+ current_y_range = new_init_y;
225
+ }
226
+ if (new_init_y2[0] !== initial_y2_range[0] ||
227
+ new_init_y2[1] !== initial_y2_range[1]) {
228
+ initial_y2_range = new_init_y2;
229
+ current_y2_range = new_init_y2;
230
+ }
231
+ });
232
+ let [x_min, x_max] = $derived(current_x_range); // Use current range for scales/axes
233
+ let [y_min, y_max] = $derived(current_y_range); // Use current range for scales/axes
234
+ let [y2_min, y2_max] = $derived(current_y2_range); // Use current range for scales/axes
235
+ // Create auto color range
236
+ let auto_color_range = $derived(
237
+ // Ensure we only calculate extent on actual numbers, filtering out nulls/undefined
238
+ all_color_values.length > 0
239
+ ? extent(all_color_values.filter((val) => val != null))
240
+ : [0, 1]);
241
+ // Create scale functions
242
+ let x_scale_fn = $derived(x_format?.startsWith(`%`)
243
+ ? scaleTime()
244
+ .domain([new Date(x_min), new Date(x_max)])
245
+ .range([pad.l, width - pad.r])
246
+ : x_scale_type === `log`
247
+ ? scaleLog()
248
+ .domain([x_min, x_max])
249
+ .range([pad.l, width - pad.r])
250
+ : scaleLinear()
251
+ .domain([x_min, x_max])
252
+ .range([pad.l, width - pad.r]));
253
+ let y_scale_fn = $derived(y_scale_type === `log`
254
+ ? scaleLog()
255
+ .domain([y_min, y_max])
256
+ .range([height - pad.b, pad.t])
257
+ : scaleLinear()
258
+ .domain([y_min, y_max])
259
+ .range([height - pad.b, pad.t]));
260
+ let y2_scale_fn = $derived(y2_scale_type === `log`
261
+ ? scaleLog()
262
+ .domain([y2_min, y2_max])
263
+ .range([height - pad.b, pad.t])
264
+ : scaleLinear()
265
+ .domain([y2_min, y2_max])
266
+ .range([height - pad.b, pad.t]));
267
+ // Size scale function
268
+ let size_scale_fn = $derived.by(() => {
269
+ const [min_radius, max_radius] = size_scale.radius_range ?? [2, 10];
270
+ // Calculate all size values directly here
271
+ const current_all_size_values = series_with_ids
272
+ .filter(Boolean)
273
+ .flatMap(({ size_values }) => size_values?.filter(Boolean) || []);
274
+ // Calculate auto size range directly here
275
+ const current_auto_size_range = current_all_size_values.length > 0
276
+ ? extent(current_all_size_values.filter((val) => val != null))
277
+ : [0, 1];
278
+ const [min_val, max_val] = size_scale.value_range ??
279
+ current_auto_size_range;
280
+ // Ensure domain is valid, especially for log scale
281
+ const safe_min_val = min_val ?? 0;
282
+ const safe_max_val = max_val ?? (safe_min_val > 0 ? safe_min_val * 1.1 : 1); // Handle zero/single value case
283
+ return size_scale.type === `log`
284
+ ? scaleLog()
285
+ .domain([
286
+ Math.max(safe_min_val, LOG_MIN_EPS),
287
+ Math.max(safe_max_val, safe_min_val * 1.1),
288
+ ])
289
+ .range([min_radius, max_radius])
290
+ .clamp(true) // Prevent sizes outside the specified pixel range
291
+ : scaleLinear()
292
+ .domain([safe_min_val, safe_max_val])
293
+ .range([min_radius, max_radius])
294
+ .clamp(true); // Prevent sizes outside the specified pixel range
295
+ });
296
+ // Color scale function
297
+ let color_scale_fn = $derived.by(() => {
298
+ const color_func_name = color_scale.scheme;
299
+ const interpolator = typeof d3_sc[color_func_name] === `function`
300
+ ? d3_sc[color_func_name]
301
+ : d3_sc.interpolateViridis;
302
+ const [min_val, max_val] = color_scale.value_range ??
303
+ auto_color_range;
304
+ return color_scale.type === `log`
305
+ ? scaleSequentialLog(interpolator).domain([
306
+ Math.max(min_val, LOG_MIN_EPS),
307
+ Math.max(max_val, min_val * 1.1),
308
+ ])
309
+ : scaleSequential(interpolator).domain([min_val, max_val]);
310
+ });
311
+ // Filter series data to only include points within bounds and augment with internal data
312
+ let filtered_series = $derived(series_with_ids
313
+ .map((data_series, series_idx) => {
314
+ if (!(data_series?.visible ?? true)) {
315
+ return {
316
+ ...data_series,
317
+ visible: false,
318
+ filtered_data: [],
319
+ };
320
+ }
321
+ if (!data_series) {
322
+ // Return empty data consistent with DataSeries structure
323
+ return {
324
+ x: [],
325
+ y: [],
326
+ visible: true, // Assume visible if undefined but we somehow process it
327
+ filtered_data: [],
328
+ _id: next_id++,
329
+ };
330
+ }
331
+ const { x: xs, y: ys, color_values, size_values, ...rest } = data_series;
332
+ // Process points internally, adding properties beyond the base Point type
333
+ const processed_points = xs.map((x, point_idx) => {
334
+ const y = ys[point_idx];
335
+ const color_value = color_values?.[point_idx];
336
+ const size_value = size_values?.[point_idx]; // Get size value for the point
337
+ // Helper to process array or scalar properties
338
+ const process_prop = (prop, point_idx) => {
339
+ if (!prop)
340
+ return undefined;
341
+ // If prop is an array, return the element at the point_idx, otherwise return the prop itself (scalar apply-to-all)
342
+ // prop[point_idx] can be undefined if point_idx out of bounds
343
+ return Array.isArray(prop) ? prop[point_idx] : prop;
344
+ };
345
+ return {
346
+ x,
347
+ y,
348
+ color_value,
349
+ metadata: process_prop(rest.metadata, point_idx),
350
+ point_style: process_prop(rest.point_style, point_idx),
351
+ point_hover: process_prop(rest.point_hover, point_idx),
352
+ point_label: process_prop(rest.point_label, point_idx),
353
+ point_offset: process_prop(rest.point_offset, point_idx),
354
+ series_idx,
355
+ point_idx,
356
+ size_value,
357
+ };
358
+ });
359
+ // Filter to points within the plot bounds
360
+ const is_valid_dim = (val, min, max) => val !== null && val !== undefined && !isNaN(val) && val >= min && val <= max;
361
+ // Determine which y-range to use based on series y_axis property
362
+ const [series_y_min, series_y_max] = (data_series.y_axis ?? `y1`) === `y2`
363
+ ? [y2_min, y2_max]
364
+ : [y_min, y_max];
365
+ const filtered_data_with_extras = processed_points.filter((pt) => is_valid_dim(pt.x, x_min, x_max) &&
366
+ is_valid_dim(pt.y, series_y_min, series_y_max));
367
+ // Return structure consistent with DataSeries but acknowledge internal data structure (filtered_data)
368
+ return {
369
+ ...data_series,
370
+ visible: true, // Mark series as visible here
371
+ filtered_data: filtered_data_with_extras,
372
+ };
373
+ })
374
+ // Filter series end up completely empty after point filtering
375
+ .filter((series_data) => series_data.filtered_data.length > 0));
376
+ // Determine axis colors based on visible series
377
+ let axis_colors = $derived.by(() => {
378
+ const visible_series = filtered_series.filter((s) => s.visible !== false);
379
+ // Count series by axis and get their colors
380
+ const y1_series = visible_series.filter((s) => (s.y_axis ?? `y1`) === `y1`);
381
+ const y2_series = visible_series.filter((s) => s.y_axis === `y2`);
382
+ // Helper to get series color
383
+ const get_series_color = (series) => {
384
+ // Check line color first, then point color
385
+ if (series.line_style?.stroke)
386
+ return series.line_style.stroke;
387
+ const first_point_style = Array.isArray(series.point_style)
388
+ ? series.point_style[0]
389
+ : series.point_style;
390
+ if (first_point_style?.fill)
391
+ return first_point_style.fill;
392
+ if (first_point_style?.stroke)
393
+ return first_point_style.stroke;
394
+ // Fallback to color scale if available
395
+ const first_color_value = series.color_values?.[0];
396
+ if (first_color_value != null)
397
+ return color_scale_fn(first_color_value);
398
+ return null; // No color found
399
+ };
400
+ return {
401
+ y1: y1_series.length === 1 ? get_series_color(y1_series[0]) : null,
402
+ y2: y2_series.length >= 1 ? get_series_color(y2_series[0]) : null,
403
+ };
404
+ });
405
+ // Calculate point counts per 3x3 grid cell
406
+ let grid_cell_counts = $derived.by(() => {
407
+ const counts = cells_3x3.reduce((acc, cell) => {
408
+ acc[cell] = 0;
409
+ return acc;
410
+ }, {});
411
+ if (!width || !height || !filtered_series)
412
+ return counts;
413
+ const plot_width = width - pad.l - pad.r;
414
+ const plot_height = height - pad.t - pad.b;
415
+ const x_boundary1 = pad.l + plot_width / 3;
416
+ const x_boundary2 = pad.l + (2 * plot_width) / 3;
417
+ const y_boundary1 = pad.t + plot_height / 3;
418
+ const y_boundary2 = pad.t + (2 * plot_height) / 3;
419
+ for (const series_data of filtered_series) {
420
+ if (!series_data?.filtered_data)
421
+ continue;
422
+ for (const point of series_data.filtered_data) {
423
+ const point_x_coord = x_format?.startsWith(`%`)
424
+ ? x_scale_fn(new Date(point.x))
425
+ : x_scale_fn(point.x);
426
+ const point_y_coord = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
427
+ // Determine grid cell parts
428
+ const x_part = point_x_coord < x_boundary1
429
+ ? `left`
430
+ : point_x_coord < x_boundary2
431
+ ? `center`
432
+ : `right`;
433
+ const y_part = point_y_coord < y_boundary1
434
+ ? `top`
435
+ : point_y_coord < y_boundary2
436
+ ? `middle`
437
+ : `bottom`;
438
+ const cell = `${y_part}-${x_part}`;
439
+ counts[cell]++;
440
+ }
441
+ }
442
+ return counts;
443
+ });
444
+ // Prepare data needed for the legend component
445
+ let legend_data = $derived.by(() => {
446
+ return series_with_ids.map((data_series, series_idx) => {
447
+ const is_visible = data_series?.visible ?? true;
448
+ // Prefer top-level label, fallback to metadata label, then default
449
+ const label = data_series?.label ??
450
+ (typeof data_series?.metadata === `object` &&
451
+ data_series.metadata !== null &&
452
+ `label` in data_series.metadata &&
453
+ typeof data_series.metadata.label === `string`
454
+ ? data_series.metadata.label
455
+ : null) ??
456
+ `Series ${series_idx + 1}`;
457
+ const display_style = {
458
+ symbol_type: `Circle`, // Default marker shape (Capitalized)
459
+ symbol_color: `black`, // Default marker color
460
+ line_color: `black`, // Default line color
461
+ };
462
+ const series_markers = data_series?.markers ?? markers;
463
+ // Check point_style (could be object or array)
464
+ const first_point_style = Array.isArray(data_series?.point_style)
465
+ ? data_series.point_style[0] // Handle potential undefined
466
+ : data_series?.point_style; // Handle potential undefined
467
+ if (series_markers?.includes(`points`)) {
468
+ if (first_point_style) {
469
+ // Assign shape only if it's one of the allowed types, else default to circle
470
+ let final_shape = `Circle`; // Default shape
471
+ if (symbol_names.includes(first_point_style.shape)) {
472
+ final_shape = first_point_style.shape;
473
+ }
474
+ display_style.symbol_type = final_shape;
475
+ display_style.symbol_color = first_point_style.fill ??
476
+ display_style.symbol_color; // Use default if nullish
477
+ if (first_point_style.stroke) {
478
+ // Use stroke color if fill is none or transparent
479
+ if (!display_style.symbol_color ||
480
+ display_style.symbol_color === `none` ||
481
+ display_style.symbol_color.startsWith(`rgba(`, 0) // Check if transparent
482
+ ) {
483
+ display_style.symbol_color = first_point_style.stroke;
484
+ }
485
+ }
486
+ }
487
+ // else: keep default display_style.symbol_type/color if no point_style
488
+ }
489
+ else {
490
+ // If no points marker, explicitly remove marker style for legend
491
+ display_style.symbol_type = undefined;
492
+ display_style.symbol_color = undefined;
493
+ }
494
+ // Check line_style
495
+ if (series_markers?.includes(`line`)) {
496
+ display_style.line_color = data_series?.line_style?.stroke ??
497
+ (display_style.symbol_color && series_markers.includes(`points`)
498
+ ? display_style.symbol_color
499
+ : `black`); // Default line color
500
+ display_style.line_dash = data_series?.line_style?.line_dash;
501
+ }
502
+ else {
503
+ // If no line marker, explicitly remove line style for legend
504
+ display_style.line_dash = undefined;
505
+ display_style.line_color = undefined;
506
+ }
507
+ return {
508
+ series_idx,
509
+ label,
510
+ visible: is_visible,
511
+ display_style,
512
+ };
513
+ });
514
+ });
515
+ // Get best placement cells, prioritizing corners first, then by density
516
+ let ranked_grid_cells = $derived.by(() => {
517
+ // Separate corners from non-corners and sort each by density (count)
518
+ const corners = corner_cells
519
+ .map((cell) => ({ cell, count: grid_cell_counts[cell] }))
520
+ .sort((a, b) => a.count - b.count);
521
+ const non_corners = cells_3x3
522
+ .filter((cell) => !corner_cells.includes(cell))
523
+ .map((cell) => ({ cell, count: grid_cell_counts[cell] }))
524
+ .sort((a, b) => a.count - b.count);
525
+ // Return corners first, then non-corners (extract just the cell names)
526
+ return [...corners, ...non_corners].map(({ cell }) => cell);
527
+ });
528
+ // Determine legend and color bar placement
529
+ let legend_cell = $derived.by(() => {
530
+ const should_place = legend != null &&
531
+ (legend_data.length > 1 || JSON.stringify(legend) !== `{}`);
532
+ return should_place && ranked_grid_cells.length > 0 ? ranked_grid_cells[0] : null;
533
+ });
534
+ let color_bar_cell = $derived.by(() => {
535
+ const should_place = color_bar && all_color_values.length > 0;
536
+ return should_place && ranked_grid_cells.length > 0
537
+ ? (ranked_grid_cells.find((cell) => cell !== legend_cell) ?? null)
538
+ : null;
539
+ });
540
+ // Determine the final placement cell for the legend based on mode
541
+ let legend_placement_cell = $derived.by(() => {
542
+ if (!legend_cell)
543
+ return null; // No legend cell assigned
544
+ const is_responsive = legend?.responsive ?? false;
545
+ const style = legend?.wrapper_style ?? ``;
546
+ // Check if position is explicitly set via top/bottom/left/right or position: absolute
547
+ const is_fixed_position = typeof style === `string` &&
548
+ /(\b(top|bottom|left|right)\s*:)|(position\s*:\s*absolute)/.test(style);
549
+ if (is_fixed_position)
550
+ return null; // Fixed position, no auto-placement needed
551
+ if (is_responsive) {
552
+ return legend_cell; // Use the current dynamically best cell
553
+ }
554
+ else {
555
+ // Not responsive, use initial cell if calculated, else the current best as fallback
556
+ return is_initial_legend_placement_calculated
557
+ ? initial_legend_cell
558
+ : legend_cell;
559
+ }
560
+ });
561
+ // Initialize tweened values for color bar position
562
+ const tweened_colorbar_coords = new Tween({ x: 0, y: 0 }, { duration: 400, ...(color_bar?.tween ?? {}) });
563
+ // Initialize tweened values for legend position
564
+ const tweened_legend_coords = new Tween({ x: 0, y: 0 }, { duration: 400, ...(legend?.tween ?? {}) });
565
+ // Effect to calculate the initial grid cell ONCE for non-responsive legend
566
+ // And update legend and color bar tweened positions
567
+ $effect(() => {
568
+ if (!width || !height)
569
+ return; // Need dimensions
570
+ const is_responsive = legend?.responsive ?? false;
571
+ const style = legend?.wrapper_style ?? ``;
572
+ const is_fixed_position = typeof style === `string` &&
573
+ /(\b(top|bottom|left|right)\s*:)|(position\s*:\s*absolute)/.test(style);
574
+ // Calculate initial legend cell if needed
575
+ if (legend_cell &&
576
+ !is_initial_legend_placement_calculated &&
577
+ !is_responsive &&
578
+ !is_fixed_position) {
579
+ initial_legend_cell = legend_cell;
580
+ is_initial_legend_placement_calculated = true;
581
+ }
582
+ // Reset initial calculation flag if mode changes TO responsive or TO fixed
583
+ if ((is_responsive || is_fixed_position) && is_initial_legend_placement_calculated) {
584
+ is_initial_legend_placement_calculated = false;
585
+ initial_legend_cell = null; // Clear stored cell
586
+ }
587
+ // Update Color Bar Position
588
+ if (color_bar_cell) {
589
+ const { left: target_x, top: target_y } = get_placement_styles(color_bar_cell, `colorbar`);
590
+ tweened_colorbar_coords.set({ x: target_x, y: target_y });
591
+ }
592
+ // Update Legend Position using the calculated placement cell (only if not manually positioned)
593
+ if (legend_placement_cell && !legend_manual_position) {
594
+ const { left: target_x, top: target_y } = get_placement_styles(legend_placement_cell, `legend`);
595
+ tweened_legend_coords.set({ x: target_x, y: target_y });
596
+ }
597
+ else if (legend_manual_position && !legend_is_dragging) {
598
+ // Use manual position if set and not currently dragging
599
+ tweened_legend_coords.set(legend_manual_position);
600
+ }
601
+ });
602
+ // Generate logarithmic ticks
603
+ function generate_log_ticks(min, max, ticks_option) {
604
+ // If ticks_option is already an array, use it directly
605
+ if (Array.isArray(ticks_option))
606
+ return ticks_option;
607
+ min = Math.max(min, 1e-10);
608
+ const min_power = Math.floor(Math.log10(min));
609
+ const max_power = Math.ceil(Math.log10(max));
610
+ const extended_min_power = max_power - min_power <= 2 ? min_power - 1 : min_power;
611
+ const extended_max_power = max_power - min_power <= 2 ? max_power + 1 : max_power;
612
+ const powers = range(extended_min_power, extended_max_power + 1).map((p) => Math.pow(10, p));
613
+ // For narrow ranges, include intermediate values
614
+ if (max_power - min_power < 3 &&
615
+ typeof ticks_option === `number` &&
616
+ ticks_option > 5) {
617
+ const detailed_ticks = [];
618
+ powers.forEach((power) => {
619
+ detailed_ticks.push(power);
620
+ if (power * 2 <= Math.pow(10, extended_max_power)) {
621
+ detailed_ticks.push(power * 2);
622
+ }
623
+ if (power * 5 <= Math.pow(10, extended_max_power)) {
624
+ detailed_ticks.push(power * 5);
625
+ }
626
+ });
627
+ return detailed_ticks;
628
+ }
629
+ return powers;
630
+ }
631
+ // Generate axis ticks
632
+ let x_tick_values = $derived.by(() => {
633
+ if (!width || !height)
634
+ return [];
635
+ // If x_ticks is already an array, use it directly
636
+ if (Array.isArray(x_ticks))
637
+ return x_ticks;
638
+ // Time-based ticks
639
+ if (x_format?.startsWith(`%`)) {
640
+ const time_scale = scaleTime().domain([new Date(x_min), new Date(x_max)]);
641
+ let count = 10; // default
642
+ if (typeof x_ticks === `number`) {
643
+ count = x_ticks < 0
644
+ ? Math.ceil((x_max - x_min) / Math.abs(x_ticks) / 86_400_000)
645
+ : x_ticks;
646
+ }
647
+ else if (typeof x_ticks === `string`) {
648
+ count = x_ticks === `day` ? 30 : x_ticks === `month` ? 12 : 10;
649
+ }
650
+ const ticks = time_scale.ticks(count);
651
+ if (typeof x_ticks === `string`) {
652
+ if (x_ticks === `month`) {
653
+ return ticks.filter((d) => d.getDate() === 1).map((d) => d.getTime());
654
+ }
655
+ if (x_ticks === `year`) {
656
+ return ticks
657
+ .filter((d) => d.getMonth() === 0 && d.getDate() === 1)
658
+ .map((d) => d.getTime());
659
+ }
660
+ }
661
+ return ticks.map((d) => d.getTime());
662
+ }
663
+ // Log scale ticks
664
+ if (x_scale_type === `log`)
665
+ return generate_log_ticks(x_min, x_max, x_ticks);
666
+ // Linear scale with interval
667
+ if (typeof x_ticks === `number` && x_ticks < 0) {
668
+ const interval = Math.abs(x_ticks);
669
+ const start = Math.ceil(x_min / interval) * interval;
670
+ return range(start, x_max + interval * 0.1, interval);
671
+ }
672
+ // Default ticks
673
+ const ticks = x_scale_fn.ticks(typeof x_ticks === `number` ? x_ticks : undefined);
674
+ return ticks.map(Number);
675
+ });
676
+ let y_tick_values = $derived.by(() => {
677
+ if (!width || !height)
678
+ return [];
679
+ // If y_ticks is already an array, use it directly
680
+ if (Array.isArray(y_ticks))
681
+ return y_ticks;
682
+ if (y_scale_type === `log`)
683
+ return generate_log_ticks(y_min, y_max, y_ticks);
684
+ if (typeof y_ticks === `number` && y_ticks < 0) {
685
+ const interval = Math.abs(y_ticks);
686
+ const start = Math.ceil(y_min / interval) * interval;
687
+ return range(start, y_max + interval * 0.1, interval);
688
+ }
689
+ const ticks = y_scale_fn.ticks(typeof y_ticks === `number` && y_ticks > 0 ? y_ticks : 5);
690
+ return ticks.map(Number);
691
+ });
692
+ let y2_tick_values = $derived.by(() => {
693
+ if (!width || !height || y2_points.length === 0)
694
+ return [];
695
+ if (y2_scale_type === `log`)
696
+ return generate_log_ticks(y2_min, y2_max, y2_ticks);
697
+ if (typeof y2_ticks === `number` && y2_ticks < 0) {
698
+ const interval = Math.abs(y2_ticks);
699
+ const start = Math.ceil(y2_min / interval) * interval;
700
+ return range(start, y2_max + interval * 0.1, interval);
701
+ }
702
+ const ticks = y2_scale_fn.ticks(typeof y2_ticks === `number` && y2_ticks > 0 ? y2_ticks : 5);
703
+ return ticks.map(Number);
704
+ });
705
+ // Format a value for display
706
+ function format_value(value, formatter) {
707
+ if (!formatter)
708
+ return `${value}`;
709
+ if (formatter.startsWith(`%`))
710
+ return timeFormat(formatter)(new Date(value));
711
+ const formatted = format(formatter)(value);
712
+ // Remove trailing zeros after decimal point
713
+ return formatted.includes(`.`)
714
+ ? formatted.replace(/(\.\d*?)0+$/, `$1`).replace(/\.$/, ``)
715
+ : formatted;
716
+ }
717
+ function get_relative_coords(evt) {
718
+ const svg_box = evt.currentTarget?.getBoundingClientRect();
719
+ if (!svg_box)
720
+ return null;
721
+ return { x: evt.clientX - svg_box.left, y: evt.clientY - svg_box.top };
722
+ }
723
+ // Define global handlers reference for adding/removing listeners
724
+ const on_window_mouse_move = (evt) => {
725
+ if (!drag_start_coords || !svg_bounding_box)
726
+ return; // Exit if not dragging or no bounds
727
+ // Calculate mouse position relative to the stored SVG bounding box
728
+ const current_x = evt.clientX - svg_bounding_box.left;
729
+ const current_y = evt.clientY - svg_bounding_box.top;
730
+ drag_current_coords = { x: current_x, y: current_y };
731
+ // Optional: update tooltip only if inside SVG bounds
732
+ const is_inside_svg = current_x >= 0 &&
733
+ current_x <= svg_bounding_box.width &&
734
+ current_y >= 0 &&
735
+ current_y <= svg_bounding_box.height;
736
+ if (is_inside_svg) {
737
+ // Use the already calculated relative coordinates
738
+ update_tooltip_point(current_x, current_y);
739
+ }
740
+ else
741
+ tooltip_point = null; // Clear tooltip if outside
742
+ };
743
+ const on_window_mouse_up = (_evt) => {
744
+ if (drag_start_coords && drag_current_coords) {
745
+ // Use current scales to invert screen coords to data coords
746
+ const start_data_x_val = x_scale_fn.invert(drag_start_coords.x);
747
+ const end_data_x_val = x_scale_fn.invert(drag_current_coords.x);
748
+ const start_data_y_val = y_scale_fn.invert(drag_start_coords.y);
749
+ const end_data_y_val = y_scale_fn.invert(drag_current_coords.y);
750
+ // Ensure range is not zero and order is correct
751
+ let x1, x2;
752
+ if (start_data_x_val instanceof Date && end_data_x_val instanceof Date) {
753
+ x1 = start_data_x_val.getTime();
754
+ x2 = end_data_x_val.getTime();
755
+ }
756
+ else if (typeof start_data_x_val === `number` &&
757
+ typeof end_data_x_val === `number`) {
758
+ x1 = start_data_x_val;
759
+ x2 = end_data_x_val;
760
+ }
761
+ else {
762
+ console.error(`Mismatched types for x-axis zoom calculation`);
763
+ // Reset states without zooming if types are wrong
764
+ drag_start_coords = null;
765
+ drag_current_coords = null;
766
+ window.removeEventListener(`mousemove`, on_window_mouse_move);
767
+ window.removeEventListener(`mouseup`, on_window_mouse_up);
768
+ return;
769
+ }
770
+ const next_x_range = [Math.min(x1, x2), Math.max(x1, x2)];
771
+ // Y axis is always number
772
+ const next_y_range = [
773
+ Math.min(start_data_y_val, end_data_y_val),
774
+ Math.max(start_data_y_val, end_data_y_val),
775
+ ];
776
+ // Check for minuscule zoom box (e.g., accidental click)
777
+ const min_zoom_size = 5; // Minimum pixels to trigger zoom
778
+ const dx = Math.abs(drag_start_coords.x - drag_current_coords.x);
779
+ const dy = Math.abs(drag_start_coords.y - drag_current_coords.y);
780
+ if (dx > min_zoom_size &&
781
+ dy > min_zoom_size &&
782
+ next_x_range[0] !== next_x_range[1] &&
783
+ next_y_range[0] !== next_y_range[1]) {
784
+ current_x_range = next_x_range;
785
+ current_y_range = next_y_range;
786
+ }
787
+ // If the box is too small, we just reset without zooming (effectively ignoring the drag)
788
+ }
789
+ // Reset states and remove listeners
790
+ drag_start_coords = null;
791
+ drag_current_coords = null;
792
+ svg_bounding_box = null; // Clear stored bounds
793
+ window.removeEventListener(`mousemove`, on_window_mouse_move);
794
+ window.removeEventListener(`mouseup`, on_window_mouse_up);
795
+ document.body.style.cursor = `default`;
796
+ };
797
+ function handle_mouse_down(evt) {
798
+ const coords = get_relative_coords(evt);
799
+ if (!coords || !svg_element)
800
+ return;
801
+ drag_start_coords = coords;
802
+ drag_current_coords = coords; // Initialize current coords
803
+ svg_bounding_box = svg_element.getBoundingClientRect(); // Store bounds on drag start
804
+ // Add listeners to window
805
+ window.addEventListener(`mousemove`, on_window_mouse_move);
806
+ window.addEventListener(`mouseup`, on_window_mouse_up);
807
+ // Prevent text selection during drag
808
+ evt.preventDefault();
809
+ }
810
+ function handle_mouse_leave() {
811
+ // Reset drag state if mouse leaves plot area
812
+ hovered = false;
813
+ tooltip_point = null;
814
+ }
815
+ function handle_double_click() {
816
+ // Reset zoom/pan to initial ranges
817
+ current_x_range = [...initial_x_range];
818
+ current_y_range = [...initial_y_range];
819
+ current_y2_range = [...initial_y2_range];
820
+ }
821
+ // tooltip logic: find closest point and update tooltip state
822
+ function update_tooltip_point(x_rel, y_rel) {
823
+ if (!width || !height)
824
+ return;
825
+ let closest_point_internal = null;
826
+ let closest_series = null;
827
+ let min_screen_dist_sq = Infinity;
828
+ const { threshold_px = 20 } = hover_config; // Use configured threshold
829
+ const hover_threshold_px_sq = threshold_px * threshold_px;
830
+ // Iterate through points to find the closest one in screen coordinates
831
+ for (const series_data of filtered_series) {
832
+ if (!series_data?.filtered_data)
833
+ continue;
834
+ for (const point of series_data.filtered_data) {
835
+ // Calculate screen coordinates of the point
836
+ const point_cx = x_format?.startsWith(`%`)
837
+ ? x_scale_fn(new Date(point.x))
838
+ : x_scale_fn(point.x);
839
+ const point_cy = (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(point.y);
840
+ // Calculate squared screen distance between mouse and point
841
+ const screen_dx = x_rel - point_cx;
842
+ const screen_dy = y_rel - point_cy;
843
+ const screen_distance_sq = screen_dx * screen_dx + screen_dy * screen_dy;
844
+ // Update if this point is closer
845
+ if (screen_distance_sq < min_screen_dist_sq) {
846
+ min_screen_dist_sq = screen_distance_sq;
847
+ closest_point_internal = point;
848
+ closest_series = series_data;
849
+ }
850
+ }
851
+ }
852
+ // Check if the closest point is within the hover threshold
853
+ if (closest_point_internal &&
854
+ closest_series &&
855
+ min_screen_dist_sq <= hover_threshold_px_sq) {
856
+ tooltip_point = closest_point_internal;
857
+ // Construct object matching change signature
858
+ const { x, y, metadata } = closest_point_internal; // Extract base Point props
859
+ // Call change handler with closest point's data
860
+ change({ x, y, metadata, series: closest_series });
861
+ }
862
+ else {
863
+ // No point close enough or no points at all
864
+ tooltip_point = null;
865
+ change(null);
866
+ }
867
+ }
868
+ function on_mouse_move(evt) {
869
+ hovered = true;
870
+ const coords = get_relative_coords(evt);
871
+ if (!coords)
872
+ return;
873
+ update_tooltip_point(coords.x, coords.y);
874
+ }
875
+ // Merge user config with defaults before the effect that uses it
876
+ let actual_label_config = $derived({
877
+ collision_strength: 1.1,
878
+ link_strength: 0.8,
879
+ link_distance: 10,
880
+ placement_ticks: 120,
881
+ link_distance_range: [5, 20], // Default min and max distance (replacing max_link_distance)
882
+ ...label_placement_config,
883
+ });
884
+ $effect(() => {
885
+ if (!width || !height)
886
+ return;
887
+ // 1. Collect nodes for simulation (only those with auto_placement)
888
+ const nodes_to_simulate = [];
889
+ const anchor_nodes = [];
890
+ const links = [];
891
+ filtered_series.forEach((series_data) => {
892
+ series_data.filtered_data.forEach((point) => {
893
+ if (point.point_label?.auto_placement && point.point_label.text) {
894
+ const anchor_x = x_format?.startsWith(`%`)
895
+ ? x_scale_fn(new Date(point.x))
896
+ : x_scale_fn(point.x);
897
+ const anchor_y = y_scale_fn(point.y);
898
+ const id = `${point.series_idx}-${point.point_idx}`;
899
+ // Estimate label size (simple approximation)
900
+ const label_width = point.point_label.text.length * 6 + 10; // Approx 6px per char + padding
901
+ const label_height = 14; // Approx font height + padding
902
+ const label_node = {
903
+ id,
904
+ anchor_x,
905
+ anchor_y,
906
+ point_node: point,
907
+ label_width,
908
+ label_height,
909
+ x: anchor_x + (point.point_label.offset?.x ?? 5), // Start at default offset
910
+ y: anchor_y + (point.point_label.offset?.y ?? 0),
911
+ };
912
+ nodes_to_simulate.push(label_node);
913
+ // Create a fixed anchor node for the link force
914
+ const fixed_anchor_id = `anchor-${id}`;
915
+ // Get the radius for the point, default if not specified
916
+ const point_radius = point.point_style?.radius ?? 3; // Default radius 3
917
+ anchor_nodes.push({
918
+ id: fixed_anchor_id,
919
+ fx: anchor_x,
920
+ fy: anchor_y,
921
+ point_radius,
922
+ });
923
+ // Link label to its fixed anchor
924
+ links.push({ source: id, target: fixed_anchor_id });
925
+ }
926
+ });
927
+ });
928
+ if (nodes_to_simulate.length === 0) {
929
+ label_positions = {};
930
+ return; // No labels to place
931
+ }
932
+ // Combine nodes for the simulation
933
+ const all_simulation_nodes = [
934
+ ...nodes_to_simulate,
935
+ ...anchor_nodes,
936
+ ];
937
+ // 2. Setup and run the simulation
938
+ const simulation = forceSimulation(all_simulation_nodes)
939
+ .force(`link`, forceLink(links)
940
+ .id((d) => d.id)
941
+ .distance(actual_label_config.link_distance)
942
+ .strength(actual_label_config.link_strength)) // Cast d to ensure id exists
943
+ .force(`collide`, forceCollide()
944
+ .radius((d_node) => {
945
+ const node_as_label = d_node;
946
+ const node_as_anchor = d_node; // Use defined AnchorNode type
947
+ if (node_as_label.label_width) {
948
+ const size = Math.max(node_as_label.label_width, node_as_label.label_height) / 2;
949
+ // Check if it's a LabelNode via a unique property
950
+ // Collision radius based on label dimensions
951
+ return size + 2; // +2 buffer
952
+ }
953
+ else if (node_as_anchor.point_radius !== undefined) {
954
+ // Check if it's our AnchorNode
955
+ // Collision radius based on the point's visual radius
956
+ return node_as_anchor.point_radius + 2; // +2 buffer
957
+ }
958
+ return 0; // Should not happen if nodes are constructed correctly
959
+ })
960
+ .strength(actual_label_config.collision_strength))
961
+ .stop();
962
+ // Run simulation for a fixed number of ticks
963
+ simulation.tick(actual_label_config.placement_ticks);
964
+ // 3. Store the final positions, applying link_distance_range constraint
965
+ nodes_to_simulate.forEach((node) => {
966
+ let final_x = node.x;
967
+ let final_y = node.y;
968
+ const dist_range = actual_label_config.link_distance_range;
969
+ if (dist_range) {
970
+ const [min_dist, max_dist] = dist_range;
971
+ const dx = final_x - node.anchor_x;
972
+ const dy = final_y - node.anchor_y;
973
+ const dist_sq = dx * dx + dy * dy;
974
+ const current_dist = Math.sqrt(dist_sq);
975
+ if (max_dist && current_dist > max_dist) {
976
+ // Clamp to max distance
977
+ const scale_factor = max_dist / current_dist;
978
+ final_x = node.anchor_x + dx * scale_factor;
979
+ final_y = node.anchor_y + dy * scale_factor;
980
+ }
981
+ else if (min_dist && current_dist < min_dist && current_dist > 0) {
982
+ // Clamp to min distance (if not directly at anchor point)
983
+ const scale_factor = min_dist / current_dist;
984
+ final_x = node.anchor_x + dx * scale_factor;
985
+ final_y = node.anchor_y + dy * scale_factor;
986
+ }
987
+ }
988
+ label_positions[node.id] = { x: final_x, y: final_y };
989
+ });
990
+ });
991
+ // Helper function to check if two series have compatible units
992
+ function have_compatible_units(series1, series2) {
993
+ const unit1 = series1.unit;
994
+ const unit2 = series2.unit;
995
+ // If either series has no unit, they're compatible
996
+ if (!unit1 || !unit2)
997
+ return true;
998
+ return unit1 === unit2;
999
+ }
1000
+ function resolve_unit_conflicts(series, target_idx) {
1001
+ const target_series = series[target_idx];
1002
+ const target_axis = target_series.y_axis ?? `y1`;
1003
+ return series.map((s, idx) => ({
1004
+ ...s,
1005
+ visible: idx === target_idx ||
1006
+ !(s.visible && (s.y_axis ?? `y1`) === target_axis &&
1007
+ !have_compatible_units(target_series, s)),
1008
+ }));
1009
+ }
1010
+ // Function to toggle series visibility
1011
+ function toggle_series_visibility(series_idx) {
1012
+ if (series_idx >= 0 && series_idx < series.length && series[series_idx]) {
1013
+ const toggled_series = series[series_idx];
1014
+ const new_visibility = !(toggled_series.visible ?? true);
1015
+ if (new_visibility) {
1016
+ series = resolve_unit_conflicts(series, series_idx);
1017
+ }
1018
+ else {
1019
+ // Just toggle visibility normally when hiding
1020
+ series = series.map((s, idx) => {
1021
+ if (idx === series_idx)
1022
+ return { ...s, visible: false };
1023
+ return s;
1024
+ });
1025
+ }
1026
+ }
1027
+ }
1028
+ // Function to handle double-click on legend item
1029
+ function handle_legend_double_click(double_clicked_idx) {
1030
+ const current_visibility = processed_series.map((s) => s?.visible ?? true);
1031
+ const visible_count = current_visibility.filter((v) => v).length;
1032
+ const is_currently_isolated = visible_count === 1 &&
1033
+ current_visibility[double_clicked_idx];
1034
+ if (is_currently_isolated && previous_series_visibility) {
1035
+ // Restore previous visibility state
1036
+ series = series.map((s, idx) => ({
1037
+ ...s,
1038
+ visible: previous_series_visibility[idx],
1039
+ }));
1040
+ previous_series_visibility = null; // Clear memory
1041
+ }
1042
+ else {
1043
+ // Isolate the double-clicked series
1044
+ // Only store previous state if we are actually isolating (more than one series visible)
1045
+ if (visible_count > 1) {
1046
+ previous_series_visibility = [...current_visibility]; // Store current state
1047
+ }
1048
+ series = series.map((s, idx) => ({
1049
+ ...s,
1050
+ visible: idx === double_clicked_idx,
1051
+ }));
1052
+ }
1053
+ }
1054
+ // Legend drag handlers
1055
+ function handle_legend_drag_start(event) {
1056
+ if (!svg_element)
1057
+ return;
1058
+ legend_is_dragging = true;
1059
+ const svg_rect = svg_element.getBoundingClientRect();
1060
+ const current_legend_x = tweened_legend_coords.current.x;
1061
+ const current_legend_y = tweened_legend_coords.current.y;
1062
+ // Calculate offset from mouse to current legend position
1063
+ legend_drag_offset = {
1064
+ x: event.clientX - svg_rect.left - current_legend_x,
1065
+ y: event.clientY - svg_rect.top - current_legend_y,
1066
+ };
1067
+ }
1068
+ function handle_legend_drag(event) {
1069
+ if (!legend_is_dragging || !svg_element)
1070
+ return;
1071
+ const svg_rect = svg_element.getBoundingClientRect();
1072
+ const new_x = event.clientX - svg_rect.left - legend_drag_offset.x;
1073
+ const new_y = event.clientY - svg_rect.top - legend_drag_offset.y;
1074
+ // Constrain to plot bounds
1075
+ const constrained_x = Math.max(0, Math.min(width - 100, new_x)); // Assume legend width ~100px
1076
+ const constrained_y = Math.max(0, Math.min(height - 50, new_y)); // Assume legend height ~50px
1077
+ legend_manual_position = { x: constrained_x, y: constrained_y };
1078
+ // Update tweened position immediately during drag
1079
+ tweened_legend_coords.set({ x: constrained_x, y: constrained_y }, { duration: 0 });
1080
+ }
1081
+ function handle_legend_drag_end(_event) {
1082
+ legend_is_dragging = false;
1083
+ }
1084
+ function get_screen_coords(point, series) {
1085
+ // convert data coordinates to potentially non-finite screen coordinates
1086
+ const screen_x = x_format?.startsWith(`%`)
1087
+ ? x_scale_fn(new Date(point.x))
1088
+ : x_scale_fn(point.x);
1089
+ const y_val = point.y;
1090
+ // Determine which y-scale to use based on series y_axis property
1091
+ const use_y2 = series?.y_axis === `y2`;
1092
+ const y_scale = use_y2 ? y2_scale_fn : y_scale_fn;
1093
+ const min_domain_y = use_y2
1094
+ ? y2_scale_type === `log` ? y_scale.domain()[0] : -Infinity
1095
+ : y_scale_type === `log`
1096
+ ? y_scale.domain()[0]
1097
+ : -Infinity;
1098
+ const safe_y_val = use_y2
1099
+ ? y2_scale_type === `log` ? Math.max(y_val, min_domain_y) : y_val
1100
+ : y_scale_type === `log`
1101
+ ? Math.max(y_val, min_domain_y)
1102
+ : y_val;
1103
+ const screen_y = y_scale(safe_y_val); // This might be non-finite
1104
+ return [screen_x, screen_y];
1105
+ }
1106
+ let using_controls = $derived(show_controls);
1107
+ let has_multiple_series = $derived(series_with_ids.filter(Boolean).length > 1);
1108
+ </script>
1109
+
1110
+ <div class="scatter" bind:clientWidth={width} bind:clientHeight={height} {style}>
1111
+ {#if width && height}
1112
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1113
+ <svg
1114
+ bind:this={svg_element}
1115
+ onmouseenter={() => (hovered = true)}
1116
+ onmousedown={handle_mouse_down}
1117
+ onmousemove={(evt: MouseEvent) => {
1118
+ // Only find closest point if not actively dragging
1119
+ if (!drag_start_coords) on_mouse_move(evt)
1120
+ }}
1121
+ onmouseleave={handle_mouse_leave}
1122
+ ondblclick={handle_double_click}
1123
+ style:cursor="crosshair"
1124
+ role="img"
1125
+ >
1126
+ <!-- Zero lines -->
1127
+ {#if show_zero_lines}
1128
+ {#if x_min <= 0 && x_max >= 0}
1129
+ {@const zero_x_pos = x_format?.startsWith(`%`)
1130
+ ? x_scale_fn(new Date(0))
1131
+ : x_scale_fn(0)}
1132
+ {#if isFinite(zero_x_pos)}
1133
+ <line
1134
+ y1={pad.t}
1135
+ y2={height - pad.b}
1136
+ x1={zero_x_pos}
1137
+ x2={zero_x_pos}
1138
+ stroke="gray"
1139
+ stroke-width="0.5"
1140
+ />
1141
+ {/if}
1142
+ {/if}
1143
+ {#if y_scale_type === `linear` && y_min < 0 && y_max > 0}
1144
+ {@const zero_y_pos = y_scale_fn(0)}
1145
+ {#if isFinite(zero_y_pos)}
1146
+ <line
1147
+ x1={pad.l}
1148
+ x2={width - pad.r}
1149
+ y1={zero_y_pos}
1150
+ y2={zero_y_pos}
1151
+ stroke="gray"
1152
+ stroke-width="0.5"
1153
+ />
1154
+ {/if}
1155
+ {/if}
1156
+ {/if}
1157
+
1158
+ <defs>
1159
+ <clipPath id="plot-area-clip">
1160
+ <rect
1161
+ x={pad.l}
1162
+ y={pad.t}
1163
+ width={width - pad.l - pad.r}
1164
+ height={height - pad.t - pad.b}
1165
+ />
1166
+ </clipPath>
1167
+ </defs>
1168
+
1169
+ <!-- Lines -->
1170
+ {#if markers?.includes(`line`) && show_lines}
1171
+ {#each filtered_series ?? [] as series_data (series_data._id)}
1172
+ {@const series_markers = series_data.markers ?? markers}
1173
+ <g data-series-id={series_data._id} clip-path="url(#plot-area-clip)">
1174
+ {#if series_markers?.includes(`line`)}
1175
+ {@const all_line_points = series_data.x.map((x, idx) => ({
1176
+ x,
1177
+ y: series_data.y[idx],
1178
+ }))}
1179
+ {@const finite_screen_points = all_line_points
1180
+ .map((point) => get_screen_coords(point, series_data))
1181
+ .filter(([sx, sy]) => isFinite(sx) && isFinite(sy))}
1182
+ {@const apply_line_controls = using_controls &&
1183
+ (!has_multiple_series ||
1184
+ series_data._id === series_with_ids[selected_series_idx]?._id)}
1185
+ <Line
1186
+ points={finite_screen_points}
1187
+ origin={[
1188
+ x_format?.startsWith(`%`)
1189
+ ? x_scale_fn(new Date(x_min))
1190
+ : x_scale_fn(x_min),
1191
+ series_data.y_axis === `y2` ? y2_scale_fn(y2_min) : y_scale_fn(y_min),
1192
+ ]}
1193
+ line_color={apply_line_controls
1194
+ ? line_color ?? `#4682b4`
1195
+ : series_data.line_style?.stroke ??
1196
+ (Array.isArray(series_data.point_style)
1197
+ ? series_data.point_style[0]?.fill
1198
+ : series_data.point_style?.fill) ??
1199
+ (series_data.color_values?.[0] != null
1200
+ ? color_scale_fn(series_data.color_values[0])
1201
+ : `#4682b4`)}
1202
+ line_width={apply_line_controls
1203
+ ? line_width ?? 2
1204
+ : series_data.line_style?.stroke_width ?? 2}
1205
+ line_dash={apply_line_controls ? line_dash : series_data.line_style?.line_dash}
1206
+ area_color="transparent"
1207
+ {line_tween}
1208
+ />
1209
+ {/if}
1210
+ </g>
1211
+ {/each}
1212
+ {/if}
1213
+
1214
+ <!-- Points -->
1215
+ {#if markers?.includes(`points`) && show_points}
1216
+ {#each filtered_series ?? [] as series_data (series_data._id)}
1217
+ {@const series_markers = series_data.markers ?? markers}
1218
+ <g data-series-id={series_data._id}>
1219
+ {#if series_markers?.includes(`points`)}
1220
+ {#each series_data.filtered_data as point ([point.x, point.y])}
1221
+ {@const label_id = `${point.series_idx}-${point.point_idx}`}
1222
+ {@const calculated_label_pos = label_positions[label_id]}
1223
+ {@const label_style = point.point_label ?? {}}
1224
+ {@const final_label = calculated_label_pos
1225
+ ? {
1226
+ ...label_style,
1227
+ offset: {
1228
+ x: calculated_label_pos.x -
1229
+ (x_format?.startsWith(`%`)
1230
+ ? x_scale_fn(new Date(point.x))
1231
+ : x_scale_fn(point.x)),
1232
+ y: calculated_label_pos.y -
1233
+ (series_data.y_axis === `y2`
1234
+ ? y2_scale_fn(point.y)
1235
+ : y_scale_fn(point.y)),
1236
+ },
1237
+ }
1238
+ : label_style}
1239
+ {@const [raw_screen_x, raw_screen_y] = get_screen_coords(
1240
+ point,
1241
+ series_data,
1242
+ )}
1243
+ {@const screen_x = isFinite(raw_screen_x) ? raw_screen_x : x_scale_fn.range()[0]}
1244
+ {@const screen_y = isFinite(raw_screen_y)
1245
+ ? raw_screen_y
1246
+ : (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn).range()[0]}
1247
+ {@const apply_controls = using_controls &&
1248
+ (!has_multiple_series ||
1249
+ series_data._id === series_with_ids[selected_series_idx]?._id)}
1250
+ <ScatterPoint
1251
+ x={screen_x}
1252
+ y={screen_y}
1253
+ is_hovered={tooltip_point !== null &&
1254
+ point.series_idx === tooltip_point.series_idx &&
1255
+ point.point_idx === tooltip_point.point_idx}
1256
+ style={{
1257
+ ...point.point_style,
1258
+ radius: apply_controls
1259
+ ? point_size ?? (point.size_value != null
1260
+ ? size_scale_fn(point.size_value)
1261
+ : point.point_style?.radius ?? 4)
1262
+ : point.size_value != null
1263
+ ? size_scale_fn(point.size_value)
1264
+ : point.point_style?.radius ?? 4,
1265
+ stroke_width: apply_controls
1266
+ ? point_stroke_width ??
1267
+ point.point_style?.stroke_width ?? 1
1268
+ : point.point_style?.stroke_width ?? 1,
1269
+ stroke: apply_controls
1270
+ ? point_stroke_color ??
1271
+ point.point_style?.stroke ?? `#000`
1272
+ : point.point_style?.stroke ?? `#000`,
1273
+ stroke_opacity: apply_controls
1274
+ ? point_stroke_opacity ??
1275
+ point.point_style?.stroke_opacity ?? 1
1276
+ : point.point_style?.stroke_opacity ?? 1,
1277
+ fill_opacity: apply_controls
1278
+ ? point_opacity ??
1279
+ point.point_style?.fill_opacity ?? 1
1280
+ : point.point_style?.fill_opacity ?? 1,
1281
+ }}
1282
+ hover={point.point_hover ?? {}}
1283
+ label={final_label}
1284
+ offset={point.point_offset ?? { x: 0, y: 0 }}
1285
+ {point_tween}
1286
+ origin={{ x: plot_center_x, y: plot_center_y }}
1287
+ --point-fill-color={point.color_value != null
1288
+ ? color_scale_fn(point.color_value)
1289
+ : apply_controls
1290
+ ? point_color ?? point.point_style?.fill ??
1291
+ `#4682b4`
1292
+ : point.point_style?.fill ?? `#4682b4`}
1293
+ {...point_events &&
1294
+ Object.fromEntries(
1295
+ Object.entries(point_events).map(([event_name, handler]) => [
1296
+ event_name,
1297
+ (event: Event) => handler({ point, event }),
1298
+ ]),
1299
+ )}
1300
+ />
1301
+ {/each}
1302
+ {/if}
1303
+ </g>
1304
+ {/each}
1305
+ {/if}
1306
+
1307
+ <!-- X-axis -->
1308
+ <g class="x-axis">
1309
+ {#if width > 0 && height > 0}
1310
+ {#each x_tick_values as tick (tick)}
1311
+ {@const tick_pos_raw = x_format?.startsWith(`%`)
1312
+ ? x_scale_fn(new Date(tick))
1313
+ : x_scale_fn(tick)}
1314
+ {#if isFinite(tick_pos_raw)}
1315
+ // Check if tick position is finite
1316
+ {@const tick_pos = tick_pos_raw}
1317
+ {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
1318
+ <g class="tick" transform="translate({tick_pos}, {height - pad.b})">
1319
+ {#if x_grid}
1320
+ <line
1321
+ y1={-(height - pad.b - pad.t)}
1322
+ y2="0"
1323
+ {...typeof x_grid === `object` ? x_grid : {}}
1324
+ />
1325
+ {/if}
1326
+
1327
+ {#if tick >= x_min && tick <= x_max}
1328
+ {@const { x, y } = x_tick_label_shift}
1329
+ <text {x} {y}>
1330
+ {format_value(tick, x_format)}
1331
+ </text>
1332
+ {/if}
1333
+ </g>
1334
+ {/if}
1335
+ {/if}
1336
+ {/each}
1337
+ {/if}
1338
+
1339
+ <!-- Current frame indicator -->
1340
+ {#if current_x_value !== null && current_x_value !== undefined}
1341
+ {@const current_pos_raw = x_format?.startsWith(`%`)
1342
+ ? x_scale_fn(new Date(current_x_value))
1343
+ : x_scale_fn(current_x_value)}
1344
+ {#if isFinite(current_pos_raw)}
1345
+ {@const current_pos = current_pos_raw}
1346
+ {#if current_pos >= pad.l && current_pos <= width - pad.r}
1347
+ {@const active_tick_height = 7}
1348
+ <rect
1349
+ x={current_pos - 1.5}
1350
+ y={height - pad.b - active_tick_height / 2}
1351
+ width="3"
1352
+ height={active_tick_height}
1353
+ fill="var(--esp-current-frame-color, #ff6b35)"
1354
+ stroke="white"
1355
+ stroke-width="0.5"
1356
+ class="current-frame-indicator"
1357
+ />
1358
+ {/if}
1359
+ {/if}
1360
+ {/if}
1361
+
1362
+ <foreignObject
1363
+ x={width / 2 + (x_label_shift.x ?? 0) - 100}
1364
+ y={height - pad.b - (x_label_shift.y ?? 0) - 10}
1365
+ width="200"
1366
+ height="20"
1367
+ >
1368
+ <div class="axis-label x-label">
1369
+ {@html x_label ?? ``}
1370
+ </div>
1371
+ </foreignObject>
1372
+ </g>
1373
+
1374
+ <!-- Y-axis -->
1375
+ <g class="y-axis">
1376
+ {#if width > 0 && height > 0}
1377
+ {#each y_tick_values as tick, idx (tick)}
1378
+ {@const tick_pos_raw = y_scale_fn(tick)}
1379
+ {#if isFinite(tick_pos_raw)}
1380
+ // Check if tick position is finite
1381
+ {@const tick_pos = tick_pos_raw}
1382
+ {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1383
+ <g class="tick" transform="translate({pad.l}, {tick_pos})">
1384
+ {#if y_grid}
1385
+ <line
1386
+ x1="0"
1387
+ x2={width - pad.l - pad.r}
1388
+ {...typeof y_grid === `object` ? y_grid : {}}
1389
+ />
1390
+ {/if}
1391
+
1392
+ {#if tick >= y_min && tick <= y_max}
1393
+ {@const { x, y } = y_tick_label_shift}
1394
+ <text
1395
+ {x}
1396
+ {y}
1397
+ text-anchor="end"
1398
+ style:fill={axis_colors.y1 || undefined}
1399
+ >
1400
+ {format_value(tick, y_format)}
1401
+ {#if y_unit && idx === 0}
1402
+ &zwnj;&ensp;{y_unit}
1403
+ {/if}
1404
+ </text>
1405
+ {/if}
1406
+ </g>
1407
+ {/if}
1408
+ {/if}
1409
+ {/each}
1410
+ {/if}
1411
+
1412
+ {#if height > 0}
1413
+ <foreignObject
1414
+ x={-100}
1415
+ y={-10}
1416
+ width="200"
1417
+ height="20"
1418
+ transform="rotate(-90, {y_label_shift.y ?? 20}, {pad.t +
1419
+ (height - pad.t - pad.b) / 2 +
1420
+ (y_label_shift.x ?? 0)}) translate({y_label_shift.y ?? 20}, {pad.t +
1421
+ (height - pad.t - pad.b) / 2 +
1422
+ (y_label_shift.x ?? 0)})"
1423
+ >
1424
+ <div class="axis-label y-label" style:color={axis_colors.y1 || undefined}>
1425
+ {@html y_label ?? ``}
1426
+ </div>
1427
+ </foreignObject>
1428
+ {/if}
1429
+ </g>
1430
+
1431
+ <!-- Y2-axis (Right) -->
1432
+ {#if y2_points.length > 0}
1433
+ <g class="y2-axis">
1434
+ {#if width > 0 && height > 0}
1435
+ {#each y2_tick_values as tick, idx (tick)}
1436
+ {@const tick_pos_raw = y2_scale_fn(tick)}
1437
+ {#if isFinite(tick_pos_raw)}
1438
+ // Check if tick position is finite
1439
+ {@const tick_pos = tick_pos_raw}
1440
+ {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
1441
+ <g class="tick" transform="translate({width - pad.r}, {tick_pos})">
1442
+ {#if y2_grid}
1443
+ <line
1444
+ x1={-(width - pad.l - pad.r)}
1445
+ x2="0"
1446
+ {...typeof y2_grid === `object` ? y2_grid : {}}
1447
+ />
1448
+ {/if}
1449
+
1450
+ {#if tick >= y2_min && tick <= y2_max}
1451
+ {@const { x, y } = y2_tick_label_shift}
1452
+ <text
1453
+ {x}
1454
+ {y}
1455
+ text-anchor="start"
1456
+ style:fill={axis_colors.y2 || undefined}
1457
+ >
1458
+ {format_value(tick, y2_format)}
1459
+ {#if y2_unit && idx === 0}
1460
+ &zwnj;&ensp;{y2_unit}
1461
+ {/if}
1462
+ </text>
1463
+ {/if}
1464
+ </g>
1465
+ {/if}
1466
+ {/if}
1467
+ {/each}
1468
+ {/if}
1469
+
1470
+ {#if height > 0 && y2_label}
1471
+ <foreignObject
1472
+ x={-100}
1473
+ y={-10}
1474
+ width="200"
1475
+ height="20"
1476
+ transform="rotate(-90, {width - pad.r + (y2_label_shift.y ?? 0)}, {pad.t +
1477
+ (height - pad.t - pad.b) / 2 +
1478
+ (y2_label_shift.x ?? 0)}) translate({width -
1479
+ pad.r +
1480
+ (y2_label_shift.y ?? 0)}, {pad.t +
1481
+ (height - pad.t - pad.b) / 2 +
1482
+ (y2_label_shift.x ?? 0)})"
1483
+ >
1484
+ <div class="axis-label y2-label" style:color={axis_colors.y2 || undefined}>
1485
+ {@html y2_label ?? ``}
1486
+ </div>
1487
+ </foreignObject>
1488
+ {/if}
1489
+ </g>
1490
+ {/if}
1491
+
1492
+ <!-- Tooltip -->
1493
+ {#if tooltip_point && hovered}
1494
+ {@const { x, y, metadata, color_value, point_label, point_style, series_idx } =
1495
+ tooltip_point}
1496
+ {@const hovered_series = series_with_ids[series_idx]}
1497
+ {@const series_markers = hovered_series?.markers ?? markers}
1498
+ {@const is_transparent_or_none = (color: string | undefined | null): boolean =>
1499
+ !color ||
1500
+ color === `none` ||
1501
+ color === `transparent` ||
1502
+ (color.startsWith(`rgba(`) && color.endsWith(`, 0)`))}
1503
+
1504
+ {@const tooltip_bg_color = (() => {
1505
+ // 1. Check color from scale
1506
+ const scale_color = color_value != null
1507
+ ? color_scale_fn(color_value)
1508
+ : undefined
1509
+ if (!is_transparent_or_none(scale_color)) return scale_color
1510
+
1511
+ // 2. Check color from point fill
1512
+ const fill_color = point_style?.fill
1513
+ if (!is_transparent_or_none(fill_color)) return fill_color
1514
+
1515
+ // 3. Check color from point stroke (only if points are visible)
1516
+ if (series_markers?.includes(`points`)) {
1517
+ const stroke_color = point_style?.stroke
1518
+ if (!is_transparent_or_none(stroke_color)) return stroke_color
1519
+ }
1520
+
1521
+ // 4. Check color from line style (only if line is visible)
1522
+ if (series_markers?.includes(`line`)) {
1523
+ // Replicate the precedence logic used for the actual line rendering
1524
+ const line_style = hovered_series?.line_style ?? {}
1525
+ const first_point_style = Array.isArray(hovered_series?.point_style)
1526
+ ? hovered_series?.point_style[0]
1527
+ : hovered_series?.point_style
1528
+ const first_color_value = hovered_series?.color_values?.[0]
1529
+
1530
+ let line_color_candidate = line_style.stroke // Line style stroke first
1531
+ if (is_transparent_or_none(line_color_candidate)) {
1532
+ line_color_candidate = first_point_style?.fill // Fallback to first point fill
1533
+ }
1534
+ if (
1535
+ is_transparent_or_none(line_color_candidate) &&
1536
+ first_color_value != null
1537
+ ) {
1538
+ line_color_candidate = color_scale_fn(first_color_value) // Fallback to first point color scale
1539
+ }
1540
+ // Final fallback within line logic: if points are *also* shown, use the point stroke
1541
+ if (
1542
+ is_transparent_or_none(line_color_candidate) &&
1543
+ series_markers.includes(`points`)
1544
+ ) {
1545
+ line_color_candidate = first_point_style?.stroke
1546
+ }
1547
+
1548
+ if (
1549
+ !is_transparent_or_none(line_color_candidate)
1550
+ ) return line_color_candidate
1551
+ }
1552
+
1553
+ // 5. Final fallback
1554
+ return `rgba(0, 0, 0, 0.7)`
1555
+ })()}
1556
+
1557
+ {@const cx = x_format?.startsWith(`%`) ? x_scale_fn(new Date(x)) : x_scale_fn(x)}
1558
+ {@const cy = (hovered_series?.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(y)}
1559
+ {@const x_formatted = format_value(x, x_format)}
1560
+ {@const y_formatted = format_value(y, y_format)}
1561
+ {@const label = point_label?.text ?? null}
1562
+
1563
+ {@const tooltip_lum = luminance(tooltip_bg_color ?? `rgba(0, 0, 0, 0.7)`)}
1564
+ {@const tooltip_text_color = tooltip_lum > 0.5 ? `black` : `white`}
1565
+
1566
+ <foreignObject x={cx + 5} y={cy}>
1567
+ <div
1568
+ class="tooltip"
1569
+ style:background-color={tooltip_bg_color}
1570
+ style:color="var(--esp-tooltip-color, {tooltip_text_color})"
1571
+ >
1572
+ {#if tooltip}
1573
+ {@const tooltip_props = { x_formatted, y_formatted, color_value, label }}
1574
+ {@render tooltip({ x, y, cx, cy, metadata, ...tooltip_props })}
1575
+ {:else}
1576
+ {label ?? `Point`} - x: {x_formatted}, y: {y_formatted}
1577
+ {/if}
1578
+ </div>
1579
+ </foreignObject>
1580
+ {/if}
1581
+
1582
+ <!-- Zoom Selection Rectangle -->
1583
+ {#if drag_start_coords && drag_current_coords}
1584
+ {@const x = Math.min(drag_start_coords.x, drag_current_coords.x)}
1585
+ {@const y = Math.min(drag_start_coords.y, drag_current_coords.y)}
1586
+ {@const rect_width = Math.abs(drag_start_coords.x - drag_current_coords.x)}
1587
+ {@const rect_height = Math.abs(drag_start_coords.y - drag_current_coords.y)}
1588
+ <rect class="zoom-rect" {x} {y} width={rect_width} height={rect_height} />
1589
+ {/if}
1590
+ </svg>
1591
+
1592
+ <!-- Control Panel positioned in top-right corner -->
1593
+ {#if show_controls}
1594
+ <ScatterPlotControls
1595
+ bind:this={controls_component}
1596
+ bind:show_controls
1597
+ bind:controls_open
1598
+ bind:markers
1599
+ bind:show_zero_lines
1600
+ bind:x_grid
1601
+ bind:y_grid
1602
+ bind:y2_grid
1603
+ bind:point_size
1604
+ bind:point_color
1605
+ bind:point_opacity
1606
+ bind:point_stroke_width
1607
+ bind:point_stroke_color
1608
+ bind:point_stroke_opacity
1609
+ bind:line_width
1610
+ bind:line_color
1611
+ bind:line_opacity
1612
+ bind:line_dash
1613
+ bind:show_points
1614
+ bind:show_lines
1615
+ bind:selected_series_idx
1616
+ bind:x_format
1617
+ bind:y_format
1618
+ bind:y2_format
1619
+ series={series_with_ids}
1620
+ {plot_controls}
1621
+ has_y2_points={y2_points.length > 0}
1622
+ />
1623
+ {/if}
1624
+
1625
+ <!-- Color Bar -->
1626
+ {#if color_bar && all_color_values.length > 0 && color_bar_cell}
1627
+ {@const effective_color_domain = (color_scale.value_range ?? auto_color_range) as [
1628
+ number,
1629
+ number,
1630
+ ]}
1631
+ <ColorBar
1632
+ {...{
1633
+ tick_labels: 4,
1634
+ tick_align: `primary`,
1635
+ color_scale_fn,
1636
+ color_scale_domain: effective_color_domain,
1637
+ scale_type: color_scale.type,
1638
+ range: effective_color_domain?.every((val) => val != null)
1639
+ ? effective_color_domain
1640
+ : undefined,
1641
+ wrapper_style: `
1642
+ position: absolute;
1643
+ left: ${tweened_colorbar_coords.current.x}px;
1644
+ top: ${tweened_colorbar_coords.current.y}px;
1645
+ transform: ${get_placement_styles(color_bar_cell, `colorbar`).transform};
1646
+ ${color_bar?.wrapper_style ?? ``}`,
1647
+ // user-overridable inner style
1648
+ style: `width: 280px; height: 20px; ${color_bar?.style ?? ``}`,
1649
+ ...color_bar,
1650
+ }}
1651
+ />
1652
+ {/if}
1653
+
1654
+ <!-- Legend -->
1655
+ <!-- Only render if multiple series or if legend prop was explicitly provided by user (even if empty object) -->
1656
+ {#if legend != null && legend_data.length > 0 && legend_cell &&
1657
+ (legend_data.length > 1 || (legend != null && JSON.stringify(legend) !== `{}`))}
1658
+ <PlotLegend
1659
+ series_data={legend_data}
1660
+ on_toggle={toggle_series_visibility}
1661
+ on_double_click={handle_legend_double_click}
1662
+ on_drag_start={handle_legend_drag_start}
1663
+ on_drag={handle_legend_drag}
1664
+ on_drag_end={handle_legend_drag_end}
1665
+ draggable={legend?.draggable ?? true}
1666
+ {...legend}
1667
+ wrapper_style={`
1668
+ position: absolute;
1669
+ left: ${tweened_legend_coords.current.x}px;
1670
+ top: ${tweened_legend_coords.current.y}px;
1671
+ transform: ${
1672
+ // Use the derived legend_placement_cell to get the correct transform (only if not manually positioned)
1673
+ legend_manual_position
1674
+ ? ``
1675
+ : get_placement_styles(legend_placement_cell, `legend`).transform};
1676
+ ${legend?.wrapper_style ?? ``}
1677
+ `}
1678
+ />
1679
+ {/if}
1680
+ {/if}
1681
+ </div>
1682
+
1683
+ <style>
1684
+ div.scatter {
1685
+ position: relative; /* Needed for absolute positioning of children like ColorBar */
1686
+ width: 100%;
1687
+ height: 100%;
1688
+ display: flex;
1689
+ min-height: var(--esp-min-height, 100px);
1690
+ container-type: inline-size;
1691
+ z-index: var(--esp-z-index, 1);
1692
+ }
1693
+ svg {
1694
+ width: 100%;
1695
+ fill: var(--esp-fill, white);
1696
+ font-weight: var(--esp-font-weight);
1697
+ overflow: visible;
1698
+ z-index: var(--esp-z-index, 1);
1699
+ font-size: var(--esp-font-size);
1700
+ }
1701
+ line {
1702
+ stroke: var(--esp-grid-stroke, gray);
1703
+ stroke-dasharray: var(--esp-grid-dash, 4);
1704
+ stroke-width: var(--esp-grid-width, 0.4);
1705
+ }
1706
+ g.x-axis text {
1707
+ text-anchor: middle;
1708
+ dominant-baseline: top;
1709
+ }
1710
+ g.y-axis text {
1711
+ dominant-baseline: central;
1712
+ }
1713
+ g.y2-axis text {
1714
+ dominant-baseline: central;
1715
+ }
1716
+ foreignobject {
1717
+ overflow: visible;
1718
+ }
1719
+ .axis-label {
1720
+ text-align: center;
1721
+ display: flex;
1722
+ align-items: center;
1723
+ justify-content: center;
1724
+ width: 100%;
1725
+ height: 100%;
1726
+ font-size: var(--esp-font-size, inherit);
1727
+ font-weight: var(--esp-font-weight, normal);
1728
+ color: var(--esp-fill, currentColor);
1729
+ white-space: nowrap;
1730
+ }
1731
+ .current-frame-indicator {
1732
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
1733
+ transition: opacity 0.2s ease;
1734
+ }
1735
+ .current-frame-indicator:hover {
1736
+ opacity: 0.8;
1737
+ }
1738
+ .tooltip {
1739
+ color: var(--esp-tooltip-color, white);
1740
+ padding: var(--esp-tooltip-padding, 1px 4px);
1741
+ border-radius: var(--esp-tooltip-border-radius, 3px);
1742
+ font-size: var(--esp-tooltip-font-size, 0.8em);
1743
+ /* Ensure background fits content width */
1744
+ width: var(--esp-tooltip-width, max-content);
1745
+ box-sizing: border-box;
1746
+ }
1747
+ .zoom-rect {
1748
+ fill: var(--esp-zoom-rect-fill, rgba(100, 100, 255, 0.2));
1749
+ stroke: var(--esp-zoom-rect-stroke, rgba(100, 100, 255, 0.8));
1750
+ stroke-width: var(--esp-zoom-rect-stroke-width, 1);
1751
+ pointer-events: none; /* Prevent rect from interfering with mouse events */
1752
+ }
1753
+ </style>