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.
- package/dist/BohrAtom.svelte +105 -0
- package/dist/BohrAtom.svelte.d.ts +21 -0
- package/dist/ControlPanel.svelte +158 -0
- package/dist/ControlPanel.svelte.d.ts +18 -0
- package/dist/Icon.svelte +23 -0
- package/dist/Icon.svelte.d.ts +8 -0
- package/dist/InfoCard.svelte +79 -0
- package/dist/InfoCard.svelte.d.ts +23 -0
- package/dist/Nucleus.svelte +64 -0
- package/dist/Nucleus.svelte.d.ts +16 -0
- package/dist/Spinner.svelte +44 -0
- package/dist/Spinner.svelte.d.ts +7 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +30 -0
- package/dist/colors/alloy-colors.json +111 -0
- package/dist/colors/dark-mode-colors.json +111 -0
- package/dist/colors/index.d.ts +26 -0
- package/dist/colors/index.js +72 -0
- package/dist/colors/jmol-colors.json +111 -0
- package/dist/colors/muted-colors.json +111 -0
- package/dist/colors/pastel-colors.json +111 -0
- package/dist/colors/vesta-colors.json +111 -0
- package/dist/composition/BarChart.svelte +260 -0
- package/dist/composition/BarChart.svelte.d.ts +33 -0
- package/dist/composition/BubbleChart.svelte +166 -0
- package/dist/composition/BubbleChart.svelte.d.ts +30 -0
- package/dist/composition/Composition.svelte +73 -0
- package/dist/composition/Composition.svelte.d.ts +27 -0
- package/dist/composition/PieChart.svelte +236 -0
- package/dist/composition/PieChart.svelte.d.ts +36 -0
- package/dist/composition/index.d.ts +5 -0
- package/dist/composition/index.js +5 -0
- package/dist/composition/parse.d.ts +14 -0
- package/dist/composition/parse.js +307 -0
- package/dist/element/ElementHeading.svelte +21 -0
- package/dist/element/ElementHeading.svelte.d.ts +8 -0
- package/dist/element/ElementPhoto.svelte +56 -0
- package/dist/element/ElementPhoto.svelte.d.ts +9 -0
- package/dist/element/ElementStats.svelte +73 -0
- package/dist/element/ElementStats.svelte.d.ts +8 -0
- package/dist/element/ElementTile.svelte +449 -0
- package/dist/element/ElementTile.svelte.d.ts +25 -0
- package/dist/element/data.d.ts +4958 -0
- package/dist/element/data.js +5628 -0
- package/dist/element/index.d.ts +4 -0
- package/dist/element/index.js +4 -0
- package/dist/icons.d.ts +435 -0
- package/dist/icons.js +435 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +43 -0
- package/dist/io/decompress.d.ts +16 -0
- package/dist/io/decompress.js +78 -0
- package/dist/io/export.d.ts +9 -0
- package/dist/io/export.js +205 -0
- package/dist/io/parse.d.ts +53 -0
- package/dist/io/parse.js +747 -0
- package/dist/labels.d.ts +31 -0
- package/dist/labels.js +209 -0
- package/dist/material/MaterialCard.svelte +135 -0
- package/dist/material/MaterialCard.svelte.d.ts +10 -0
- package/dist/material/SymmetryCard.svelte +23 -0
- package/dist/material/SymmetryCard.svelte.d.ts +9 -0
- package/dist/material/index.d.ts +2 -0
- package/dist/material/index.js +2 -0
- package/dist/math.d.ts +24 -0
- package/dist/math.js +216 -0
- package/dist/periodic-table/PeriodicTable.svelte +284 -0
- package/dist/periodic-table/PeriodicTable.svelte.d.ts +50 -0
- package/dist/periodic-table/PropertySelect.svelte +20 -0
- package/dist/periodic-table/PropertySelect.svelte.d.ts +13 -0
- package/dist/periodic-table/TableInset.svelte +18 -0
- package/dist/periodic-table/TableInset.svelte.d.ts +9 -0
- package/dist/periodic-table/index.d.ts +9 -0
- package/dist/periodic-table/index.js +3 -0
- package/dist/plot/ColorBar.svelte +414 -0
- package/dist/plot/ColorBar.svelte.d.ts +22 -0
- package/dist/plot/ColorScaleSelect.svelte +31 -0
- package/dist/plot/ColorScaleSelect.svelte.d.ts +15 -0
- package/dist/plot/ElementScatter.svelte +38 -0
- package/dist/plot/ElementScatter.svelte.d.ts +14 -0
- package/dist/plot/Line.svelte +42 -0
- package/dist/plot/Line.svelte.d.ts +15 -0
- package/dist/plot/PlotLegend.svelte +206 -0
- package/dist/plot/PlotLegend.svelte.d.ts +18 -0
- package/dist/plot/ScatterPlot.svelte +1753 -0
- package/dist/plot/ScatterPlot.svelte.d.ts +114 -0
- package/dist/plot/ScatterPlotControls.svelte +505 -0
- package/dist/plot/ScatterPlotControls.svelte.d.ts +33 -0
- package/dist/plot/ScatterPoint.svelte +72 -0
- package/dist/plot/ScatterPoint.svelte.d.ts +17 -0
- package/dist/plot/index.d.ts +168 -0
- package/dist/plot/index.js +46 -0
- package/dist/state.svelte.d.ts +12 -0
- package/dist/state.svelte.js +11 -0
- package/dist/structure/Bond.svelte +68 -0
- package/dist/structure/Bond.svelte.d.ts +13 -0
- package/dist/structure/Lattice.svelte +115 -0
- package/dist/structure/Lattice.svelte.d.ts +15 -0
- package/dist/structure/Structure.svelte +298 -0
- package/dist/structure/Structure.svelte.d.ts +28 -0
- package/dist/structure/StructureCard.svelte +26 -0
- package/dist/structure/StructureCard.svelte.d.ts +9 -0
- package/dist/structure/StructureControls.svelte +383 -0
- package/dist/structure/StructureControls.svelte.d.ts +23 -0
- package/dist/structure/StructureLegend.svelte +130 -0
- package/dist/structure/StructureLegend.svelte.d.ts +17 -0
- package/dist/structure/StructureScene.svelte +331 -0
- package/dist/structure/StructureScene.svelte.d.ts +47 -0
- package/dist/structure/bonding.d.ts +16 -0
- package/dist/structure/bonding.js +150 -0
- package/dist/structure/index.d.ts +98 -0
- package/dist/structure/index.js +114 -0
- package/dist/structure/pbc.d.ts +6 -0
- package/dist/structure/pbc.js +72 -0
- package/dist/trajectory/Sidebar.svelte +412 -0
- package/dist/trajectory/Sidebar.svelte.d.ts +14 -0
- package/dist/trajectory/Trajectory.svelte +1084 -0
- package/dist/trajectory/Trajectory.svelte.d.ts +49 -0
- package/dist/trajectory/TrajectoryError.svelte +120 -0
- package/dist/trajectory/TrajectoryError.svelte.d.ts +12 -0
- package/dist/trajectory/extract.d.ts +5 -0
- package/dist/trajectory/extract.js +157 -0
- package/dist/trajectory/index.d.ts +16 -0
- package/dist/trajectory/index.js +49 -0
- package/dist/trajectory/parse.d.ts +13 -0
- package/dist/trajectory/parse.js +1093 -0
- package/dist/trajectory/plotting.d.ts +12 -0
- package/dist/trajectory/plotting.js +148 -0
- package/license +21 -0
- package/package.json +131 -0
- 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
|
+
‌ {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
|
+
‌ {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>
|