matterviz 0.3.5 → 0.3.7

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 (229) hide show
  1. package/dist/MillerIndexInput.svelte +5 -5
  2. package/dist/api/optimade.js +3 -3
  3. package/dist/brillouin/BrillouinZone.svelte +5 -2
  4. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  5. package/dist/brillouin/BrillouinZoneExportPane.svelte +1 -3
  6. package/dist/brillouin/BrillouinZoneInfoPane.svelte +1 -1
  7. package/dist/brillouin/BrillouinZoneScene.svelte +5 -5
  8. package/dist/brillouin/compute.js +21 -21
  9. package/dist/brillouin/index.d.ts +1 -1
  10. package/dist/brillouin/index.js +0 -1
  11. package/dist/brillouin/types.d.ts +8 -13
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte +3 -3
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +3 -4
  14. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +33 -34
  15. package/dist/chempot-diagram/compute.js +1 -7
  16. package/dist/chempot-diagram/temperature.d.ts +1 -1
  17. package/dist/chempot-diagram/temperature.js +1 -3
  18. package/dist/chempot-diagram/types.d.ts +4 -9
  19. package/dist/colors/index.js +5 -5
  20. package/dist/composition/Composition.svelte +2 -1
  21. package/dist/composition/Formula.svelte +7 -4
  22. package/dist/composition/FormulaFilter.svelte +1 -3
  23. package/dist/composition/format.js +4 -4
  24. package/dist/composition/parse.d.ts +2 -1
  25. package/dist/composition/parse.js +61 -46
  26. package/dist/convex-hull/ConvexHull2D.svelte +62 -51
  27. package/dist/convex-hull/ConvexHull3D.svelte +101 -90
  28. package/dist/convex-hull/ConvexHull4D.svelte +70 -58
  29. package/dist/convex-hull/ConvexHullControls.svelte +24 -35
  30. package/dist/convex-hull/ConvexHullInfoPane.svelte +8 -5
  31. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +2 -0
  32. package/dist/convex-hull/ConvexHullStats.svelte +9 -2
  33. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +2 -0
  34. package/dist/convex-hull/GasPressureControls.svelte +7 -7
  35. package/dist/convex-hull/StructurePopup.svelte +65 -30
  36. package/dist/convex-hull/StructurePopup.svelte.d.ts +6 -6
  37. package/dist/convex-hull/TemperatureSlider.svelte +8 -5
  38. package/dist/convex-hull/barycentric-coords.d.ts +2 -2
  39. package/dist/convex-hull/barycentric-coords.js +2 -2
  40. package/dist/convex-hull/gas-thermodynamics.js +2 -4
  41. package/dist/convex-hull/helpers.d.ts +13 -2
  42. package/dist/convex-hull/helpers.js +37 -16
  43. package/dist/convex-hull/index.d.ts +1 -0
  44. package/dist/convex-hull/index.js +1 -0
  45. package/dist/convex-hull/thermodynamics.d.ts +2 -1
  46. package/dist/convex-hull/thermodynamics.js +7 -7
  47. package/dist/convex-hull/types.d.ts +15 -15
  48. package/dist/effects.svelte.d.ts +12 -0
  49. package/dist/effects.svelte.js +37 -0
  50. package/dist/element/BohrAtom.svelte +4 -4
  51. package/dist/element/data.json.gz.d.ts +3 -1
  52. package/dist/element/index.d.ts +1 -1
  53. package/dist/element/index.js +0 -1
  54. package/dist/fermi-surface/FermiSurface.svelte +4 -4
  55. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  56. package/dist/fermi-surface/FermiSurfaceControls.svelte +15 -19
  57. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  58. package/dist/fermi-surface/FermiSurfaceScene.svelte +8 -6
  59. package/dist/fermi-surface/compute.js +2 -2
  60. package/dist/fermi-surface/export.js +13 -26
  61. package/dist/fermi-surface/parse.js +8 -12
  62. package/dist/fermi-surface/types.d.ts +2 -5
  63. package/dist/heatmap-matrix/HeatmapMatrix.svelte +21 -3
  64. package/dist/heatmap-matrix/index.js +6 -6
  65. package/dist/io/decompress.d.ts +2 -1
  66. package/dist/io/decompress.js +1 -1
  67. package/dist/io/export.js +1 -1
  68. package/dist/io/index.d.ts +1 -1
  69. package/dist/io/index.js +0 -1
  70. package/dist/io/url-drop.js +7 -1
  71. package/dist/isosurface/IsosurfaceControls.svelte +11 -25
  72. package/dist/isosurface/slice.js +1 -1
  73. package/dist/isosurface/types.js +12 -12
  74. package/dist/labels.d.ts +1 -1
  75. package/dist/labels.js +14 -11
  76. package/dist/layout/InfoTag.svelte +6 -4
  77. package/dist/layout/PropertyFilter.svelte +4 -2
  78. package/dist/layout/json-tree/JsonTree.svelte +22 -14
  79. package/dist/layout/json-tree/JsonValue.svelte +2 -2
  80. package/dist/layout/json-tree/types.d.ts +3 -2
  81. package/dist/layout/json-tree/types.js +0 -1
  82. package/dist/layout/json-tree/utils.d.ts +4 -4
  83. package/dist/layout/json-tree/utils.js +12 -20
  84. package/dist/marching-cubes.js +13 -15
  85. package/dist/math.d.ts +11 -1
  86. package/dist/math.js +15 -6
  87. package/dist/overlays/DragControlTab.svelte +98 -0
  88. package/dist/overlays/DragControlTab.svelte.d.ts +8 -0
  89. package/dist/overlays/DraggablePane.svelte +7 -84
  90. package/dist/overlays/index.d.ts +1 -0
  91. package/dist/overlays/index.js +1 -0
  92. package/dist/periodic-table/PeriodicTable.svelte +11 -11
  93. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +4 -2
  94. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  95. package/dist/phase-diagram/PhaseDiagramControls.svelte +4 -9
  96. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  97. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +2 -10
  98. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +2 -3
  99. package/dist/phase-diagram/TdbInfoPanel.svelte +3 -3
  100. package/dist/phase-diagram/build-diagram.js +11 -18
  101. package/dist/phase-diagram/diagram-input.d.ts +5 -9
  102. package/dist/phase-diagram/index.d.ts +2 -2
  103. package/dist/phase-diagram/index.js +0 -2
  104. package/dist/phase-diagram/parse.d.ts +2 -2
  105. package/dist/phase-diagram/parse.js +6 -10
  106. package/dist/phase-diagram/svg-to-diagram.js +15 -15
  107. package/dist/phase-diagram/types.d.ts +5 -11
  108. package/dist/phase-diagram/utils.d.ts +2 -2
  109. package/dist/phase-diagram/utils.js +9 -11
  110. package/dist/plot/BarPlot.svelte +162 -314
  111. package/dist/plot/BarPlot.svelte.d.ts +5 -4
  112. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  113. package/dist/plot/BinnedScatterPlot.svelte +1114 -0
  114. package/dist/plot/BinnedScatterPlot.svelte.d.ts +66 -0
  115. package/dist/plot/ColorBar.svelte +19 -17
  116. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  117. package/dist/plot/FillArea.svelte +2 -4
  118. package/dist/plot/FillArea.svelte.d.ts +1 -1
  119. package/dist/plot/Histogram.svelte +167 -281
  120. package/dist/plot/Histogram.svelte.d.ts +1 -1
  121. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  122. package/dist/plot/InteractiveAxisLabel.svelte +5 -3
  123. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  124. package/dist/plot/PlotAxis.svelte +169 -0
  125. package/dist/plot/PlotAxis.svelte.d.ts +24 -0
  126. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  127. package/dist/plot/ReferenceLine3D.svelte +53 -51
  128. package/dist/plot/ReferencePlane.svelte +39 -42
  129. package/dist/plot/ScatterPlot.svelte +300 -367
  130. package/dist/plot/ScatterPlot.svelte.d.ts +8 -5
  131. package/dist/plot/ScatterPlot3D.svelte +33 -6
  132. package/dist/plot/ScatterPlot3D.svelte.d.ts +3 -2
  133. package/dist/plot/ScatterPlot3DControls.svelte +9 -9
  134. package/dist/plot/ScatterPlotControls.svelte +3 -4
  135. package/dist/plot/ScatterPoint.svelte +18 -27
  136. package/dist/plot/ScatterPoint.svelte.d.ts +4 -3
  137. package/dist/plot/Surface3D.svelte +4 -7
  138. package/dist/plot/ZeroLines.svelte +2 -1
  139. package/dist/plot/ZeroLines.svelte.d.ts +2 -1
  140. package/dist/plot/ZoomRect.svelte +2 -2
  141. package/dist/plot/ZoomRect.svelte.d.ts +3 -3
  142. package/dist/plot/adaptive-density.d.ts +69 -0
  143. package/dist/plot/adaptive-density.js +191 -0
  144. package/dist/plot/auto-place.d.ts +43 -0
  145. package/dist/plot/auto-place.js +122 -0
  146. package/dist/plot/axis-utils.js +3 -5
  147. package/dist/plot/binned-scatter-types.d.ts +59 -0
  148. package/dist/plot/binned-scatter-types.js +1 -0
  149. package/dist/plot/data-cleaning.js +1 -1
  150. package/dist/plot/data-transform.js +1 -1
  151. package/dist/plot/fill-utils.d.ts +4 -9
  152. package/dist/plot/fill-utils.js +29 -44
  153. package/dist/plot/index.d.ts +4 -0
  154. package/dist/plot/index.js +2 -0
  155. package/dist/plot/interactions.d.ts +4 -4
  156. package/dist/plot/interactions.js +4 -3
  157. package/dist/plot/layout.d.ts +20 -2
  158. package/dist/plot/layout.js +59 -16
  159. package/dist/plot/reference-line.d.ts +1 -1
  160. package/dist/plot/reference-line.js +9 -11
  161. package/dist/plot/scales.d.ts +1 -1
  162. package/dist/plot/scales.js +20 -23
  163. package/dist/plot/types.d.ts +30 -58
  164. package/dist/plot/types.js +2 -6
  165. package/dist/plot/utils/label-placement.d.ts +24 -3
  166. package/dist/plot/utils/label-placement.js +82 -12
  167. package/dist/plot/utils/series-visibility.d.ts +8 -2
  168. package/dist/plot/utils/series-visibility.js +23 -5
  169. package/dist/rdf/RdfPlot.svelte +5 -5
  170. package/dist/rdf/calc-rdf.js +3 -3
  171. package/dist/sanitize.d.ts +2 -0
  172. package/dist/sanitize.js +2 -0
  173. package/dist/spectral/Bands.svelte +1 -1
  174. package/dist/spectral/BandsAndDos.svelte +22 -16
  175. package/dist/spectral/BrillouinBandsDos.svelte +20 -16
  176. package/dist/spectral/Dos.svelte +1 -1
  177. package/dist/spectral/helpers.d.ts +4 -2
  178. package/dist/spectral/helpers.js +44 -35
  179. package/dist/spectral/index.d.ts +1 -1
  180. package/dist/spectral/index.js +0 -1
  181. package/dist/structure/AtomLegend.svelte +23 -6
  182. package/dist/structure/AtomLegend.svelte.d.ts +1 -0
  183. package/dist/structure/CanvasTooltip.svelte +9 -9
  184. package/dist/structure/CanvasTooltip.svelte.d.ts +1 -1
  185. package/dist/structure/CellSelect.svelte +14 -16
  186. package/dist/structure/Structure.svelte +317 -68
  187. package/dist/structure/Structure.svelte.d.ts +4 -2
  188. package/dist/structure/StructureControls.svelte +20 -45
  189. package/dist/structure/StructureExportPane.svelte +2 -1
  190. package/dist/structure/StructureInfoPane.svelte +10 -8
  191. package/dist/structure/StructureScene.svelte +527 -177
  192. package/dist/structure/StructureScene.svelte.d.ts +5 -2
  193. package/dist/structure/atom-properties.js +4 -4
  194. package/dist/structure/bond-order-perception.js +115 -98
  195. package/dist/structure/bonding.d.ts +27 -1
  196. package/dist/structure/bonding.js +187 -16
  197. package/dist/structure/export.js +1 -1
  198. package/dist/structure/index.d.ts +3 -2
  199. package/dist/structure/index.js +0 -2
  200. package/dist/structure/parse.js +88 -59
  201. package/dist/symmetry/WyckoffTable.svelte +7 -0
  202. package/dist/symmetry/index.js +13 -14
  203. package/dist/table/HeatmapTable.svelte +45 -66
  204. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  205. package/dist/table/ToggleMenu.svelte +19 -10
  206. package/dist/theme/themes.mjs +12 -0
  207. package/dist/tooltip/index.d.ts +1 -1
  208. package/dist/tooltip/index.js +0 -1
  209. package/dist/trajectory/Trajectory.svelte +43 -15
  210. package/dist/trajectory/TrajectoryInfoPane.svelte +2 -2
  211. package/dist/trajectory/extract.js +1 -1
  212. package/dist/trajectory/frame-reader.js +4 -4
  213. package/dist/trajectory/helpers.d.ts +5 -4
  214. package/dist/trajectory/helpers.js +9 -17
  215. package/dist/trajectory/index.d.ts +2 -2
  216. package/dist/trajectory/index.js +2 -2
  217. package/dist/trajectory/parse/ase.js +4 -4
  218. package/dist/trajectory/parse/hdf5.js +1 -1
  219. package/dist/trajectory/parse/index.js +2 -3
  220. package/dist/trajectory/parse/lammps.js +1 -1
  221. package/dist/trajectory/parse/vasp.js +1 -1
  222. package/dist/trajectory/plotting.d.ts +1 -1
  223. package/dist/trajectory/plotting.js +38 -38
  224. package/dist/trajectory/types.d.ts +1 -1
  225. package/dist/utils.d.ts +1 -0
  226. package/dist/utils.js +9 -0
  227. package/dist/xrd/calc-xrd.js +3 -4
  228. package/dist/xrd/parse.js +1 -1
  229. package/package.json +42 -22
