matterviz 0.4.2 → 0.4.3

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 (244) hide show
  1. package/dist/FilePicker.svelte +40 -50
  2. package/dist/api/optimade.js +10 -7
  3. package/dist/app.css +15 -0
  4. package/dist/brillouin/BrillouinZone.svelte +18 -9
  5. package/dist/brillouin/BrillouinZoneScene.svelte +20 -29
  6. package/dist/brillouin/compute.js +10 -16
  7. package/dist/brillouin/geometry.js +2 -2
  8. package/dist/chempot-diagram/ChemPotDiagram.svelte +18 -15
  9. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +17 -113
  10. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +104 -264
  11. package/dist/chempot-diagram/async-compute.svelte.js +12 -13
  12. package/dist/chempot-diagram/color.d.ts +21 -1
  13. package/dist/chempot-diagram/color.js +70 -1
  14. package/dist/chempot-diagram/compute.d.ts +7 -0
  15. package/dist/chempot-diagram/compute.js +85 -26
  16. package/dist/chempot-diagram/export.js +1 -6
  17. package/dist/chempot-diagram/temperature.js +9 -10
  18. package/dist/composition/BarChart.svelte +7 -9
  19. package/dist/composition/Composition.svelte +3 -8
  20. package/dist/composition/Formula.svelte +2 -2
  21. package/dist/composition/FormulaFilter.svelte +27 -93
  22. package/dist/composition/format.js +6 -8
  23. package/dist/composition/parse.js +50 -105
  24. package/dist/constants.d.ts +0 -1
  25. package/dist/constants.js +6 -17
  26. package/dist/convex-hull/ConvexHull2D.svelte +0 -2
  27. package/dist/convex-hull/ConvexHull3D.svelte +19 -47
  28. package/dist/convex-hull/ConvexHull4D.svelte +65 -95
  29. package/dist/convex-hull/StructurePopup.svelte +1 -1
  30. package/dist/convex-hull/gas-thermodynamics.js +21 -53
  31. package/dist/convex-hull/helpers.js +1 -7
  32. package/dist/convex-hull/thermodynamics.js +19 -27
  33. package/dist/coordination/CoordinationBarPlot.svelte +12 -21
  34. package/dist/element/BohrAtom.svelte +0 -1
  35. package/dist/element/ElementTile.svelte +26 -44
  36. package/dist/element/data.d.ts +2 -0
  37. package/dist/element/data.js +5 -1
  38. package/dist/element/index.d.ts +1 -1
  39. package/dist/element/index.js +1 -1
  40. package/dist/fermi-surface/FermiSurface.svelte +22 -23
  41. package/dist/fermi-surface/FermiSurface.svelte.d.ts +2 -6
  42. package/dist/fermi-surface/FermiSurfaceScene.svelte +32 -77
  43. package/dist/fermi-surface/compute.js +22 -55
  44. package/dist/fermi-surface/parse.js +38 -70
  45. package/dist/file-viewer/JsonBrowser.svelte +1043 -0
  46. package/dist/file-viewer/JsonBrowser.svelte.d.ts +9 -0
  47. package/dist/file-viewer/PlotPanel.svelte +350 -0
  48. package/dist/file-viewer/PlotPanel.svelte.d.ts +9 -0
  49. package/dist/file-viewer/TrajectoryWithDos.svelte +30 -0
  50. package/dist/file-viewer/TrajectoryWithDos.svelte.d.ts +10 -0
  51. package/dist/file-viewer/detect.d.ts +11 -0
  52. package/dist/file-viewer/detect.js +381 -0
  53. package/dist/file-viewer/eligibility.d.ts +4 -0
  54. package/dist/file-viewer/eligibility.js +53 -0
  55. package/dist/file-viewer/host-protocol.d.ts +50 -0
  56. package/dist/file-viewer/host-transfer.d.ts +25 -0
  57. package/dist/file-viewer/host-transfer.js +42 -0
  58. package/dist/file-viewer/index.d.ts +5 -0
  59. package/dist/file-viewer/index.js +5 -0
  60. package/dist/file-viewer/main.d.ts +44 -0
  61. package/dist/file-viewer/main.js +535 -0
  62. package/dist/file-viewer/parse.d.ts +9 -0
  63. package/dist/file-viewer/parse.js +170 -0
  64. package/dist/file-viewer/plot-utils.d.ts +24 -0
  65. package/dist/file-viewer/plot-utils.js +191 -0
  66. package/dist/file-viewer/types.d.ts +4 -0
  67. package/dist/file-viewer/types.js +5 -0
  68. package/dist/heatmap-matrix/HeatmapMatrix.svelte +57 -84
  69. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +1 -1
  70. package/dist/index.d.ts +2 -2
  71. package/dist/index.js +1 -1
  72. package/dist/io/ExportPane.svelte +1 -1
  73. package/dist/io/decompress.d.ts +2 -0
  74. package/dist/io/decompress.js +3 -2
  75. package/dist/io/export.d.ts +7 -4
  76. package/dist/io/export.js +70 -18
  77. package/dist/io/file-drop.js +60 -35
  78. package/dist/io/url-drop.d.ts +2 -0
  79. package/dist/io/url-drop.js +28 -16
  80. package/dist/isosurface/Isosurface.svelte +687 -196
  81. package/dist/isosurface/Isosurface.svelte.d.ts +7 -1
  82. package/dist/isosurface/IsosurfaceControls.svelte +564 -85
  83. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +1 -1
  84. package/dist/isosurface/VolumeSlice.svelte +224 -0
  85. package/dist/isosurface/VolumeSlice.svelte.d.ts +28 -0
  86. package/dist/isosurface/coloring.d.ts +20 -0
  87. package/dist/isosurface/coloring.js +118 -0
  88. package/dist/isosurface/geometry-worker-types.d.ts +38 -0
  89. package/dist/isosurface/geometry.worker.d.ts +1 -0
  90. package/dist/isosurface/geometry.worker.js +59 -0
  91. package/dist/isosurface/grid.d.ts +10 -0
  92. package/dist/isosurface/grid.js +31 -0
  93. package/dist/isosurface/index.d.ts +4 -0
  94. package/dist/isosurface/index.js +4 -0
  95. package/dist/isosurface/profile.d.ts +12 -0
  96. package/dist/isosurface/profile.js +19 -0
  97. package/dist/isosurface/sampling.d.ts +34 -0
  98. package/dist/isosurface/sampling.js +408 -0
  99. package/dist/isosurface/slice-rendering.d.ts +13 -0
  100. package/dist/isosurface/slice-rendering.js +79 -0
  101. package/dist/isosurface/slice.d.ts +22 -2
  102. package/dist/isosurface/slice.js +200 -116
  103. package/dist/isosurface/types.d.ts +24 -1
  104. package/dist/isosurface/types.js +120 -8
  105. package/dist/json-path.d.ts +4 -0
  106. package/dist/json-path.js +89 -0
  107. package/dist/labels.js +1 -1
  108. package/dist/layout/InfoCard.svelte +1 -3
  109. package/dist/layout/SettingsSection.svelte +52 -61
  110. package/dist/layout/SettingsSection.svelte.d.ts +6 -7
  111. package/dist/layout/ViewerChrome.svelte +7 -1
  112. package/dist/layout/json-tree/JsonNode.svelte +14 -48
  113. package/dist/layout/json-tree/JsonTree.svelte +48 -95
  114. package/dist/layout/json-tree/JsonValue.svelte +3 -18
  115. package/dist/layout/json-tree/utils.d.ts +0 -3
  116. package/dist/layout/json-tree/utils.js +50 -179
  117. package/dist/marching-cubes.d.ts +8 -1
  118. package/dist/marching-cubes.js +115 -48
  119. package/dist/math.d.ts +3 -2
  120. package/dist/math.js +29 -38
  121. package/dist/overlays/ContextMenu.svelte +15 -32
  122. package/dist/overlays/DraggablePane.svelte +39 -58
  123. package/dist/overlays/DraggablePane.svelte.d.ts +1 -1
  124. package/dist/overlays/GlassChip.svelte +3 -1
  125. package/dist/periodic-table/PeriodicTableControls.svelte +26 -52
  126. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +18 -34
  127. package/dist/phase-diagram/build-diagram.js +1 -6
  128. package/dist/phase-diagram/svg-to-diagram.js +9 -14
  129. package/dist/plot/bar/BarPlot.svelte +38 -42
  130. package/dist/plot/bar/SpacegroupBarPlot.svelte +18 -42
  131. package/dist/plot/box/BoxPlot.svelte +27 -23
  132. package/dist/plot/core/auto-place.d.ts +1 -1
  133. package/dist/plot/core/auto-place.js +1 -1
  134. package/dist/plot/core/data-transform.d.ts +1 -2
  135. package/dist/plot/core/data-transform.js +0 -10
  136. package/dist/plot/core/layout.d.ts +12 -5
  137. package/dist/plot/core/layout.js +37 -27
  138. package/dist/plot/core/pan-zoom.svelte.d.ts +2 -4
  139. package/dist/plot/core/pan-zoom.svelte.js +34 -17
  140. package/dist/plot/core/placed-tween.svelte.d.ts +3 -0
  141. package/dist/plot/core/placed-tween.svelte.js +68 -21
  142. package/dist/plot/core/svg.js +0 -1
  143. package/dist/plot/core/utils/hierarchy-chart.d.ts +1 -0
  144. package/dist/plot/core/utils/hierarchy-chart.js +12 -0
  145. package/dist/plot/core/utils/hierarchy-labels.js +3 -2
  146. package/dist/plot/histogram/Histogram.svelte +21 -26
  147. package/dist/plot/sankey/sankey.js +13 -9
  148. package/dist/plot/scatter/BinnedScatterPlot.svelte +59 -43
  149. package/dist/plot/scatter/ScatterPlot.svelte +67 -52
  150. package/dist/plot/scatter/adaptive-density.d.ts +1 -1
  151. package/dist/plot/scatter/adaptive-density.js +36 -20
  152. package/dist/plot/sunburst/Sunburst.svelte +20 -20
  153. package/dist/plot/treemap/Treemap.svelte +175 -60
  154. package/dist/plot/treemap/Treemap.svelte.d.ts +6 -0
  155. package/dist/plot/treemap/index.d.ts +1 -0
  156. package/dist/plot/treemap/labels.d.ts +38 -0
  157. package/dist/plot/treemap/labels.js +80 -0
  158. package/dist/plot/treemap/treemap.d.ts +4 -2
  159. package/dist/plot/treemap/treemap.js +5 -2
  160. package/dist/rdf/RdfPlot.svelte +17 -35
  161. package/dist/rdf/calc-rdf.js +59 -44
  162. package/dist/rdf/index.d.ts +0 -1
  163. package/dist/sanitize.js +84 -26
  164. package/dist/scene/SceneCamera.svelte +1 -1
  165. package/dist/scene/SceneCamera.svelte.d.ts +1 -1
  166. package/dist/scene/index.d.ts +1 -2
  167. package/dist/scene/index.js +1 -1
  168. package/dist/scene/{props.js → props.svelte.js} +15 -1
  169. package/dist/settings.js +159 -482
  170. package/dist/spectral/Bands.svelte +151 -158
  171. package/dist/spectral/BrillouinBandsDos.svelte +4 -19
  172. package/dist/spectral/Dos.svelte +26 -24
  173. package/dist/spectral/helpers.js +40 -105
  174. package/dist/structure/AtomLegend.svelte +8 -13
  175. package/dist/structure/AtomLegend.svelte.d.ts +2 -3
  176. package/dist/structure/CellSelect.svelte +35 -10
  177. package/dist/structure/Structure.svelte +177 -99
  178. package/dist/structure/Structure.svelte.d.ts +1 -1
  179. package/dist/structure/StructureCarousel.svelte +10 -3
  180. package/dist/structure/StructureControls.svelte +8 -26
  181. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  182. package/dist/structure/StructureInfoPane.svelte +65 -76
  183. package/dist/structure/StructureScene.svelte +75 -109
  184. package/dist/structure/StructureScene.svelte.d.ts +3 -1
  185. package/dist/structure/StructureViewport.svelte +120 -55
  186. package/dist/structure/StructureViewport.svelte.d.ts +3 -1
  187. package/dist/structure/atom-properties.d.ts +1 -1
  188. package/dist/structure/atom-properties.js +4 -3
  189. package/dist/structure/bond-order-perception.js +3 -8
  190. package/dist/structure/bonding.d.ts +0 -1
  191. package/dist/structure/bonding.js +16 -23
  192. package/dist/structure/export.js +50 -74
  193. package/dist/structure/format-detect.js +5 -7
  194. package/dist/structure/index.js +6 -12
  195. package/dist/structure/parse.js +38 -100
  196. package/dist/structure/partial-occupancy.js +7 -4
  197. package/dist/structure/pbc.js +8 -10
  198. package/dist/structure/polyhedra.js +5 -4
  199. package/dist/structure/prop-groups.d.ts +85 -0
  200. package/dist/structure/prop-groups.js +18 -0
  201. package/dist/symmetry/SymmetryStats.svelte +0 -4
  202. package/dist/symmetry/WyckoffTable.svelte +5 -4
  203. package/dist/symmetry/cell-transform.js +5 -14
  204. package/dist/symmetry/index.js +6 -9
  205. package/dist/table/HeatmapTable.svelte +61 -103
  206. package/dist/table/ToggleMenu.svelte +166 -44
  207. package/dist/table/ToggleMenu.svelte.d.ts +1 -1
  208. package/dist/theme/embedded.d.ts +18 -0
  209. package/dist/theme/embedded.js +206 -0
  210. package/dist/theme/index.d.ts +4 -0
  211. package/dist/theme/index.js +1 -2
  212. package/dist/theme/themes.mjs +31 -60
  213. package/dist/tooltip/TooltipContent.svelte +1 -1
  214. package/dist/tooltip/TooltipContent.svelte.d.ts +1 -1
  215. package/dist/tooltip/index.d.ts +8 -1
  216. package/dist/trajectory/Trajectory.svelte +140 -120
  217. package/dist/trajectory/TrajectoryError.svelte +1 -66
  218. package/dist/trajectory/TrajectoryExportPane.svelte +0 -1
  219. package/dist/trajectory/TrajectoryInfoPane.svelte +55 -80
  220. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +2 -1
  221. package/dist/trajectory/extract.js +19 -29
  222. package/dist/trajectory/format-detect.d.ts +2 -0
  223. package/dist/trajectory/format-detect.js +7 -5
  224. package/dist/trajectory/frame-reader.js +3 -4
  225. package/dist/trajectory/helpers.js +16 -49
  226. package/dist/trajectory/index.js +2 -8
  227. package/dist/trajectory/parse/ase.js +1 -1
  228. package/dist/trajectory/parse/index.d.ts +4 -3
  229. package/dist/trajectory/parse/index.js +12 -17
  230. package/dist/trajectory/parse/vaspout-h5.d.ts +0 -1
  231. package/dist/trajectory/parse/vaspout-h5.js +1 -4
  232. package/dist/trajectory/parse/xyz.js +48 -2
  233. package/dist/trajectory/plotting.js +14 -37
  234. package/dist/xrd/XrdPlot.svelte +43 -68
  235. package/dist/xrd/calc-xrd.js +22 -58
  236. package/dist/xrd/parse.js +13 -22
  237. package/package.json +40 -8
  238. package/readme.md +6 -5
  239. package/dist/scene/types.d.ts +0 -26
  240. package/dist/tooltip/types.d.ts +0 -8
  241. package/dist/trajectory/constants.d.ts +0 -6
  242. package/dist/trajectory/constants.js +0 -11
  243. /package/dist/{scene/types.js → file-viewer/host-protocol.js} +0 -0
  244. /package/dist/{tooltip/types.js → isosurface/geometry-worker-types.js} +0 -0