@@ -7,7 +7,7 @@
7
7
  import { format_value, symbol_names } from '../labels'
8
8
  import { sanitize_html } from '../sanitize'
9
9
  import { FullscreenToggle, set_fullscreen_bg } from '../layout'
10
- import type { Vec2 } from '../math'
10
+ import type { Point2D, Vec2 } from '../math'
11
11
  import type {
12
12
  AxisLoadError,
13
13
  BasePlotProps,
@@ -30,18 +30,16 @@
30
30
  ScaleType,
31
31
  ScatterHandlerEvent,
32
32
  ScatterHandlerProps,
33
- Sides,
34
33
  StyleOverrides,
35
34
  UserContentProps,
36
- XyObj,
37
35
  } from './'
38
36
  import {
39
- AxisLabel,
40
37
  ColorBar,
41
38
  compute_element_placement,
42
39
  FillArea,
43
40
  get_tick_label,
44
41
  Line,
42
+ PlotAxis,
45
43
  PlotLegend,
46
44
  PlotTooltip,
47
45
  ReferenceLine,
@@ -50,6 +48,12 @@
50
48
  ZeroLines,
51
49
  ZoomRect,
52
50
  } from './'
51
+ import {
52
+ build_obstacles_norm,
53
+ has_explicit_position,
54
+ measured_footprint,
55
+ place_decorations,
56
+ } from './auto-place'
53
57
  import type { AxisChangeState } from './axis-utils'
54
58
  import { create_axis_change_handler } from './axis-utils'
55
59
  import {
@@ -63,12 +67,12 @@
63
67
  create_hover_lock,
64
68
  } from './hover-lock.svelte'
65
69
  import {
66
- DEFAULT_GRID_STYLE,
67
70
  DEFAULT_MARKERS,
68
71
  get_scale_type_name,
69
72
  is_time_scale,
70
73
  } from './types'
71
74
  import { compute_label_positions } from './utils/label-placement'
75
+ import type { SeriesVisibilitySnapshot } from './utils/series-visibility'
72
76
  import {
73
77
  handle_legend_double_click,
74
78
  toggle_group_visibility,
@@ -101,13 +105,15 @@
101
105
  pixels_to_data_delta,
102
106
  sync_y2_range,
103
107
  } from './interactions'
104
- import type { Rect } from './layout'
108
+ import type { Rect, Sides } from './layout'
105
109
  import {
106
110
  calc_auto_padding,
107
111
  constrain_tooltip_position,
108
112
  filter_padding,
109
113
  LABEL_GAP_DEFAULT,
114
+ measure_full_footprint,
110
115
  measure_max_tick_width,
116
+ sample_series_obstacle_points,
111
117
  } from './layout'
112
118
  import type { IndexedRefLine } from './reference-line'
113
119
  import { group_ref_lines_by_z, index_ref_lines } from './reference-line'
@@ -119,6 +125,9 @@
119
125
  get_nice_data_range,
120
126
  } from './scales'
121
127
 
128
+ const in_range = (val: number | null | undefined, lo: number, hi: number) =>
129
+ val != null && !isNaN(val) && val >= Math.min(lo, hi) && val <= Math.max(lo, hi)
130
+
122
131
  let {
123
132
  series = $bindable([]),
124
133
  x_axis = $bindable({}),
@@ -205,14 +214,15 @@
205
214
  color_bar?:
206
215
  | (ComponentProps<typeof ColorBar> & {
207
216
  margin?: number | Sides
208
- tween?: TweenOptions<XyObj>
217
+ tween?: TweenOptions<Point2D>
209
218
  responsive?: boolean // Allow colorbar to reposition if density changes (default: false)
219
+ axis_clearance?: number // Min distance kept from plot edges/axes (default: 15)
210
220
  })
211
221
  | null
212
222
  label_placement_config?: Partial<LabelPlacementConfig>
213
223
  hover_config?: Partial<HoverConfig>
214
224
  legend?: LegendConfig | null
215
- point_tween?: TweenOptions<XyObj>
225
+ point_tween?: TweenOptions<Point2D>
216
226
  line_tween?: TweenOptions<string>
217
227
  point_events?: Record<
218
228
  string,
@@ -295,8 +305,8 @@
295
305
  )
296
306
 
297
307
  // State for rectangle zoom selection
298
- let drag_start_coords = $state<XyObj | null>(null)
299
- let drag_current_coords = $state<XyObj | null>(null)
308
+ let drag_start_coords = $state<Point2D | null>(null)
309
+ let drag_current_coords = $state<Point2D | null>(null)
300
310
 
301
311
  // Zoom/pan state - track both initial (data-driven) and current (after pan/zoom) ranges
302
312
  let initial_x_range = $state<[number, number]>([0, 1])
@@ -307,7 +317,7 @@
307
317
  let zoom_x2_range = $state<[number, number]>([0, 1])
308
318
  let zoom_y_range = $state<[number, number]>([0, 1])
309
319
  let zoom_y2_range = $state<[number, number]>([0, 1])
310
- let previous_series_visibility: boolean[] | null = $state(null)
320
+ let prev_series_visibility: SeriesVisibilitySnapshot | null = $state(null)
311
321
 
312
322
  // Y2 axis sync configuration
313
323
  let y2_sync_config = $derived(normalize_y2_sync(y2_axis?.sync))
@@ -348,7 +358,7 @@
348
358
  >(null)
349
359
 
350
360
  // Fill region hover state
351
- let hovered_fill_idx = $state<number | null>(null)
361
+ let hovered_fill_key = $state<string | null>(null)
352
362
 
353
363
  // Reference line hover state
354
364
  let hovered_ref_line_idx = $state<number | null>(null)
@@ -357,7 +367,7 @@
357
367
  let axis_loading = $state<`x` | `x2` | `y` | `y2` | null>(null)
358
368
 
359
369
  // State to hold the calculated label positions after simulation
360
- let label_positions = $state<Record<string, XyObj>>({})
370
+ let label_positions = $state<Record<string, Point2D>>({})
361
371
 
362
372
  // State for legend dragging
363
373
  let legend_is_dragging = $state(false)
@@ -414,9 +424,9 @@
414
424
  let y2_points = $derived(points_by_axis.y2)
415
425
  let x2_points = $derived(points_by_axis.x2)
416
426
 
417
- // Layout: dynamic padding based on tick label widths
427
+ // Layout: tick-label padding (decoration reservations are added in `pad` below)
418
428
  const default_padding = { t: 5, b: 50, l: 50, r: 20 }
419
- let pad = $state(untrack(() => filter_padding(padding, default_padding)))
429
+ let base_pad = $state(untrack(() => filter_padding(padding, default_padding)))
420
430
 
421
431
  // Update padding when format or ticks change
422
432
  $effect(() => {
@@ -432,13 +442,84 @@
432
442
  : filter_padding(padding, default_padding)
433
443
 
434
444
  if (
435
- pad.t !== new_pad.t ||
436
- pad.b !== new_pad.b ||
437
- pad.l !== new_pad.l ||
438
- pad.r !== new_pad.r
439
- ) pad = new_pad
445
+ base_pad.t !== new_pad.t ||
446
+ base_pad.b !== new_pad.b ||
447
+ base_pad.l !== new_pad.l ||
448
+ base_pad.r !== new_pad.r
449
+ ) base_pad = new_pad
450
+ })
451
+
452
+ // === Auto-move legend/colorbar outside the plot when interior overlap is unavoidable ===
453
+ // (shared logic lives in auto-place.ts so every 2D plot reuses it)
454
+ // ColorBar's orientation prop defaults to horizontal, so treat unset as horizontal too
455
+ const colorbar_is_horizontal = $derived((color_bar?.orientation ?? `horizontal`) === `horizontal`)
456
+ const colorbar_footprint = $derived(
457
+ colorbar_element?.offsetWidth && colorbar_element?.offsetHeight
458
+ ? measure_full_footprint(colorbar_element)
459
+ : colorbar_is_horizontal
460
+ ? { width: 220, height: 56 }
461
+ : { width: 56, height: 100 },
462
+ )
463
+ const legend_footprint = $derived(measured_footprint(legend_element, { width: 120, height: 80 }))
464
+ const legend_has_explicit_pos = $derived(has_explicit_position(legend?.style))
465
+
466
+ // Plot-specific obstacle field: series points/lines normalized to [0,1] (y=0 at top)
467
+ const obstacles_norm = $derived.by(() => {
468
+ if (!width || !height || !filtered_series) return []
469
+ const base_w = width - base_pad.l - base_pad.r
470
+ const base_h = height - base_pad.t - base_pad.b
471
+ if (base_w <= 0 || base_h <= 0) return []
472
+ const norm_x = is_time_x
473
+ ? scaleTime().domain([new Date(x_min), new Date(x_max)]).range([0, 1])
474
+ : create_scale(final_x_axis.scale_type ?? `linear`, [x_min, x_max], [0, 1])
475
+ const norm_y = create_scale(final_y_axis.scale_type ?? `linear`, [y_min, y_max], [0, 1])
476
+ return build_obstacles_norm(
477
+ filtered_series
478
+ .filter((srs) => srs?.filtered_data)
479
+ .map((srs) => ({
480
+ points: srs.filtered_data.map((pt) => ({
481
+ x: is_time_x ? norm_x(new Date(pt.x)) : norm_x(pt.x),
482
+ y: 1 - norm_y(pt.y), // norm_y is 0 at bottom; invert so 0 = top
483
+ })),
484
+ draws_line: styles.show_lines && (srs.markers ?? DEFAULT_MARKERS).includes(`line`),
485
+ })),
486
+ base_w,
487
+ base_h,
488
+ )
440
489
  })