@@ -0,0 +1,1043 @@
1
+ <script lang="ts">
2
+ // Split-pane browser for navigating and visualizing structured JSON data.
3
+ // Left sidebar shows a JsonTree for navigating the file's JSON structure.
4
+ // Right canvas renders one or more visualization panels in a split layout.
5
+ // Users can click tree nodes to render in the main panel, or drag nodes
6
+ // to specific edges to create horizontal/vertical splits.
7
+ import BrillouinZone from '../brillouin/BrillouinZone.svelte'
8
+ import ConvexHull from '../convex-hull/ConvexHull.svelte'
9
+ import type { PhaseData } from '../convex-hull/types'
10
+ import FermiSurface from '../fermi-surface/FermiSurface.svelte'
11
+ import { is_fermi_surface_data } from '../fermi-surface/types'
12
+ import type { VolumetricData } from '../isosurface/types'
13
+ import JsonTree from '../layout/json-tree/JsonTree.svelte'
14
+ import IsobaricBinaryPhaseDiagram from '../phase-diagram/IsobaricBinaryPhaseDiagram.svelte'
15
+ import type { PhaseDiagramData } from '../phase-diagram/types'
16
+ import { merge, type DefaultSettings } from '../settings'
17
+ import Bands from '../spectral/Bands.svelte'
18
+ import BandsAndDos from '../spectral/BandsAndDos.svelte'
19
+ import Dos from '../spectral/Dos.svelte'
20
+ import type { BaseBandStructure, DosInput } from '../spectral/types'
21
+ import type { AnyStructure, LatticeType } from '../structure'
22
+ import {
23
+ is_optimade_raw,
24
+ normalize_fractional_coords,
25
+ parse_optimade_from_raw,
26
+ } from '../structure/parse'
27
+ import { build_structure_props_from_settings as structure_props } from '../structure/prop-groups'
28
+ import Structure from '../structure/Structure.svelte'
29
+ import type { XrdPattern } from '../xrd'
30
+ import XrdPlot from '../xrd/XrdPlot.svelte'
31
+ import { mount, unmount } from 'svelte'
32
+ import {
33
+ detect_view_type,
34
+ resolve_path,
35
+ scan_renderable_paths,
36
+ TYPE_COLORS,
37
+ TYPE_LABELS,
38
+ } from './detect'
39
+ import type { RenderableType } from './detect'
40
+ import PlotPanel from './PlotPanel.svelte'
41
+
42
+ let {
43
+ value,
44
+ defaults,
45
+ filename,
46
+ }: {
47
+ value: unknown
48
+ defaults?: DefaultSettings
49
+ filename?: string
50
+ } = $props()
51
+
52
+ // === Panel state ===
53
+ // Each panel holds a mounted component. Panels are arranged in a flat list
54
+ // with split info (direction) between consecutive panels.
55
+ // Panel sizing is stored as a parallel array of flex weights.
56
+ interface PanelInfo {
57
+ id: string
58
+ data_path: string
59
+ detected_type: RenderableType
60
+ val: unknown
61
+ component: ReturnType<typeof mount> | null
62
+ element: HTMLElement | null
63
+ }
64
+
65
+ type SplitDirection = `horizontal` | `vertical`
66
+
67
+ let panels = $state<PanelInfo[]>([])
68
+ let panel_sizes = $state<number[]>([]) // flex weight per panel (parallel to panels[])
69
+ let split_directions = $state<SplitDirection[]>([]) // direction between panels[i] and panels[i+1]
70
+
71
+ // Debounce timer for rapid clicks (cleaned up when component is destroyed)
72
+ let select_timer: ReturnType<typeof setTimeout> | undefined
73
+ let destroyed = false
74
+ $effect(() => () => {
75
+ destroyed = true
76
+ if (select_timer) clearTimeout(select_timer)
77
+ })
78
+
79
+ // Unmount all imperatively mounted panels when this component is destroyed
80
+ // (prevents leaked WebGL contexts, Three.js renderers, etc.)
81
+ $effect(() => () => {
82
+ for (let idx = 0; idx < panels.length; idx++) {
83
+ unmount_panel(idx)
84
+ }
85
+ })
86
+
87
+ // Scan for renderable paths asynchronously to avoid blocking the UI on large JSON files.
88
+ // Uses requestIdleCallback to yield to the main thread during tree rendering.
89
+ let renderable_paths = $state(new Map<string, { type: RenderableType; label: string }>())
90
+ let auto_rendered = false
91
+ $effect(() => {
92
+ const current_value = value
93
+ const idle_handle = requestIdleCallback(() => {
94
+ renderable_paths = scan_renderable_paths(current_value)
95
+ // Auto-render if the root value itself is a single renderable type
96
+ // (avoids forcing the user to click for single-type JSON files)
97
+ if (!auto_rendered && renderable_paths.has(``)) {
98
+ auto_rendered = true
99
+ const info = renderable_paths.get(``)
100
+ if (info) replace_or_add_panel(``, info.type, current_value)
101
+ }
102
+ })
103
+ return () => cancelIdleCallback(idle_handle)
104
+ })
105
+
106
+ // === Draggable sidebar divider ===
107
+ let sidebar_width = $state(320)
108
+ let is_sidebar_dragging = $state(false)
109
+
110
+ // Generic drag cleanup helper -- in a webview iframe the cursor can leave the
111
+ // document entirely, so we listen for mouseup, blur, and pointerleave to ensure
112
+ // the drag always terminates.
113
+ function start_drag(on_move: (event: MouseEvent) => void, on_done: () => void): void {
114
+ let cleaned_up = false
115
+ function cleanup(): void {
116
+ if (cleaned_up) return
117
+ cleaned_up = true
118
+ on_done()
119
+ globalThis.removeEventListener(`mousemove`, on_move)
120
+ globalThis.removeEventListener(`mouseup`, cleanup)
121
+ globalThis.removeEventListener(`blur`, cleanup)
122
+ document.documentElement.removeEventListener(`mouseleave`, cleanup)
123
+ }
124
+ globalThis.addEventListener(`mousemove`, on_move)
125
+ globalThis.addEventListener(`mouseup`, cleanup)
126
+ globalThis.addEventListener(`blur`, cleanup)
127
+ document.documentElement.addEventListener(`mouseleave`, cleanup)
128
+ }
129
+
130
+ function on_sidebar_divider_mousedown(event: MouseEvent): void {
131
+ event.preventDefault()
132
+ is_sidebar_dragging = true
133
+ const start_x = event.clientX
134
+ const start_width = sidebar_width
135
+ start_drag(
136
+ (move_event) => {
137
+ const delta = move_event.clientX - start_x
138
+ sidebar_width = Math.max(150, Math.min(start_width + delta, window.innerWidth * 0.6))
139
+ },
140
+ () => {
141
+ is_sidebar_dragging = false
142
+ },
143
+ )
144
+ }
145
+
146
+ // === Drag-and-drop from tree ===
147
+ let drop_zone = $state<`top` | `bottom` | `left` | `right` | `center` | null>(null)
148
+ let drop_target_panel_idx = $state<number>(-1)
149
+ let canvas_element: HTMLElement | undefined = $state()
150
+
151
+ let sidebar_element: HTMLElement | undefined = $state()
152
+
153
+ // Strip internal suffix used to register multiple renderable types at the same path
154
+ function strip_type_suffix(path: string): string {
155
+ const idx = path.indexOf(`\u0000`)
156
+ return idx !== -1 ? path.slice(0, idx) : path
157
+ }
158
+
159
+ // Convert a data path (relative to JSON root) to the tree path used by JsonTree
160
+ function data_to_tree_path(data_path: string): string {
161
+ const clean = strip_type_suffix(data_path)
162
+ return filename ? (clean ? `${filename}.${clean}` : filename) : clean
163
+ }
164
+
165
+ // Build a map of tree paths that are renderable (for fast draggable lookup).
166
+ // Skip synthetic suffix paths (\x00...) since those have their own badge drag handlers.
167
+ let renderable_tree_paths = $derived(
168
+ new Map(
169
+ [...renderable_paths]
170
+ .filter(([data_path]) => !data_path.includes(`\u0000`))
171
+ .map(([data_path, info]) => [data_to_tree_path(data_path), { data_path, ...info }]),
172
+ ),
173
+ )
174
+
175
+ // Mark renderable tree nodes as draggable via attribute (no per-node listeners)
176
+ function mark_draggable_nodes(): void {
177
+ if (!sidebar_element) return
178
+ for (const node of sidebar_element.querySelectorAll(`[data-path]`)) {
179
+ const el = node as HTMLElement
180
+ const tree_path = el.getAttribute(`data-path`) ?? ``
181
+ el.draggable = renderable_tree_paths.has(tree_path)
182
+ }
183
+ }
184
+
185
+ // Single delegated dragstart handler on sidebar (no per-node listeners needed)
186
+ $effect(() => {
187
+ if (!sidebar_element) return
188
+ function on_dragstart(event: DragEvent): void {
189
+ if (!event.dataTransfer) {
190
+ event.preventDefault()
191
+ return
192
+ }
193
+ const origin = event.target
194
+ if (!(origin instanceof HTMLElement)) return
195
+ // If dragging a badge directly, use its own data attributes
196
+ const badge = origin.closest(`.renderable-badge`) as HTMLElement | null
197
+ if (badge) {
198
+ const data_path = badge.dataset.renderable_path ?? ``
199
+ const detected_type = badge.dataset.renderable_type ?? ``
200
+ event.dataTransfer.setData(`text/plain`, JSON.stringify({ data_path, detected_type }))
201
+ event.dataTransfer.effectAllowed = `copy`
202
+ return
203
+ }
204
+ // Otherwise dragging a tree node -- look up from tree path
205
+ const target = origin.closest(`[data-path]`) as HTMLElement | null
206
+ if (!target) return
207
+ const tree_path = target.getAttribute(`data-path`) ?? ``
208
+ const info = renderable_tree_paths.get(tree_path)
209
+ if (!info) {
210
+ event.preventDefault()
211
+ return
212
+ }
213
+ event.dataTransfer.setData(
214
+ `text/plain`,
215
+ JSON.stringify({ data_path: info.data_path, detected_type: info.type }),
216
+ )
217
+ event.dataTransfer.effectAllowed = `copy`
218
+ }
219
+ sidebar_element.addEventListener(`dragstart`, on_dragstart)
220
+ return () => sidebar_element?.removeEventListener(`dragstart`, on_dragstart)
221
+ })
222
+
223
+ // === Badge injection ===
224
+ function apply_badges(): void {
225
+ if (!sidebar_element || renderable_paths.size === 0) return
226
+ for (const existing of sidebar_element.querySelectorAll(`.renderable-badge`)) {
227
+ existing.remove()
228
+ }
229
+ for (const [data_path, info] of renderable_paths) {
230
+ const node = sidebar_element.querySelector(
231
+ `[data-path="${CSS.escape(data_to_tree_path(data_path))}"]`,
232
+ )
233
+ if (!node) continue
234
+ const colon_el = node.querySelector(`.colon`)
235
+ const insert_after = colon_el ?? node.querySelector(`.node-key`) ?? node
236
+ const badge = document.createElement(`span`)
237
+ badge.className = `renderable-badge`
238
+ badge.textContent = info.label
239
+ badge.title = `Drag to canvas or click to render`
240
+ badge.dataset.renderable_path = data_path
241
+ badge.dataset.renderable_type = info.type
242
+ badge.style.background = TYPE_COLORS[info.type]
243
+ badge.draggable = true
244
+ insert_after.after(badge)
245
+ }
246
+ }
247
+
248
+ // Delegated click handler for badges (avoids per-badge listeners that leak on re-render)
249
+ // Uses capture phase to intercept before tree node fold/select handlers
250
+ $effect(() => {
251
+ if (!sidebar_element) return
252
+ function on_badge_click(event: MouseEvent): void {
253
+ const origin = event.target
254
+ if (!(origin instanceof HTMLElement)) return
255
+ const badge = origin.closest(`.renderable-badge`) as HTMLElement | null
256
+ if (!badge) return
257
+ event.stopPropagation()
258
+ event.preventDefault()
259
+ const raw_path = badge.dataset.renderable_path ?? ``
260
+ const data_path = strip_type_suffix(raw_path)
261
+ const detected_type = badge.dataset.renderable_type
262
+ if (!detected_type || !(detected_type in TYPE_LABELS)) return
263
+ const val = resolve_path(value, data_path)
264
+ if (val !== undefined)
265
+ replace_or_add_panel(data_path, detected_type as RenderableType, val)
266
+ }
267
+ sidebar_element.addEventListener(`click`, on_badge_click, true)
268
+ return () => sidebar_element?.removeEventListener(`click`, on_badge_click, true)
269
+ })
270
+
271
+ // Escape key closes all panels, returning to the overview
272
+ $effect(() => {
273
+ if (panels.length === 0) return
274
+ function on_keydown(event: KeyboardEvent): void {
275
+ if (event.key !== `Escape`) return
276
+ const target = event.target
277
+ if (
278
+ target instanceof HTMLElement &&
279
+ [`INPUT`, `SELECT`, `TEXTAREA`].includes(target.tagName)
280
+ )
281
+ return
282
+ close_all_panels()
283
+ }
284
+ globalThis.addEventListener(`keydown`, on_keydown)
285
+ return () => globalThis.removeEventListener(`keydown`, on_keydown)
286
+ })
287
+
288
+ // Re-apply badges + draggable attributes when tree DOM changes.
289
+ // Guard with a flag so our own badge DOM mutations don't re-trigger the observer,
290
+ // and coalesce rapid mutations into a single rAF.
291
+ let applying_badges = false
292
+ $effect(() => {
293
+ if (!sidebar_element) return
294
+ let pending_raf: number | null = null
295
+ function schedule_refresh(): void {
296
+ if (pending_raf) return
297
+ pending_raf = requestAnimationFrame(() => {
298
+ pending_raf = null
299
+ if (applying_badges) return
300
+ applying_badges = true
301
+ try {
302
+ apply_badges()
303
+ mark_draggable_nodes()
304
+ } finally {
305
+ applying_badges = false
306
+ }
307
+ })
308
+ }
309
+ schedule_refresh()
310
+ const observer = new MutationObserver(schedule_refresh)
311
+ observer.observe(sidebar_element, { childList: true, subtree: true })
312
+ return () => {
313
+ observer.disconnect()
314
+ if (pending_raf) cancelAnimationFrame(pending_raf)
315
+ }
316
+ })
317
+
318
+ // === Panel management ===
319
+
320
+ const make_panel_id = (): string => `panel_${crypto.randomUUID()}`
321
+
322
+ // Click replaces the single/first panel; drag adds a split
323
+ function replace_or_add_panel(
324
+ data_path: string,
325
+ detected_type: RenderableType,
326
+ val: unknown,
327
+ ): void {
328
+ if (panels.length === 0) {
329
+ panels = [
330
+ { id: make_panel_id(), data_path, detected_type, val, component: null, element: null },
331
+ ]
332
+ panel_sizes = [1]
333
+ } else {
334
+ unmount_panel(0)
335
+ panels[0] = {
336
+ id: make_panel_id(),
337
+ data_path,
338
+ detected_type,
339
+ val,
340
+ component: null,
341
+ element: null,
342
+ }
343
+ panels = [...panels] // trigger reactivity
344
+ }
345
+ requestAnimationFrame(() => mount_all_panels())
346
+ }
347
+
348
+ function add_panel_with_split(
349
+ data_path: string,
350
+ detected_type: RenderableType,
351
+ val: unknown,
352
+ target_idx: number,
353
+ zone: `top` | `bottom` | `left` | `right`,
354
+ ): void {
355
+ const new_panel: PanelInfo = {
356
+ id: make_panel_id(),
357
+ data_path,
358
+ detected_type,
359
+ val,
360
+ component: null,
361
+ element: null,
362
+ }
363
+ const direction: SplitDirection =
364
+ zone === `top` || zone === `bottom` ? `vertical` : `horizontal`
365
+ const insert_before = zone === `top` || zone === `left`
366
+
367
+ if (panels.length === 0) {
368
+ panels = [new_panel]
369
+ panel_sizes = [1]
370
+ } else {
371
+ const new_panels = [...panels]
372
+ const new_sizes = [...panel_sizes]
373
+ const new_dirs = [...split_directions]
374
+ const insert_idx = insert_before ? target_idx : target_idx + 1
375
+ // Split the target panel's size in half: new panel gets half, target keeps half
376
+ const target_size = new_sizes[target_idx] ?? 1
377
+ new_sizes[target_idx] = target_size / 2
378
+ new_panels.splice(insert_idx, 0, new_panel)
379
+ new_sizes.splice(insert_idx, 0, target_size / 2)
380
+ // Add direction between the two panels
381
+ new_dirs.splice(target_idx, 0, direction)
382
+ panels = new_panels
383
+ panel_sizes = new_sizes
384
+ split_directions = new_dirs
385
+ }
386
+ requestAnimationFrame(() => mount_all_panels())
387
+ }
388
+
389
+ function close_panel(idx: number): void {
390
+ unmount_panel(idx)
391
+ const new_panels = [...panels]
392
+ const new_sizes = [...panel_sizes]
393
+ const new_dirs = [...split_directions]
394
+ // Give the closed panel's size to its neighbor
395
+ const closed_size = new_sizes[idx] ?? 0
396
+ const neighbor_idx = idx > 0 ? idx - 1 : idx + 1
397
+ if (neighbor_idx < new_sizes.length) new_sizes[neighbor_idx] += closed_size
398
+ new_panels.splice(idx, 1)
399
+ new_sizes.splice(idx, 1)
400
+ // Remove the adjacent split direction
401
+ if (new_dirs.length > 0) {
402
+ const dir_idx = Math.min(idx, new_dirs.length - 1)
403
+ new_dirs.splice(dir_idx, 1)
404
+ }
405
+ panels = new_panels
406
+ panel_sizes = new_sizes
407
+ split_directions = new_dirs
408
+ }
409
+
410
+ function close_all_panels(): void {
411
+ for (let idx = panels.length - 1; idx >= 0; idx--) unmount_panel(idx)
412
+ panels = []
413
+ panel_sizes = []
414
+ split_directions = []
415
+ }
416
+
417
+ function unmount_panel(idx: number): void {
418
+ const panel = panels[idx]
419
+ if (panel?.component) {
420
+ try {
421
+ unmount(panel.component)
422
+ } catch (error) {
423
+ console.error(`JsonBrowser: unmount failed:`, error)
424
+ }
425
+ panel.component = null
426
+ }
427
+ if (panel?.element) panel.element.innerHTML = ``
428
+ }
429
+
430
+ // === Component mounting ===
431
+
432
+ function prepare_structure(val: unknown): unknown {
433
+ if (is_optimade_raw(val)) {
434
+ const result = parse_optimade_from_raw(val)
435
+ if (result) return result
436
+ }
437
+ const record = val as Record<string, unknown>
438
+ if (Array.isArray(record.sites) && record.lattice) {
439
+ return normalize_fractional_coords(
440
+ val as Parameters<typeof normalize_fractional_coords>[0],
441
+ )
442
+ }
443
+ return val
444
+ }
445
+
446
+ // Merge defaults once (reused across all panel mounts)
447
+ const merged_defaults = $derived(merge(defaults))
448
+ const common_props = { fullscreen_toggle: false, style: `height:100%` }
449
+
450
+ function mount_into(
451
+ target: HTMLElement,
452
+ val: unknown,
453
+ detected_type: RenderableType,
454
+ panel_id?: string,
455
+ ): ReturnType<typeof mount> | null {
456
+ const onclose =
457
+ panel_id !== undefined
458
+ ? () => {
459
+ const idx = panels.findIndex((panel) => panel.id === panel_id)
460
+ if (idx === -1) return
461
+ if (panels.length > 1) close_panel(idx)
462
+ else close_all_panels()
463
+ }
464
+ : undefined
465
+ target.innerHTML = ``
466
+ // Force layout so Three.js gets real dimensions
467
+ void target.offsetHeight
468
+
469
+ const structure_mount_props = {
470
+ allow_file_drop: false,
471
+ enable_tips: false,
472
+ ...structure_props(merged_defaults),
473
+ ...common_props,
474
+ }
475
+
476
+ try {
477
+ if (detected_type === `structure`) {
478
+ return mount(Structure, {
479
+ target,
480
+ props: {
481
+ structure: prepare_structure(val) as AnyStructure,
482
+ ...structure_mount_props,
483
+ },
484
+ })
485
+ } else if (detected_type === `fermi_surface` || detected_type === `band_grid`) {
486
+ const props: Record<string, unknown> = { allow_file_drop: false, ...common_props }
487
+ if (is_fermi_surface_data(val as Parameters<typeof is_fermi_surface_data>[0])) {
488
+ props.fermi_data = val
489
+ } else props.band_data = val
490
+ return mount(FermiSurface, { target, props })
491
+ } else if (detected_type === `convex_hull`) {
492
+ return mount(ConvexHull, {
493
+ target,
494
+ props: { entries: val as PhaseData[], ...common_props },
495
+ })
496
+ } else if (detected_type === `phase_diagram`) {
497
+ return mount(IsobaricBinaryPhaseDiagram, {
498
+ target,
499
+ props: { data: val as PhaseDiagramData, ...common_props },
500
+ })
501
+ } else if (detected_type === `volumetric`) {
502
+ const vol_data = val as { lattice: LatticeType }
503
+ return mount(Structure, {
504
+ target,
505
+ props: {
506
+ structure: { sites: [], lattice: vol_data.lattice } as AnyStructure,
507
+ volumetric_data: [val as VolumetricData],
508
+ ...structure_mount_props,
509
+ },
510
+ })
511
+ } else if (detected_type === `bands_and_dos`) {
512
+ const data = val as Record<string, unknown>
513
+ // Support both { band_structure, dos } wrapper and combined-fields format
514
+ const band_data = (data.band_structure ?? val) as BaseBandStructure
515
+ const dos_data = (data.dos ?? val) as DosInput
516
+ return mount(BandsAndDos, {
517
+ target,
518
+ props: { band_structs: band_data, doses: dos_data, ...common_props },
519
+ })
520
+ } else if (detected_type === `band_structure`) {
521
+ return mount(Bands, {
522
+ target,
523
+ props: {
524
+ band_structs: val as BaseBandStructure,
525
+ ...common_props,
526
+ padding: { b: 60 },
527
+ },
528
+ })
529
+ } else if (detected_type === `dos`) {
530
+ return mount(Dos, {
531
+ target,
532
+ props: { doses: val as DosInput, ...common_props, padding: { b: 60 } },
533
+ })
534
+ } else if (detected_type === `brillouin_zone`) {
535
+ const bz_val = val as Record<string, unknown>
536
+ const bz_props: Record<string, unknown> = { allow_file_drop: false, ...common_props }
537
+ if (bz_val.structure) bz_props.structure = prepare_structure(bz_val.structure)
538
+ // Pass pre-computed BZ data (vertices, faces, edges) if present
539
+ if (bz_val.vertices && bz_val.faces) bz_props.bz_data = bz_val
540
+ return mount(BrillouinZone, { target, props: bz_props })
541
+ } else if (detected_type === `xrd`) {
542
+ return mount(XrdPlot, {
543
+ target,
544
+ props: { patterns: val as XrdPattern, allow_file_drop: false, ...common_props },
545
+ })
546
+ } else if (detected_type === `table`) {
547
+ return mount(PlotPanel, {
548
+ target,
549
+ props: { data: val, initial_type: `table`, onclose, ...common_props },
550
+ })
551
+ } else if (detected_type === `plot`) {
552
+ return mount(PlotPanel, { target, props: { data: val, onclose, ...common_props } })
553
+ }
554
+ } catch (error) {
555
+ console.error(`JsonBrowser: mount failed for ${detected_type}:`, error)
556
+ }
557
+ return null
558
+ }
559
+
560
+ function mount_all_panels(): void {
561
+ for (const panel of panels) {
562
+ if (panel.component) continue // already mounted
563
+ const el = document.querySelector<HTMLElement>(`#${panel.id}`)
564
+ if (!el) continue
565
+ panel.element = el
566
+ panel.component = mount_into(el, panel.val, panel.detected_type, panel.id)
567
+ }
568
+ }
569
+
570
+ // === Drop zone detection ===
571
+
572
+ function get_drop_zone(
573
+ event: DragEvent,
574
+ rect: DOMRect,
575
+ ): `top` | `bottom` | `left` | `right` | `center` {
576
+ const rel_x = (event.clientX - rect.left) / rect.width
577
+ const rel_y = (event.clientY - rect.top) / rect.height
578
+ const edge_threshold = 0.25
579
+ if (rel_y < edge_threshold) return `top`
580
+ if (rel_y > 1 - edge_threshold) return `bottom`
581
+ if (rel_x < edge_threshold) return `left`
582
+ if (rel_x > 1 - edge_threshold) return `right`
583
+ return `center`
584
+ }
585
+
586
+ function on_canvas_dragover(event: DragEvent): void {
587
+ event.preventDefault()
588
+ if (event.dataTransfer) event.dataTransfer.dropEffect = `copy`
589
+ if (!canvas_element) return
590
+ // Default to center/first panel; override if cursor is inside a specific panel
591
+ drop_zone = `center`
592
+ drop_target_panel_idx = 0
593
+ const panel_els = canvas_element.querySelectorAll(`.viz-panel`)
594
+ for (let idx = 0; idx < panel_els.length; idx++) {
595
+ const rect = panel_els[idx].getBoundingClientRect()
596
+ if (
597
+ event.clientX >= rect.left &&
598
+ event.clientX <= rect.right &&
599
+ event.clientY >= rect.top &&
600
+ event.clientY <= rect.bottom
601
+ ) {
602
+ drop_zone = get_drop_zone(event, rect)
603
+ drop_target_panel_idx = idx
604
+ break
605
+ }
606
+ }
607
+ // Prevent mixed-axis splits until nested layouts are supported
608
+ if (split_directions.length > 0) {
609
+ const layout = split_directions[0]
610
+ if (layout === `vertical` && (drop_zone === `left` || drop_zone === `right`)) {
611
+ drop_zone = `center`
612
+ } else if (layout === `horizontal` && (drop_zone === `top` || drop_zone === `bottom`)) {
613
+ drop_zone = `center`
614
+ }
615
+ }
616
+ }
617
+
618
+ function on_canvas_dragleave(event: DragEvent): void {
619
+ // Only clear if leaving the canvas entirely
620
+ if (canvas_element && !canvas_element.contains(event.relatedTarget as Node)) {
621
+ drop_zone = null
622
+ drop_target_panel_idx = -1
623
+ }
624
+ }
625
+
626
+ function on_canvas_drop(event: DragEvent): void {
627
+ event.preventDefault()
628
+ const raw = event.dataTransfer?.getData(`text/plain`)
629
+ // Capture the already-computed drop state before clearing it
630
+ const zone = drop_zone
631
+ const target_idx = Math.max(0, drop_target_panel_idx)
632
+ drop_zone = null
633
+ drop_target_panel_idx = -1
634
+ if (!raw) return
635
+ try {
636
+ const parsed = JSON.parse(raw) as { data_path: string; detected_type: RenderableType }
637
+ const data_path = strip_type_suffix(parsed.data_path)
638
+ const { detected_type } = parsed
639
+ if (!detected_type || !(detected_type in TYPE_LABELS)) return
640
+ const val = resolve_path(value, data_path)
641
+ if (val === undefined) return
642
+ if (panels.length === 0 || zone === `center` || !zone) {
643
+ replace_or_add_panel(data_path, detected_type, val)
644
+ } else {
645
+ add_panel_with_split(data_path, detected_type, val, target_idx, zone)
646
+ }
647
+ } catch (error) {
648
+ console.error(`JsonBrowser: drop failed:`, error)
649
+ }
650
+ }
651
+
652
+ // === Panel split divider dragging ===
653
+ let split_dragging_idx = $state(-1)
654
+
655
+ function on_split_divider_mousedown(event: MouseEvent, split_idx: number): void {
656
+ event.preventDefault()
657
+ split_dragging_idx = split_idx
658
+ const direction = split_directions[split_idx]
659
+ if (!direction) return
660
+ const panel_container = canvas_element?.querySelector(
661
+ `.panel-container`,
662
+ ) as HTMLElement | null
663
+ if (!panel_container) return
664
+ const container_rect = panel_container.getBoundingClientRect()
665
+
666
+ const is_vertical = direction === `vertical`
667
+ const start_pos = is_vertical ? event.clientY : event.clientX
668
+ const container_size = is_vertical ? container_rect.height : container_rect.width
669
+ // Total flex of the two adjacent panels
670
+ const left_idx = split_idx
671
+ const right_idx = split_idx + 1
672
+ const total_flex = (panel_sizes[left_idx] ?? 1) + (panel_sizes[right_idx] ?? 1)
673
+ const start_left_size = panel_sizes[left_idx] ?? total_flex / 2
674
+
675
+ start_drag(
676
+ (move_event) => {
677
+ const current_pos = is_vertical ? move_event.clientY : move_event.clientX
678
+ const delta_fraction = (current_pos - start_pos) / container_size
679
+ const delta_flex = delta_fraction * total_flex
680
+ const new_left = Math.max(
681
+ total_flex * 0.1,
682
+ Math.min(total_flex * 0.9, start_left_size + delta_flex),
683
+ )
684
+ panel_sizes[left_idx] = new_left
685
+ panel_sizes[right_idx] = total_flex - new_left
686
+ },
687
+ () => {
688
+ split_dragging_idx = -1
689
+ },
690
+ )
691
+ }
692
+
693
+ // === Helpers ===
694
+
695
+ function strip_root_prefix(path: string): string {
696
+ if (!filename || !path.startsWith(filename)) return path
697
+ const stripped = path.slice(filename.length)
698
+ return stripped.startsWith(`.`) ? stripped.slice(1) : stripped
699
+ }
700
+
701
+ function handle_select(path: string, val: unknown): void {
702
+ const data_path = strip_root_prefix(path)
703
+ if (select_timer) clearTimeout(select_timer)
704
+ select_timer = setTimeout(() => {
705
+ if (destroyed) return // component was destroyed while timer was pending
706
+ const detected = detect_view_type(val)
707
+ if (detected) replace_or_add_panel(data_path, detected, val)
708
+ }, 150)
709
+ }
710
+
711
+ // The first split direction determines the flex layout direction
712
+ let layout_direction = $derived(
713
+ split_directions.length > 0 ? split_directions[0] : `vertical`,
714
+ )
715
+
716
+ const type_color = (key: string) => TYPE_COLORS[key as RenderableType] ?? `#888`
717
+ </script>
718
+
719
+ {#snippet type_list(header: string, extra_style?: string)}
720
+ <div class="type-list" style={extra_style ?? ``}>
721
+ <p class="type-list-header">{header}</p>
722
+ {#each Object.entries(TYPE_LABELS) as [type_key, label] (type_key)}
723
+ <span class="type-tag" style="border-color: {type_color(type_key)}44;">
724
+ <span class="chip-dot" style="background: {type_color(type_key)};"></span>
725
+ {label}
726
+ </span>
727
+ {/each}
728
+ </div>
729
+ {/snippet}
730
+
731
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
732
+ <div class="json-browser" class:dragging={is_sidebar_dragging || split_dragging_idx >= 0}>
733
+ <aside class="sidebar" bind:this={sidebar_element} style="width: {sidebar_width}px">
734
+ <JsonTree
735
+ {value}
736
+ root_label={filename}
737
+ default_fold_level={1}
738
+ onselect={handle_select}
739
+ show_header
740
+ />
741
+ </aside>
742
+
743
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
744
+ <div class="divider sidebar-divider" onmousedown={on_sidebar_divider_mousedown}></div>
745
+
746
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
747
+ <div
748
+ class="canvas"
749
+ bind:this={canvas_element}
750
+ ondragover={on_canvas_dragover}
751
+ ondragleave={on_canvas_dragleave}
752
+ ondrop={on_canvas_drop}
753
+ >
754
+ {#if panels.length === 0}
755
+ <!-- Placeholder when no panels -->
756
+ <div class="placeholder">
757
+ {#if renderable_paths.size === 0}
758
+ <p class="placeholder-title">No renderable data found</p>
759
+ <p class="placeholder-sub">
760
+ This JSON file doesn't contain recognized visualization data.
761
+ </p>
762
+ {@render type_list(`Recognized types:`)}
763
+ {:else}
764
+ <p class="placeholder-title">Click or drag a data node to visualize it</p>
765
+ <p class="placeholder-sub">
766
+ Found {renderable_paths.size} renderable item{renderable_paths.size === 1
767
+ ? ``
768
+ : `s`}. Click to render, or drag to an edge to create a split view.
769
+ </p>
770
+ <div
771
+ style="margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;"
772
+ >
773
+ {#each [...renderable_paths] as [data_path, info] (data_path)}
774
+ <button
775
+ type="button"
776
+ class="renderable-chip"
777
+ style="background: {type_color(info.type)}22; border: 1px solid {type_color(
778
+ info.type,
779
+ )}66;"
780
+ onclick={() => {
781
+ const clean_path = strip_type_suffix(data_path)
782
+ const val = resolve_path(value, clean_path)
783
+ if (val !== undefined) {
784
+ replace_or_add_panel(clean_path, info.type, val)
785
+ }
786
+ }}
787
+ >
788
+ <span class="chip-dot" style="background: {type_color(info.type)};"></span>
789
+ {info.label}: <code>{strip_type_suffix(data_path) || `root`}</code>
790
+ </button>
791
+ {/each}
792
+ </div>
793
+ {@render type_list(`All recognized types:`, `margin-top: 20px`)}
794
+ {/if}
795
+ </div>
796
+ {:else}
797
+ <!-- Panel layout -->
798
+ <div
799
+ class="panel-container"
800
+ class:vertical={layout_direction === `vertical`}
801
+ class:horizontal={layout_direction === `horizontal`}
802
+ >
803
+ {#each panels as panel, idx (panel.id)}
804
+ {#if idx > 0 && split_directions[idx - 1]}
805
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
806
+ <div
807
+ class="split-divider"
808
+ class:vertical={split_directions[idx - 1] === `vertical`}
809
+ class:horizontal={split_directions[idx - 1] === `horizontal`}
810
+ class:active={split_dragging_idx === idx - 1}
811
+ onmousedown={(event) => on_split_divider_mousedown(event, idx - 1)}
812
+ ></div>
813
+ {/if}
814
+ <div class="viz-panel" style="flex: {panel_sizes[idx] ?? 1}">
815
+ <div class="panel-mount" id={panel.id}></div>
816
+ <!-- Panel label -->
817
+ <div class="panel-label" style="background: {TYPE_COLORS[panel.detected_type]}cc;">
818
+ {TYPE_LABELS[panel.detected_type]}: {strip_type_suffix(panel.data_path)}
819
+ </div>
820
+ </div>
821
+ {/each}
822
+ </div>
823
+ {/if}
824
+
825
+ <!-- Drop zone overlay -->
826
+ {#if drop_zone && drop_zone !== `center`}
827
+ <div class="drop-indicator {drop_zone}"></div>
828
+ {/if}
829
+ </div>
830
+ </div>
831
+
832
+ <style>
833
+ .json-browser {
834
+ display: flex;
835
+ width: 100%;
836
+ height: 100%;
837
+ overflow: hidden;
838
+ background: var(--vscode-editor-background, #1e1e1e);
839
+ color: var(--vscode-editor-foreground, #d4d4d4);
840
+ }
841
+ .json-browser.dragging {
842
+ user-select: none;
843
+ }
844
+ .sidebar {
845
+ flex-shrink: 0;
846
+ min-width: 150px;
847
+ max-width: 60%;
848
+ overflow: auto;
849
+ padding: 4px;
850
+ }
851
+ .divider {
852
+ width: 5px;
853
+ flex-shrink: 0;
854
+ background: var(--vscode-panel-border, rgba(255, 255, 255, 0.1));
855
+ cursor: col-resize;
856
+ transition: background 0.15s;
857
+ }
858
+ .divider:hover,
859
+ .json-browser.dragging .sidebar-divider {
860
+ background: var(--vscode-focusBorder, #007fd4);
861
+ }
862
+ .canvas {
863
+ flex: 1;
864
+ height: 100%;
865
+ position: relative;
866
+ overflow: hidden;
867
+ min-width: 200px;
868
+ }
869
+ /* === Panel layout === */
870
+ .panel-container {
871
+ display: flex;
872
+ width: 100%;
873
+ height: 100%;
874
+ }
875
+ .panel-container.vertical {
876
+ flex-direction: column;
877
+ }
878
+ .panel-container.horizontal {
879
+ flex-direction: row;
880
+ }
881
+ .viz-panel {
882
+ position: relative;
883
+ overflow: hidden;
884
+ min-width: 100px;
885
+ min-height: 80px;
886
+ }
887
+ .panel-mount {
888
+ width: 100%;
889
+ height: 100%;
890
+ }
891
+ .panel-label {
892
+ position: absolute;
893
+ bottom: 4px;
894
+ left: 4px;
895
+ z-index: 10;
896
+ font-size: 10px;
897
+ padding: 2px 6px;
898
+ border-radius: 3px;
899
+ color: white;
900
+ opacity: 0.7;
901
+ pointer-events: none;
902
+ white-space: nowrap;
903
+ max-width: calc(100% - 16px);
904
+ overflow: hidden;
905
+ text-overflow: ellipsis;
906
+ }
907
+ /* === Split dividers === */
908
+ .split-divider {
909
+ flex-shrink: 0;
910
+ background: var(--vscode-panel-border, rgba(255, 255, 255, 0.15));
911
+ transition: background 0.15s;
912
+ z-index: 5;
913
+ }
914
+ .split-divider.vertical {
915
+ height: 5px;
916
+ cursor: row-resize;
917
+ }
918
+ .split-divider.horizontal {
919
+ width: 5px;
920
+ cursor: col-resize;
921
+ }
922
+ .split-divider:hover,
923
+ .split-divider.active {
924
+ background: var(--vscode-focusBorder, #007fd4);
925
+ }
926
+ /* === Drop zone indicators === */
927
+ .drop-indicator {
928
+ position: absolute;
929
+ background: rgba(0, 127, 212, 0.25);
930
+ border: 2px solid var(--vscode-focusBorder, #007fd4);
931
+ z-index: 20;
932
+ pointer-events: none;
933
+ transition: all 0.1s;
934
+ }
935
+ .drop-indicator.top {
936
+ top: 0;
937
+ left: 0;
938
+ right: 0;
939
+ height: 40%;
940
+ }
941
+ .drop-indicator.bottom {
942
+ bottom: 0;
943
+ left: 0;
944
+ right: 0;
945
+ height: 40%;
946
+ }
947
+ .drop-indicator.left {
948
+ top: 0;
949
+ left: 0;
950
+ bottom: 0;
951
+ width: 40%;
952
+ }
953
+ .drop-indicator.right {
954
+ top: 0;
955
+ right: 0;
956
+ bottom: 0;
957
+ width: 40%;
958
+ }
959
+ /* === Placeholder === */
960
+ .placeholder {
961
+ display: flex;
962
+ flex-direction: column;
963
+ align-items: center;
964
+ justify-content: center;
965
+ height: 100%;
966
+ opacity: 0.8;
967
+ padding: 2rem;
968
+ text-align: center;
969
+ }
970
+ .placeholder-title {
971
+ font-size: 16px;
972
+ margin-bottom: 8px;
973
+ font-weight: 500;
974
+ }
975
+ .placeholder-sub {
976
+ font-size: 13px;
977
+ opacity: 0.65;
978
+ max-width: 400px;
979
+ line-height: 1.5;
980
+ }
981
+ .type-list {
982
+ margin-top: 16px;
983
+ display: flex;
984
+ flex-wrap: wrap;
985
+ gap: 6px;
986
+ justify-content: center;
987
+ align-items: center;
988
+ }
989
+ .type-list-header {
990
+ width: 100%;
991
+ font-size: 11px;
992
+ text-transform: uppercase;
993
+ letter-spacing: 0.5px;
994
+ opacity: 0.5;
995
+ margin-bottom: 4px;
996
+ }
997
+ .type-tag {
998
+ display: inline-flex;
999
+ align-items: center;
1000
+ gap: 5px;
1001
+ padding: 3px 8px;
1002
+ border-radius: 10px;
1003
+ font-size: 11px;
1004
+ border: 1px solid;
1005
+ opacity: 0.7;
1006
+ }
1007
+ .renderable-chip {
1008
+ display: inline-flex;
1009
+ align-items: center;
1010
+ gap: 6px;
1011
+ padding: 4px 10px;
1012
+ border-radius: 12px;
1013
+ font-size: 12px;
1014
+ cursor: pointer;
1015
+ color: inherit;
1016
+ transition: opacity 0.15s;
1017
+ }
1018
+ .renderable-chip:hover {
1019
+ opacity: 0.8;
1020
+ }
1021
+ .renderable-chip code {
1022
+ font-size: 11px;
1023
+ opacity: 0.8;
1024
+ }
1025
+ .chip-dot {
1026
+ width: 8px;
1027
+ height: 8px;
1028
+ border-radius: 50%;
1029
+ flex-shrink: 0;
1030
+ }
1031
+ /* Badges are injected imperatively — use :global so Svelte doesn't strip the rule */
1032
+ :global(.renderable-badge) {
1033
+ font-size: 9px;
1034
+ padding: 1px 4px;
1035
+ margin-left: 4px;
1036
+ border-radius: 3px;
1037
+ color: white;
1038
+ font-weight: 500;
1039
+ white-space: nowrap;
1040
+ cursor: grab;
1041
+ flex-shrink: 0;
1042
+ }
1043
+ </style>