441
490
 
491
+ const decor = $derived.by(() =>
492
+ place_decorations({
493
+ base_pad,
494
+ width,
495
+ height,
496
+ obstacles_norm,
497
+ // gate on legend_element (the actual render signal) not legend_data, whose fill entries read
498
+ // computed_fills -> pad and would make this derived reference itself
499
+ legend: legend != null && legend_element != null &&
500
+ !legend_has_explicit_pos && !legend_is_dragging && !legend_manual_position
501
+ ? { footprint: legend_footprint, clearance: legend?.axis_clearance }
502
+ : null,
503
+ // gate on a measured colorbar: its outside style stretches it to full width, so deciding from
504
+ // the (wide) pre-measure fallback would flip-flop placement between interior and outside
505
+ colorbar: Boolean(color_bar) && all_color_values.length > 0 && !color_bar?.wrapper_style &&
506
+ (colorbar_element?.offsetWidth ?? 0) > 0 && (colorbar_element?.offsetHeight ?? 0) > 0
507
+ ? {
508
+ footprint: colorbar_footprint,
509
+ horizontal: colorbar_is_horizontal,
510
+ clearance: color_bar?.axis_clearance,
511
+ }
512
+ : null,
513
+ })
514
+ )
515
+ const pad = $derived(decor.pad)
516
+ const legend_auto_outside = $derived(decor.legend_outside)
517
+ const legend_outside_x = $derived(decor.legend_pos.x)
518
+ const legend_outside_y = $derived(decor.legend_pos.y)
519
+ const effective_cbar_wrapper_style = $derived(
520
+ color_bar?.wrapper_style ?? (decor.colorbar_outside ? decor.colorbar_style : undefined),
521
+ )
522
+
442
523
  // Reactive clip area dimensions to ensure proper responsiveness
443
524
  let clip_area = $derived({
444
525
  x: pad.l || 0,
@@ -686,10 +767,6 @@
686
767
  )
687
768
 
688
769
  // Filter to points within the plot bounds (handles inverted ranges like [3.5, 1.4])
689
- const in_range = (val: number | null | undefined, lo: number, hi: number) =>
690
- val != null && !isNaN(val) && val >= Math.min(lo, hi) &&
691
- val <= Math.max(lo, hi)
692
-
693
770
  // Determine which ranges to use based on series axis properties
694
771
  const [series_x_min, series_x_max] = (data_series.x_axis ?? `x1`) === `x2`
695
772
  ? [x2_min, x2_max]
@@ -720,7 +797,10 @@
720
797
  ),
721
798
  )
722
799
 
723
- // Collect all plot points for legend placement calculation
800
+ // Obstacle field for legend/colorbar auto-placement. Sampling only data points lets the
801
+ // legend land on top of a steep connecting line whose markers are sparse (e.g. y=x^2), so
802
+ // sample_series_obstacle_points also walks each drawn segment at a fixed pixel cadence.
803
+ const SEGMENT_SAMPLE_STEP = 12 // px between samples taken along a connecting line
724
804
  let plot_points_for_placement = $derived.by(() => {
725
805
  if (!width || !height || !filtered_series) return []
726
806
 
@@ -729,21 +809,17 @@
729
809
  for (const series_data of filtered_series) {
730
810
  if (!series_data?.filtered_data) continue
731
811
  const use_x2_scale = series_data.x_axis === `x2`
732
- for (const point of series_data.filtered_data) {
733
- const active_x_scale = use_x2_scale ? x2_scale_fn : x_scale_fn
734
- const active_is_time_x = use_x2_scale ? is_time_x2 : is_time_x
735
- const point_x_coord = active_is_time_x
736
- ? active_x_scale(new Date(point.x))
737
- : active_x_scale(point.x)
738
- const point_y_coord =
739
- (series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn)(
740
- point.y,
741
- )
742
-
743
- if (isFinite(point_x_coord) && isFinite(point_y_coord)) {
744
- points.push({ x: point_x_coord, y: point_y_coord })
745
- }
746
- }
812
+ const active_x_scale = use_x2_scale ? x2_scale_fn : x_scale_fn
813
+ const active_is_time_x = use_x2_scale ? is_time_x2 : is_time_x
814
+ const active_y_scale = series_data.y_axis === `y2` ? y2_scale_fn : y_scale_fn
815
+ const draws_line = styles.show_lines &&
816
+ (series_data.markers ?? DEFAULT_MARKERS).includes(`line`)
817
+
818
+ const pixel_points = series_data.filtered_data.map((point) => ({
819
+ x: active_is_time_x ? active_x_scale(new Date(point.x)) : active_x_scale(point.x),
820
+ y: active_y_scale(point.y),
821
+ }))
822
+ points.push(...sample_series_obstacle_points(pixel_points, draws_line, SEGMENT_SAMPLE_STEP))
747
823
  }
748
824
  return points
749
825
  })
@@ -756,11 +832,29 @@
756
832
  line_dash?: string
757
833
  }
758
834
 
835
+ const fill_hover_key = (
836
+ source_type: `fill_region` | `error_band`,
837
+ source_idx: number,
838
+ id?: string | number,
839
+ is_duplicate_id = false,
840
+ ): string => {
841
+ if (id == null) return `${source_type}:idx:${source_idx}`
842
+ if (is_duplicate_id) return `${source_type}:id:${id}:idx:${source_idx}`
843
+ return `${source_type}:id:${id}`
844
+ }
845
+ const has_duplicate_id = <T extends { id?: string | number }>(
846
+ items: readonly T[] | undefined,
847
+ source_idx: number,
848
+ id?: string | number,
849
+ ): boolean =>
850
+ id != null && (items?.some((item, idx) => idx !== source_idx && item.id === id) ?? false)
851
+
759
852
  // Computed fill regions: merge fill_regions and converted error_bands, resolve boundaries
760
853
  type ComputedFill = FillRegion & {
761
854
  idx: number
762
855
  source_type: `fill_region` | `error_band`
763
856
  source_idx: number
857
+ hover_key: string
764
858
  path_segments: string[]
765
859
  }
766
860
  let computed_fills = $derived.by((): ComputedFill[] => {
@@ -774,16 +868,29 @@
774
868
  region: FillRegion | null
775
869
  source_type: `fill_region` | `error_band`
776
870
  source_idx: number
871
+ hover_key: string
777
872
  }[] = [
778
873
  ...(fill_regions ?? []).map((region, source_idx) => ({
779
874
  region,
780
875
  source_type: `fill_region` as const,
781
876
  source_idx,
877
+ hover_key: fill_hover_key(
878
+ `fill_region`,
879
+ source_idx,
880
+ region.id,
881
+ has_duplicate_id(fill_regions, source_idx, region.id),
882
+ ),
782
883
  })),
783
884
  ...(error_bands ?? []).map((band, source_idx) => ({
784
885
  region: convert_error_band_to_fill_region(band, series_with_ids),
785
886
  source_type: `error_band` as const,
786
887
  source_idx,
888
+ hover_key: fill_hover_key(
889
+ `error_band`,
890
+ source_idx,
891
+ band.id,
892
+ has_duplicate_id(error_bands, source_idx, band.id),
893
+ ),
787
894
  })),
788
895
  ]
789
896
 
@@ -808,8 +915,9 @@
808
915
  region: FillRegion
809
916
  source_type: `fill_region` | `error_band`
810
917
  source_idx: number
918
+ hover_key: string
811
919
  } => entry.region !== null)
812
- .map(({ region, source_type, source_idx }, idx) => {
920
+ .map(({ region, source_type, source_idx, hover_key }, idx) => {
813
921
  if (region.visible === false) return null
814
922
 
815
923
  // Domain context for boundary resolution
@@ -876,7 +984,7 @@
876
984
 
877
985
  if (path_segments.length === 0) return null
878
986
 
879
- return { ...region, idx, source_type, source_idx, path_segments }
987
+ return { ...region, idx, source_type, source_idx, hover_key, path_segments }
880
988
  })
881
989
  .filter((fill): fill is ComputedFill => fill !== null)
882
990
  })
@@ -1070,14 +1178,10 @@
1070
1178
  const plot_width = width - pad.l - pad.r
1071
1179
  const plot_height = height - pad.t - pad.b
1072
1180
 
1073
- // Use measured size if available, otherwise estimate
1074
- const legend_size = legend_element
1075
- ? { width: legend_element.offsetWidth, height: legend_element.offsetHeight }
1076
- : { width: 120, height: 80 }
1077
-
1078
1181
  const placement_config = {
1079
1182
  plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
1080
- element_size: legend_size,
1183
+ element: legend_element,
1184
+ element_size: { width: 120, height: 80 }, // fallback before first render
1081
1185
  axis_clearance: legend?.axis_clearance,
1082
1186
  exclude_rects: [],
1083
1187
  points: plot_points_for_placement,
@@ -1093,13 +1197,12 @@
1093
1197
  const plot_width = width - pad.l - pad.r
1094
1198
  const plot_height = height - pad.t - pad.b
1095
1199
 
1096
- // Use measured size if available, otherwise estimate based on orientation
1097
- const is_horizontal = color_bar.orientation === `horizontal`
1098
- const colorbar_size = colorbar_element
1099
- ? { width: colorbar_element.offsetWidth, height: colorbar_element.offsetHeight }
1100
- : is_horizontal
1101
- ? { width: 220, height: 40 }
1102
- : { width: 40, height: 100 }
1200
+ // Fallback estimate (with room for tick labels) used before the colorbar first
1201
+ // renders; compute_element_placement measures the real footprint once it's laid out
1202
+ const is_horizontal = (color_bar.orientation ?? `horizontal`) === `horizontal`
1203
+ const colorbar_size = is_horizontal
1204
+ ? { width: 220, height: 56 }
1205
+ : { width: 56, height: 100 }
1103
1206
 
1104
1207
  // Build exclusion rects (avoid legend if it's placed)
1105
1208
  const exclude_rects: Rect[] = []
@@ -1114,9 +1217,11 @@
1114
1217
 
1115
1218
  return compute_element_placement({
1116
1219
  plot_bounds: { x: pad.l, y: pad.t, width: plot_width, height: plot_height },
1220
+ element: colorbar_element,
1117
1221
  element_size: colorbar_size,
1118
- // Colorbar needs slightly more clearance than legend to avoid axis labels
1119
- axis_clearance: 15,
1222
+ // Small gap from the corner; the full-footprint measurement reserves the tick
1223
+ // labels, so this alone keeps the colorbar off the axes
1224
+ axis_clearance: color_bar?.axis_clearance ?? 15,
1120
1225
  exclude_rects,
1121
1226
  points: plot_points_for_placement,
1122
1227
  })
@@ -1867,7 +1972,7 @@
1867
1972
  </script>
1868
1973
 
1869
1974
  {#snippet fill_regions_layer(fills: typeof computed_fills)}
1870
- {#each fills as fill (fill.id ?? fill.idx)}
1975
+ {#each fills as fill (fill.hover_key)}
1871
1976
  {#each fill.path_segments as
1872
1977
  path_d,
1873
1978
  segment_idx
@@ -1880,13 +1985,13 @@
1880
1985
  {clip_path_id}
1881
1986
  {x_scale_fn}
1882
1987
  {y_scale_fn}
1883
- hovered_region={hovered_fill_idx}
1884
- on_click={(event) => {
1988
+ is_hovered={hovered_fill_key === fill.hover_key}
1989
+ on_click={(event: FillHandlerEvent) => {
1885
1990
  fill.on_click?.(event)
1886
1991
  on_fill_click?.(event)
1887
1992
  }}
1888
- on_hover={(event) => {
1889
- hovered_fill_idx = event?.region_idx ?? null
1993
+ on_hover={(event: FillHandlerEvent | null) => {
1994
+ hovered_fill_key = event ? fill.hover_key : null
1890
1995
  fill.on_hover?.(event)
1891
1996
  on_fill_hover?.(event)
1892
1997
  }}
@@ -2020,289 +2125,115 @@
2020
2125
  <!-- Reference lines: below grid -->
2021
2126
  {@render ref_lines_layer(ref_lines_by_z.below_grid)}
2022
2127
 
2023
- <g class="x-axis">
2024
- {#if width > 0 && height > 0}
2025
- {#each x_tick_values as tick (tick)}
2026
- {@const tick_pos_raw = is_time_x ? x_scale_fn(new Date(tick)) : x_scale_fn(tick)}
2027
- {#if isFinite(tick_pos_raw)}
2028
- // Check if tick position is finite
2029
- {@const tick_pos = tick_pos_raw}
2030
- {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
2031
- {@const inside = final_x_axis.tick?.label?.inside ?? false}
2032
- <g class="tick" transform="translate({tick_pos}, {height - pad.b})">
2033
- {#if final_display.x_grid}
2034
- <line
2035
- y1={-(height - pad.b - pad.t)}
2036
- y2="0"
2037
- {...DEFAULT_GRID_STYLE}
2038
- {...final_x_axis.grid_style}
2039
- />
2040
- {/if}
2041
- <line y1="0" y2={inside ? -5 : 5} stroke="var(--border-color, gray)" />
2042
-
2043
- {#if tick >= Math.min(x_min, x_max) && tick <= Math.max(x_min, x_max)}
2044
- {@const base_y = inside ? -8 : 20}
2045
- {@const shift = final_x_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
2046
- {@const x = shift.x ?? 0}
2047
- {@const y = base_y + (shift.y ?? 0)}
2048
- {@const custom_label = get_tick_label(tick, final_x_axis.ticks)}
2049
- {@const dominant_baseline = inside ? `auto` : `hanging`}
2050
- <text
2051
- {x}
2052
- {y}
2053
- dominant-baseline={dominant_baseline}
2054
- fill={final_x_axis.color}
2055
- >
2056
- {custom_label ?? format_value(tick, final_x_axis.format ?? ``)}
2057
- </text>
2058
- {/if}
2059
- </g>
2060
- {/if}
2061
- {/if}
2062
- {/each}
2063
- {/if}
2128
+ <PlotAxis
2129
+ side="x"
2130
+ ticks={x_tick_values}
2131
+ place={(tick) => (is_time_x ? x_scale_fn(new Date(tick)) : x_scale_fn(tick))}
2132
+ axis={final_x_axis}
2133
+ {pad}
2134
+ {width}
2135
+ {height}
2136
+ show_grid={final_display.x_grid}
2137
+ show_baseline={false}
2138
+ domain={[x_min, x_max]}
2139
+ tick_label={(tick) => get_tick_label(tick, final_x_axis.ticks)}
2140
+ label_x={width / 2 + (final_x_axis.label_shift?.x ?? 0)}
2141
+ label_y={height - pad.b - (final_x_axis.label_shift?.y ?? -40)}
2142
+ axis_loading={axis_loading === `x`}
2143
+ on_axis_change={(key) => handle_axis_change(`x`, key)}
2144
+ />
2064
2145
 
2065
- <!-- Current frame indicator -->
2066
- {#if current_x_value != null}
2067
- {@const current_pos_raw = is_time_x
2068
- ? x_scale_fn(new Date(current_x_value))
2069
- : x_scale_fn(current_x_value)}
2070
- {#if isFinite(current_pos_raw)}
2071
- {@const current_pos = current_pos_raw}
2072
- {#if current_pos >= pad.l && current_pos <= width - pad.r}
2073
- {@const active_tick_height = 7}
2074
- <rect
2075
- x={current_pos - 1.5}
2076
- y={height - pad.b - active_tick_height / 2}
2077
- width="3"
2078
- height={active_tick_height}
2079
- fill="var(--scatter-current-frame-color, #ff6b35)"
2080
- stroke="white"
2081
- stroke-width="1"
2082
- class="current-frame-indicator"
2083
- />
2084
- {/if}
2146
+ <!-- Current frame indicator -->
2147
+ {#if current_x_value != null}
2148
+ {@const current_pos_raw = is_time_x
2149
+ ? x_scale_fn(new Date(current_x_value))
2150
+ : x_scale_fn(current_x_value)}
2151
+ {#if isFinite(current_pos_raw)}
2152
+ {@const current_pos = current_pos_raw}
2153
+ {#if current_pos >= pad.l && current_pos <= width - pad.r}
2154
+ {@const active_tick_height = 7}
2155
+ <rect
2156
+ x={current_pos - 1.5}
2157
+ y={height - pad.b - active_tick_height / 2}
2158
+ width="3"
2159
+ height={active_tick_height}
2160
+ fill="var(--scatter-current-frame-color, #ff6b35)"
2161
+ stroke="white"
2162
+ stroke-width="1"
2163
+ class="current-frame-indicator"
2164
+ />
2085
2165
  {/if}
2086
2166
  {/if}
2167
+ {/if}
2087
2168
 
2088
- {#if final_x_axis.label || final_x_axis.options?.length}
2089
- {@const { label_shift, label = ``, options, selected_key, color } = final_x_axis}
2090
- <AxisLabel
2091
- x={width / 2 + (label_shift?.x ?? 0)}
2092
- y={height - pad.b - (label_shift?.y ?? -40)}
2093
- {label}
2094
- {options}
2095
- {selected_key}
2096
- loading={axis_loading === `x`}
2097
- axis_type="x"
2098
- {color}
2099
- on_select={(key) => handle_axis_change(`x`, key)}
2100
- />
2101
- {/if}
2102
- </g>
2103
-
2104
- <g class="y-axis">
2105
- {#if width > 0 && height > 0}
2106
- {#each y_tick_values as tick, idx (tick)}
2107
- {@const tick_pos_raw = y_scale_fn(tick)}
2108
- {#if isFinite(tick_pos_raw)}
2109
- // Check if tick position is finite
2110
- {@const tick_pos = tick_pos_raw}
2111
- {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
2112
- {@const inside = final_y_axis.tick?.label?.inside ?? false}
2113
- <g class="tick" transform="translate({pad.l}, {tick_pos})">
2114
- {#if final_display.y_grid}
2115
- <line
2116
- x1="0"
2117
- x2={width - pad.l - pad.r}
2118
- {...DEFAULT_GRID_STYLE}
2119
- {...final_y_axis.grid_style}
2120
- />
2121
- {/if}
2122
- <line
2123
- x1={inside ? 0 : -5}
2124
- x2={inside ? 5 : 0}
2125
- stroke="var(--border-color, gray)"
2126
- />
2127
-
2128
- {#if tick >= Math.min(y_min, y_max) && tick <= Math.max(y_min, y_max)}
2129
- {@const base_x = inside ? 8 : -8}
2130
- {@const shift = final_y_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
2131
- {@const x = base_x + (shift.x ?? 0)}
2132
- {@const y = shift.y ?? 0}
2133
- {@const custom_label = get_tick_label(tick, final_y_axis.ticks)}
2134
- {@const text_anchor = inside ? `start` : `end`}
2135
- <text {x} {y} text-anchor={text_anchor} fill={final_y_axis.color}>
2136
- {custom_label ?? format_value(tick, final_y_axis.format ?? ``)}
2137
- {#if final_y_axis.unit && idx === 0}
2138
- &zwnj;&ensp;{final_y_axis.unit}
2139
- {/if}
2140
- </text>
2141
- {/if}
2142
- </g>
2143
- {/if}
2144
- {/if}
2145
- {/each}
2146
- {/if}
2147
-
2148
- {#if height > 0 && (final_y_axis.label || final_y_axis.options?.length)}
2149
- {@const { label_shift, label = ``, options, selected_key, color, tick } =
2150
- final_y_axis}
2151
- {@const y_inside = tick?.label?.inside ?? false}
2152
- {@const y_label_x = Math.max(
2169
+ <PlotAxis
2170
+ side="y"
2171
+ ticks={y_tick_values}
2172
+ place={y_scale_fn}
2173
+ axis={final_y_axis}
2174
+ {pad}
2175
+ {width}
2176
+ {height}
2177
+ show_grid={final_display.y_grid}
2178
+ show_baseline={false}
2179
+ domain={[y_min, y_max]}
2180
+ unit_on_first_tick
2181
+ tick_label={(tick) => get_tick_label(tick, final_y_axis.ticks)}
2182
+ label_x={Math.max(
2153
2183
  12,
2154
- pad.l - (y_inside ? 0 : tick_label_widths.y_max) - LABEL_GAP_DEFAULT,
2155
- ) +
2156
- (label_shift?.x ?? 0)}
2157
- <AxisLabel
2158
- x={y_label_x}
2159
- y={pad.t + (height - pad.t - pad.b) / 2 + (label_shift?.y ?? 0)}
2160
- rotate
2161
- {label}
2162
- {options}
2163
- {selected_key}
2164
- loading={axis_loading === `y`}
2165
- axis_type="y"
2166
- {color}
2167
- on_select={(key) => handle_axis_change(`y`, key)}
2168
- />
2169
- {/if}
2170
- </g>
2184
+ pad.l - (final_y_axis.tick?.label?.inside ? 0 : tick_label_widths.y_max) -
2185
+ LABEL_GAP_DEFAULT,
2186
+ ) + (final_y_axis.label_shift?.x ?? 0)}
2187
+ label_y={pad.t + (height - pad.t - pad.b) / 2 + (final_y_axis.label_shift?.y ?? 0)}
2188
+ axis_loading={axis_loading === `y`}
2189
+ on_axis_change={(key) => handle_axis_change(`y`, key)}
2190
+ />
2171
2191
 
2172
2192
  <!-- Y2-axis (Right) -->
2173
2193
  {#if y2_points.length > 0}
2174
- <g class="y2-axis">
2175
- {#if width > 0 && height > 0}
2176
- {#each y2_tick_values as tick, idx (tick)}
2177
- {@const tick_pos_raw = y2_scale_fn(tick)}
2178
- {#if isFinite(tick_pos_raw)}
2179
- // Check if tick position is finite
2180
- {@const tick_pos = tick_pos_raw}
2181
- {#if tick_pos >= pad.t && tick_pos <= height - pad.b}
2182
- {@const inside = final_y2_axis.tick?.label?.inside ?? false}
2183
- <g class="tick" transform="translate({width - pad.r}, {tick_pos})">
2184
- {#if final_display.y2_grid}
2185
- <line
2186
- x1={-(width - pad.l - pad.r)}
2187
- x2="0"
2188
- {...DEFAULT_GRID_STYLE}
2189
- {...final_y2_axis.grid_style}
2190
- />
2191
- {/if}
2192
- <line
2193
- x1={inside ? -5 : 0}
2194
- x2={inside ? 0 : 5}
2195
- stroke="var(--border-color, gray)"
2196
- />
2197
-
2198
- {#if tick >= Math.min(y2_min, y2_max) && tick <= Math.max(y2_min, y2_max)}
2199
- {@const base_x = inside ? -8 : 8}
2200
- {@const shift = final_y2_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
2201
- {@const x = base_x + (shift.x ?? 0)}
2202
- {@const y = shift.y ?? 0}
2203
- {@const custom_label = get_tick_label(tick, final_y2_axis.ticks)}
2204
- {@const text_anchor = inside ? `end` : `start`}
2205
- <text {x} {y} text-anchor={text_anchor} fill={final_y2_axis.color}>
2206
- {custom_label ?? format_value(tick, final_y2_axis.format ?? ``)}
2207
- {#if final_y2_axis.unit && idx === 0}
2208
- &zwnj;&ensp;{final_y2_axis.unit}
2209
- {/if}
2210
- </text>
2211
- {/if}
2212
- </g>
2213
- {/if}
2214
- {/if}
2215
- {/each}
2216
- {/if}
2217
-
2218
- {#if height > 0 && (final_y2_axis.label || final_y2_axis.options?.length)}
2219
- {@const { label_shift, label = ``, options, selected_key, color, tick } =
2220
- final_y2_axis}
2221
- {@const inside = tick?.label?.inside ?? false}
2222
- {@const tick_shift = inside ? 0 : (tick?.label?.shift?.x ?? 0) + 8}
2223
- {@const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max}
2224
- <AxisLabel
2225
- x={width - pad.r + tick_shift + tick_width_contribution +
2226
- LABEL_GAP_DEFAULT + (label_shift?.x ?? 0)}
2227
- y={pad.t + (height - pad.t - pad.b) / 2 + (label_shift?.y ?? 0)}
2228
- rotate
2229
- {label}
2230
- {options}
2231
- {selected_key}
2232
- loading={axis_loading === `y2`}
2233
- axis_type="y2"
2234
- {color}
2235
- on_select={(key) => handle_axis_change(`y2`, key)}
2236
- />
2237
- {/if}
2238
- </g>
2194
+ {@const y2_inside = final_y2_axis.tick?.label?.inside ?? false}
2195
+ {@const y2_tick_shift = y2_inside ? 0 : (final_y2_axis.tick?.label?.shift?.x ?? 0) + 8}
2196
+ {@const y2_tick_width = y2_inside ? 0 : tick_label_widths.y2_max}
2197
+ <PlotAxis
2198
+ side="y2"
2199
+ ticks={y2_tick_values}
2200
+ place={y2_scale_fn}
2201
+ axis={final_y2_axis}
2202
+ {pad}
2203
+ {width}
2204
+ {height}
2205
+ show_grid={final_display.y2_grid}
2206
+ show_baseline={false}
2207
+ domain={[y2_min, y2_max]}
2208
+ unit_on_first_tick
2209
+ tick_label={(tick) => get_tick_label(tick, final_y2_axis.ticks)}
2210
+ label_x={width - pad.r + y2_tick_shift + y2_tick_width + LABEL_GAP_DEFAULT +
2211
+ (final_y2_axis.label_shift?.x ?? 0)}
2212
+ label_y={pad.t + (height - pad.t - pad.b) / 2 + (final_y2_axis.label_shift?.y ?? 0)}
2213
+ axis_loading={axis_loading === `y2`}
2214
+ on_axis_change={(key) => handle_axis_change(`y2`, key)}
2215
+ />
2239
2216
  {/if}
2240
2217
 
2241
2218
  <!-- X2-axis (Top) -->
2242
2219
  {#if x2_points.length > 0}
2243
- <g class="x2-axis">
2244
- {#if width > 0 && height > 0}
2245
- {#each x2_tick_values as tick (tick)}
2246
- {@const tick_pos_raw = is_time_x2
2247
- ? x2_scale_fn(new Date(tick))
2248
- : x2_scale_fn(tick)}
2249
- {#if isFinite(tick_pos_raw)}
2250
- {@const tick_pos = tick_pos_raw}
2251
- {#if tick_pos >= pad.l && tick_pos <= width - pad.r}
2252
- {@const inside = final_x2_axis.tick?.label?.inside ?? false}
2253
- <g class="tick" transform="translate({tick_pos}, {pad.t})">
2254
- {#if final_display.x2_grid}
2255
- <line
2256
- y1="0"
2257
- y2={height - pad.b - pad.t}
2258
- {...DEFAULT_GRID_STYLE}
2259
- {...final_x2_axis.grid_style}
2260
- />
2261
- {/if}
2262
- <line
2263
- y1="0"
2264
- y2={inside ? 5 : -5}
2265
- stroke={final_x2_axis.color || `var(--border-color, gray)`}
2266
- />
2267
-
2268
- {#if tick >= Math.min(x2_min, x2_max) && tick <= Math.max(x2_min, x2_max)}
2269
- {@const base_y = inside ? 8 : -20}
2270
- {@const shift = final_x2_axis.tick?.label?.shift ?? { x: 0, y: 0 }}
2271
- {@const x = shift.x ?? 0}
2272
- {@const y = base_y + (shift.y ?? 0)}
2273
- {@const custom_label = get_tick_label(tick, final_x2_axis.ticks)}
2274
- {@const dominant_baseline = inside ? `hanging` : `auto`}
2275
- <text
2276
- {x}
2277
- {y}
2278
- dominant-baseline={dominant_baseline}
2279
- fill={final_x2_axis.color}
2280
- >
2281
- {custom_label ?? format_value(tick, final_x2_axis.format ?? ``)}
2282
- </text>
2283
- {/if}
2284
- </g>
2285
- {/if}
2286
- {/if}
2287
- {/each}
2288
- {/if}
2289
-
2290
- {#if final_x2_axis.label || final_x2_axis.options?.length}
2291
- {@const { label_shift, label = ``, options, selected_key, color } =
2292
- final_x2_axis}
2293
- <AxisLabel
2294
- x={width / 2 + (label_shift?.x ?? 0)}
2295
- y={Math.max(12, pad.t - (label_shift?.y ?? 40))}
2296
- {label}
2297
- {options}
2298
- {selected_key}
2299
- loading={axis_loading === `x2`}
2300
- axis_type="x2"
2301
- {color}
2302
- on_select={(key) => handle_axis_change(`x2`, key)}
2303
- />
2304
- {/if}
2305
- </g>
2220
+ <PlotAxis
2221
+ side="x2"
2222
+ ticks={x2_tick_values}
2223
+ place={(tick) => (is_time_x2 ? x2_scale_fn(new Date(tick)) : x2_scale_fn(tick))}
2224
+ axis={final_x2_axis}
2225
+ {pad}
2226
+ {width}
2227
+ {height}
2228
+ show_grid={final_display.x2_grid}
2229
+ show_baseline={false}
2230
+ domain={[x2_min, x2_max]}
2231
+ tick_label={(tick) => get_tick_label(tick, final_x2_axis.ticks)}
2232
+ label_x={width / 2 + (final_x2_axis.label_shift?.x ?? 0)}
2233
+ label_y={Math.max(12, pad.t - (final_x2_axis.label_shift?.y ?? 40))}
2234
+ axis_loading={axis_loading === `x2`}
2235
+ on_axis_change={(key) => handle_axis_change(`x2`, key)}
2236
+ />
2306
2237
  {/if}
2307
2238
 
2308
2239
  <!-- Tooltip rendered inside overlay (moved outside SVG for stacking above colorbar) -->
@@ -2417,7 +2348,12 @@
2417
2348
  }
2418
2349
  {@const label_id = `${point.series_idx}-${point.point_idx}`}
2419
2350
  {@const calculated_label_pos = label_positions[label_id]}
2420
- {@const label_style = point.point_label ?? {}}
2351
+ {@const point_label = point.point_label ?? {}}
2352
+ {@const label_style = point_label.auto_placement &&
2353
+ actual_label_config.max_neighbors &&
2354
+ !calculated_label_pos
2355
+ ? {}
2356
+ : point_label}
2421
2357
  {@const final_label = calculated_label_pos
2422
2358
  ? {
2423
2359
  ...label_style,
@@ -2608,7 +2544,7 @@
2608
2544
  has_x2_points={x2_points.length > 0}
2609
2545
  has_y2_points={y2_points.length > 0}
2610
2546
  children={controls_extra}
2611
- on_touch={(key) => touched.add(key)}
2547
+ on_touch={(key: string) => touched.add(key)}
2612
2548
  />
2613
2549
  {/if}
2614
2550
 
@@ -2627,12 +2563,10 @@
2627
2563
  class="colorbar-wrapper"
2628
2564
  role="img"
2629
2565
  aria-label="Color scale legend"
2630
- style={`
2631
- position: absolute;
2632
- left: ${tweened_colorbar_coords.current.x}px;
2633
- top: ${tweened_colorbar_coords.current.y}px;
2634
- pointer-events: auto;
2635
- `}
2566
+ style={`${
2567
+ // explicit wrapper_style or auto-outside places the colorbar; else auto-placement coords
2568
+ effective_cbar_wrapper_style ??
2569
+ `position: absolute; left: ${tweened_colorbar_coords.current.x}px; top: ${tweened_colorbar_coords.current.y}px`}; pointer-events: auto;`}
2636
2570
  >
2637
2571
  <ColorBar
2638
2572
  tick_labels={4}
@@ -2641,9 +2575,9 @@
2641
2575
  color_scale_domain={color_domain}
2642
2576
  scale_type={typeof color_scale === `string` ? undefined : color_scale.type}
2643
2577
  range={color_domain?.every((val) => val != null) ? color_domain : undefined}
2644
- wrapper_style={color_bar?.wrapper_style ?? ``}
2645
- bar_style="width: 220px; height: 20px; {color_bar?.style ?? ``}"
2578
+ bar_style="width: 220px; height: 16px; {color_bar?.style ?? ``}"
2646
2579
  {...color_bar}
2580
+ wrapper_style={effective_cbar_wrapper_style ? `height: 100%; width: 100%;` : ``}
2647
2581
  />
2648
2582
  </div>
2649
2583
  {/if}
@@ -2656,11 +2590,15 @@
2656
2590
  {@const default_y = pad.t + 10}
2657
2591
  {@const current_x = legend_is_dragging && legend_manual_position
2658
2592
  ? legend_manual_position.x
2593
+ : legend_auto_outside
2594
+ ? legend_outside_x
2659
2595
  : legend_placement
2660
2596
  ? tweened_legend_coords.current.x
2661
2597
  : default_x}
2662
2598
  {@const current_y = legend_is_dragging && legend_manual_position
2663
2599
  ? legend_manual_position.y
2600
+ : legend_auto_outside
2601
+ ? legend_outside_y
2664
2602
  : legend_placement
2665
2603
  ? tweened_legend_coords.current.y
2666
2604
  : default_y}
@@ -2671,7 +2609,7 @@
2671
2609
  on_drag={handle_legend_drag}
2672
2610
  on_drag_end={() => (legend_is_dragging = false)}
2673
2611
  on_hover_change={legend_hover.set_locked}
2674
- on_item_hover={(series_idx) =>
2612
+ on_item_hover={(series_idx: number | null) =>
2675
2613
  (hovered_legend_series_idx = series_idx != null && series_idx >= 0
2676
2614
  ? series_idx
2677
2615
  : null)}
@@ -2679,24 +2617,24 @@
2679
2617
  draggable={legend?.draggable ?? true}
2680
2618
  {...legend}
2681
2619
  on_toggle={legend?.on_toggle ??
2682
- ((series_idx) => {
2620
+ ((series_idx: number) => {
2683
2621
  series = toggle_series_visibility(series, series_idx)
2684
2622
  })}
2685
2623
  on_double_click={legend?.on_double_click ??
2686
- ((double_clicked_idx) => {
2624
+ ((double_clicked_idx: number) => {
2687
2625
  const result = handle_legend_double_click(
2688
2626
  series,
2689
2627
  double_clicked_idx,
2690
- previous_series_visibility,
2628
+ prev_series_visibility,
2691
2629
  )
2692
2630
  series = result.series
2693
- previous_series_visibility = result.previous_visibility
2631
+ prev_series_visibility = result.prev_visibility
2694
2632
  })}
2695
2633
  on_group_toggle={legend?.on_group_toggle ??
2696
- ((_group_name, series_indices) => {
2634
+ ((_group_name: string, series_indices: number[]) => {
2697
2635
  series = toggle_group_visibility(series, series_indices)
2698
2636
  })}
2699
- on_fill_toggle={(source_type, source_idx) => {
2637
+ on_fill_toggle={(source_type: `fill_region` | `error_band`, source_idx: number) => {
2700
2638
  // Only fill_regions can be toggled (error_bands are not bindable)
2701
2639
  if (source_type === `fill_region`) {
2702
2640
  fill_regions = fill_regions.map((region, idx) =>
@@ -2706,11 +2644,14 @@
2706
2644
  )
2707
2645
  }
2708
2646
  }}
2709
- on_fill_double_click={(source_type, source_idx) => {
2647
+ on_fill_double_click={(
2648
+ source_type: `fill_region` | `error_band`,
2649
+ source_idx: number,
2650
+ ) => {
2710
2651
  // Only fill_regions can be toggled (error_bands are not bindable)
2711
2652
  if (source_type !== `fill_region`) return
2712
2653
  // Toggle: if only this fill is visible, show all; otherwise show only this one
2713
- const visible_count = fill_regions.filter((r) => r.visible !== false).length
2654
+ const visible_count = fill_regions.filter((region) => region.visible !== false).length
2714
2655
  const this_visible = fill_regions[source_idx]?.visible !== false
2715
2656
  if (visible_count === 1 && this_visible) {
2716
2657
  // Show all fills
@@ -2770,6 +2711,13 @@
2770
2711
  padding-top: var(--plot-fullscreen-padding-top, 2em);
2771
2712
  box-sizing: border-box;
2772
2713
  }
2714
+ /* Center the colorbar within its wrapper when shorter than it (e.g. capped by --cbar-max-height
2715
+ in fullscreen). Users can override via wrapper_style (inline wins). */
2716
+ .colorbar-wrapper {
2717
+ display: flex;
2718
+ align-items: center;
2719
+ justify-content: center;
2720
+ }
2773
2721
  .header-controls {
2774
2722
  position: absolute;
2775
2723
  top: var(--ctrl-btn-top, 5pt);
@@ -2805,21 +2753,6 @@
2805
2753
  font-weight: var(--scatter-font-weight);
2806
2754
  font-size: var(--scatter-font-size);
2807
2755
  }
2808
- line {
2809
- stroke: var(--scatter-grid-stroke, gray);
2810
- stroke-dasharray: var(--scatter-grid-dash, 4);
2811
- stroke-width: var(--scatter-grid-width, 0.4);
2812
- }
2813
- g:is(.x-axis, .x2-axis) text {
2814
- text-anchor: middle;
2815
- dominant-baseline: top;
2816
- }
2817
- g:is(.y-axis, .y2-axis) text {
2818
- dominant-baseline: central;
2819
- }
2820
- g:is(.x-axis, .x2-axis, .y-axis, .y2-axis) .tick text {
2821
- font-size: var(--tick-font-size, 0.8em); /* shrink tick labels */
2822
- }
2823
2756
  .scatter :global(.axis-label) {
2824
2757
  text-align: center;
2825
2758
  width: 100%;