matterviz 0.3.4 → 0.3.5

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 (125) hide show
  1. package/dist/FilePicker.svelte +1 -1
  2. package/dist/app.css +7 -0
  3. package/dist/brillouin/BrillouinZone.svelte +5 -2
  4. package/dist/brillouin/compute.js +8 -4
  5. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +6 -6
  6. package/dist/chempot-diagram/async-compute.svelte.js +5 -4
  7. package/dist/chempot-diagram/chempot-worker.js +2 -2
  8. package/dist/chempot-diagram/compute.js +16 -16
  9. package/dist/composition/FormulaFilter.svelte +3 -3
  10. package/dist/constants.js +2 -8
  11. package/dist/convex-hull/ConvexHull.svelte +2 -2
  12. package/dist/convex-hull/ConvexHull2D.svelte +11 -10
  13. package/dist/convex-hull/ConvexHull3D.svelte +16 -14
  14. package/dist/convex-hull/ConvexHull4D.svelte +26 -14
  15. package/dist/convex-hull/ConvexHullControls.svelte +1 -1
  16. package/dist/convex-hull/ConvexHullInfoPane.svelte +68 -61
  17. package/dist/convex-hull/ConvexHullStats.svelte +23 -6
  18. package/dist/convex-hull/GasPressureControls.svelte +3 -3
  19. package/dist/convex-hull/TemperatureSlider.svelte +1 -1
  20. package/dist/convex-hull/barycentric-coords.js +2 -2
  21. package/dist/convex-hull/helpers.js +45 -27
  22. package/dist/convex-hull/thermodynamics.js +2 -2
  23. package/dist/element/BohrAtom.svelte +25 -27
  24. package/dist/element/BohrAtom.svelte.d.ts +2 -2
  25. package/dist/element/data.d.ts +2 -3
  26. package/dist/fermi-surface/FermiSurface.svelte +5 -2
  27. package/dist/fermi-surface/compute.js +3 -3
  28. package/dist/fermi-surface/parse.js +2 -2
  29. package/dist/fermi-surface/symmetry.js +1 -1
  30. package/dist/heatmap-matrix/HeatmapMatrix.svelte +8 -8
  31. package/dist/icons.d.ts +6 -6
  32. package/dist/icons.js +6 -6
  33. package/dist/io/decompress.js +12 -7
  34. package/dist/io/export.js +20 -16
  35. package/dist/io/is-binary.js +19 -4
  36. package/dist/isosurface/parse.js +8 -8
  37. package/dist/isosurface/types.js +9 -9
  38. package/dist/layout/InfoTag.svelte +1 -1
  39. package/dist/layout/json-tree/JsonNode.svelte +1 -0
  40. package/dist/layout/json-tree/utils.js +2 -1
  41. package/dist/marching-cubes.js +1 -1
  42. package/dist/math.js +1 -1
  43. package/dist/overlays/CopyButton.svelte +45 -0
  44. package/dist/overlays/CopyButton.svelte.d.ts +8 -0
  45. package/dist/overlays/InfoPaneCards.svelte +149 -0
  46. package/dist/overlays/InfoPaneCards.svelte.d.ts +22 -0
  47. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +33 -35
  48. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +2 -2
  49. package/dist/phase-diagram/PhaseDiagramControls.svelte +27 -29
  50. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +2 -2
  51. package/dist/phase-diagram/parse.js +3 -3
  52. package/dist/phase-diagram/svg-to-diagram.js +10 -12
  53. package/dist/plot/BarPlot.svelte +24 -15
  54. package/dist/plot/BarPlot.svelte.d.ts +3 -2
  55. package/dist/plot/FillArea.svelte +2 -3
  56. package/dist/plot/FillArea.svelte.d.ts +3 -2
  57. package/dist/plot/Histogram.svelte +37 -19
  58. package/dist/plot/Line.svelte +2 -3
  59. package/dist/plot/Line.svelte.d.ts +2 -2
  60. package/dist/plot/PlotLegend.svelte +79 -8
  61. package/dist/plot/PlotLegend.svelte.d.ts +4 -0
  62. package/dist/plot/PortalSelect.svelte +5 -5
  63. package/dist/plot/ScatterPlot.svelte +47 -33
  64. package/dist/plot/ScatterPlot.svelte.d.ts +5 -4
  65. package/dist/plot/ScatterPlot3D.svelte +6 -3
  66. package/dist/plot/ScatterPoint.svelte +10 -4
  67. package/dist/plot/ScatterPoint.svelte.d.ts +4 -2
  68. package/dist/plot/SpacegroupBarPlot.svelte +5 -4
  69. package/dist/plot/data-cleaning.js +9 -9
  70. package/dist/plot/index.d.ts +0 -6
  71. package/dist/plot/scales.d.ts +3 -3
  72. package/dist/plot/scales.js +29 -29
  73. package/dist/plot/types.d.ts +5 -9
  74. package/dist/rdf/calc-rdf.js +1 -1
  75. package/dist/sanitize.js +22 -15
  76. package/dist/settings.d.ts +2 -0
  77. package/dist/settings.js +12 -3
  78. package/dist/spectral/Bands.svelte +6 -6
  79. package/dist/spectral/BandsAndDos.svelte +4 -4
  80. package/dist/spectral/BrillouinBandsDos.svelte +3 -3
  81. package/dist/spectral/Dos.svelte +2 -2
  82. package/dist/spectral/helpers.js +1 -1
  83. package/dist/structure/AtomLegend.svelte +4 -4
  84. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  85. package/dist/structure/Cylinder.svelte +7 -7
  86. package/dist/structure/Structure.svelte +169 -27
  87. package/dist/structure/Structure.svelte.d.ts +6 -2
  88. package/dist/structure/StructureControls.svelte +130 -16
  89. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  90. package/dist/structure/StructureInfoPane.svelte +519 -218
  91. package/dist/structure/StructureInfoPane.svelte.d.ts +2 -1
  92. package/dist/structure/StructureScene.svelte +399 -68
  93. package/dist/structure/StructureScene.svelte.d.ts +8 -4
  94. package/dist/structure/atom-properties.js +3 -1
  95. package/dist/structure/bond-order-perception.d.ts +13 -0
  96. package/dist/structure/bond-order-perception.js +367 -0
  97. package/dist/structure/bonding.d.ts +10 -1
  98. package/dist/structure/bonding.js +232 -11
  99. package/dist/structure/export.js +6 -4
  100. package/dist/structure/index.d.ts +19 -4
  101. package/dist/structure/index.js +3 -0
  102. package/dist/structure/label-placement.d.ts +14 -0
  103. package/dist/structure/label-placement.js +72 -0
  104. package/dist/structure/parse.d.ts +2 -1
  105. package/dist/structure/parse.js +25 -36
  106. package/dist/structure/supercell.js +35 -2
  107. package/dist/symmetry/SymmetryStats.svelte +1 -1
  108. package/dist/symmetry/cell-transform.js +15 -1
  109. package/dist/symmetry/index.js +3 -3
  110. package/dist/table/HeatmapTable.svelte +3 -3
  111. package/dist/table/ToggleMenu.svelte +1 -1
  112. package/dist/trajectory/Trajectory.svelte +2 -2
  113. package/dist/trajectory/TrajectoryInfoPane.svelte +14 -88
  114. package/dist/trajectory/extract.js +4 -4
  115. package/dist/trajectory/frame-reader.js +2 -2
  116. package/dist/trajectory/parse/ase.js +2 -6
  117. package/dist/trajectory/parse/hdf5.js +1 -3
  118. package/dist/trajectory/plotting.js +1 -1
  119. package/dist/utils.js +1 -1
  120. package/dist/xrd/calc-xrd.js +1 -1
  121. package/package.json +22 -37
  122. package/dist/structure/ferrox-wasm-types.d.ts +0 -46
  123. package/dist/structure/ferrox-wasm-types.js +0 -18
  124. package/dist/structure/ferrox-wasm.d.ts +0 -94
  125. package/dist/structure/ferrox-wasm.js +0 -249
@@ -1,19 +1,66 @@
1
1
  <script lang="ts">
2
2
  import { get_electro_neg_formula } from '../composition'
3
- import { element_data } from '../element'
3
+ import { element_data, type ElementSymbol } from '../element'
4
4
  import Icon from '../Icon.svelte'
5
5
  import { format_num } from '../labels'
6
6
  import type { InfoItem } from '../layout'
7
+ import CopyButton from '../overlays/CopyButton.svelte'
7
8
  import DraggablePane from '../overlays/DraggablePane.svelte'
9
+ import { sanitize_html } from '../sanitize'
10
+ import { colors } from '../state.svelte'
8
11
  import type { AnyStructure, Site } from './'
9
12
  import { get_density } from './'
10
13
  import { wyckoff_positions_from_moyo, WyckoffTable } from '../symmetry'
11
14
  import type { MoyoDataset } from '@spglib/moyo-wasm'
12
15
  import type { ComponentProps } from 'svelte'
13
16
  import type { HTMLAttributes } from 'svelte/elements'
14
- import { sanitize_html } from '../sanitize'
15
17
  import { SvelteSet } from 'svelte/reactivity'
16
18
 
19
+ type SiteDetail = {
20
+ label: string
21
+ value: string
22
+ key: string
23
+ tooltip?: string
24
+ }
25
+ type SiteCard = {
26
+ idx: number
27
+ element: string
28
+ element_name: string
29
+ title: string
30
+ details: SiteDetail[]
31
+ search_text: string
32
+ }
33
+
34
+ const SITE_WINDOW_SIZE = 100
35
+ const USAGE_TIP_ITEMS: InfoItem[] = [
36
+ {
37
+ label: `File Drop`,
38
+ value: `Drop POSCAR, XYZ, CIF or JSON files to load structures`,
39
+ },
40
+ {
41
+ label: `Atom Selection`,
42
+ value:
43
+ `Click atoms to select them, then pick distance or angle mode to measure all pairwise distances/angles`,
44
+ },
45
+ {
46
+ label: `Navigation`,
47
+ value: `Hold Shift/Cmd/Ctrl + drag to pan the scene`,
48
+ },
49
+ {
50
+ label: `Camera Reset`,
51
+ value: `Double-click anywhere to reset camera to default view`,
52
+ },
53
+ {
54
+ label: `Colors`,
55
+ value:
56
+ `Click legend labels to change colors, double-click to reset, right-click to remap elements`,
57
+ },
58
+ {
59
+ label: `Keyboard`,
60
+ value: `Press 'f' for fullscreen, 'i' to toggle this pane`,
61
+ },
62
+ ]
63
+
17
64
  let {
18
65
  structure,
19
66
  pane_open = $bindable(false),
@@ -21,6 +68,7 @@
21
68
  toggle_props = {},
22
69
  pane_props = {},
23
70
  highlighted_sites = $bindable([]),
71
+ hovered_site_idx = $bindable(null),
24
72
  selected_sites = $bindable([]),
25
73
  sym_data = null,
26
74
  ...rest
@@ -31,12 +79,16 @@
31
79
  toggle_props?: ComponentProps<typeof DraggablePane>[`toggle_props`]
32
80
  pane_props?: ComponentProps<typeof DraggablePane>[`pane_props`]
33
81
  highlighted_sites?: number[] // Sites highlighted from Wyckoff table hover
82
+ hovered_site_idx?: number | null // Site hovered in this pane or in the 3D scene
34
83
  selected_sites?: number[] // Sites selected from Wyckoff table click
35
84
  sym_data?: MoyoDataset | null // Symmetry analysis data (bindable for external access)
36
85
  } = $props()
37
86
 
38
87
  let copied_items = new SvelteSet<string>()
39
88
  let sites_expanded = $state(false)
89
+ let site_filter = $state(``)
90
+ let site_window_start = $state(0)
91
+ let site_cards_el = $state<HTMLDivElement>()
40
92
 
41
93
  async function copy_to_clipboard(label: string, value: string, key: string) {
42
94
  try {
@@ -48,24 +100,122 @@
48
100
  }
49
101
  }
50
102
 
51
- function handle_click(item: InfoItem, section_title: string) {
52
- if (section_title === `Usage Tips`) return
53
- if (item.key === `sites-toggle`) sites_expanded = !sites_expanded
54
- else copy_to_clipboard(item.label, String(item.value), item.key ?? item.label)
103
+ function copy_event(
104
+ event: MouseEvent,
105
+ label: string,
106
+ value: string,
107
+ key: string,
108
+ ) {
109
+ event.stopPropagation()
110
+ copy_to_clipboard(label, value, key)
111
+ }
112
+
113
+ function copy_info_item(item: InfoItem) {
114
+ copy_to_clipboard(item.label, String(item.value), item.key ?? item.label)
115
+ }
116
+
117
+ function set_site_hover(site_idx: number | null) {
118
+ highlighted_sites = site_idx === null ? [] : [site_idx]
119
+ hovered_site_idx = site_idx
120
+ }
121
+
122
+ function select_site(site_idx: number, event?: MouseEvent | KeyboardEvent) {
123
+ if (event?.shiftKey) {
124
+ selected_sites = selected_sites.includes(site_idx)
125
+ ? selected_sites.filter((idx) => idx !== site_idx)
126
+ : [...selected_sites, site_idx]
127
+ return
128
+ }
129
+ selected_sites = selected_sites.length === 1 && selected_sites[0] === site_idx
130
+ ? []
131
+ : [site_idx]
132
+ }
133
+
134
+ function update_site_filter(event: Event): void {
135
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
136
+ site_filter = event.currentTarget.value
137
+ site_window_start = 0
138
+ }
139
+
140
+ function handle_site_keydown(event: KeyboardEvent, card: SiteCard) {
141
+ const plain_key = !event.altKey && !event.ctrlKey && !event.metaKey
142
+ if ([`Enter`, ` `].includes(event.key)) {
143
+ event.preventDefault()
144
+ select_site(card.idx, event)
145
+ return
146
+ }
147
+ if (event.key === `c` && plain_key) {
148
+ event.preventDefault()
149
+ copy_to_clipboard(card.title, site_summary(card), `site-${card.idx}-summary`)
150
+ return
151
+ }
152
+ if (![`ArrowDown`, `ArrowUp`].includes(event.key)) return
153
+ event.preventDefault()
154
+ const current_card = event.currentTarget as HTMLDivElement | null
155
+ const sibling_cards = Array.from(
156
+ current_card?.parentElement?.querySelectorAll<HTMLDivElement>(`.site-card`) ?? [],
157
+ )
158
+ const current_idx = sibling_cards.indexOf(current_card as HTMLDivElement)
159
+ const next_idx = event.key === `ArrowDown`
160
+ ? Math.min(current_idx + 1, sibling_cards.length - 1)
161
+ : Math.max(current_idx - 1, 0)
162
+ sibling_cards[next_idx]?.focus()
163
+ }
164
+
165
+ function get_element_name(element: string): string {
166
+ return element_data?.find((el) => el.symbol === element)?.name || element
167
+ }
168
+
169
+ function site_summary(card: SiteCard): string {
170
+ return [
171
+ card.element_name,
172
+ ...card.details.map(({ label, value }) => `${label}: ${value}`),
173
+ ].join(`; `)
174
+ }
175
+
176
+ function format_site_property(prop_key: string, prop_value: unknown): SiteDetail | null {
177
+ if (prop_value == null) return null
178
+ const format_numeric_value = (value: unknown, format = `.3~f`): string | null => {
179
+ const numeric_value = Number(value)
180
+ return Number.isNaN(numeric_value) ? null : format_num(numeric_value, format)
181
+ }
182
+ const format_value_list = (values: unknown[]): string =>
183
+ `(${values.map((value) => format_numeric_value(value) ?? String(value)).join(`, `)})`
184
+ let tooltip: string | undefined
185
+
186
+ if (
187
+ prop_key === `force` && Array.isArray(prop_value) &&
188
+ prop_value.length === 3 && prop_value.every((value) => typeof value === `number`)
189
+ ) {
190
+ const force_values = prop_value as [number, number, number]
191
+ const value = `${format_num(Math.hypot(...force_values), `.3~f`)} eV/Å`
192
+ tooltip = `Force vector: ${
193
+ force_values.map((force) => format_num(force, `.3~f`)).join(`, `)
194
+ } eV/Å`
195
+ return { label: prop_key, value, key: prop_key, tooltip }
196
+ }
197
+ if (prop_key === `magmom` || prop_key.includes(`magnet`)) {
198
+ const formatted_value = format_numeric_value(prop_value)
199
+ if (!formatted_value) return null
200
+ tooltip = `Magnetic moment in Bohr magnetons`
201
+ return { label: prop_key, value: `${formatted_value} μB`, key: prop_key, tooltip }
202
+ }
203
+
204
+ const value = Array.isArray(prop_value)
205
+ ? format_value_list(prop_value)
206
+ : format_numeric_value(prop_value) ?? String(prop_value)
207
+ return { label: prop_key, value, key: prop_key }
55
208
  }
56
209
 
57
210
  let pane_data = $derived.by(() => {
58
211
  if (!structure) return []
59
212
  const sections: { title: string; items: InfoItem[] }[] = []
60
- const [min_threshold, max_threshold] = atom_count_thresholds
61
213
 
62
214
  // Structure Info
63
215
  const structure_items: InfoItem[] = [
64
216
  {
65
217
  label: `Formula`,
66
- value: `${
67
- get_electro_neg_formula(structure)
68
- } (${structure.sites.length} sites)`,
218
+ value: `${get_electro_neg_formula(structure)} (${structure.sites.length} sites)`,
69
219
  key: `structure-formula`,
70
220
  },
71
221
  {
@@ -80,10 +230,7 @@
80
230
  // Only display scalar values (skip arrays and objects)
81
231
  if (value == null || typeof value === `object`) continue
82
232
  structure_items.push({
83
- label: key.replace(/_/g, ` `).replace(
84
- /\b\w/g,
85
- (char) => char.toUpperCase(),
86
- ),
233
+ label: key.replace(/_/g, ` `).replace(/\b\w/g, (char) => char.toUpperCase()),
87
234
  value: String(value),
88
235
  key: `structure-prop-${key}`,
89
236
  })
@@ -99,23 +246,17 @@
99
246
  items: [
100
247
  {
101
248
  label: `Volume, Density`,
102
- value: `${format_num(volume, `.3~s`)} ų, ${
103
- format_num(get_density(structure), `.3~f`)
104
- } g/cm³`,
249
+ value: `${format_num(volume, `.3~s`)} ų, ${format_num(get_density(structure), `.3~f`)} g/cm³`,
105
250
  key: `cell-volume-density`,
106
251
  },
107
252
  {
108
253
  label: `a, b, c`,
109
- value: `${format_num(a, `.4~f`)}, ${format_num(b, `.4~f`)}, ${
110
- format_num(c, `.4~f`)
111
- } Å`,
254
+ value: `${format_num(a, `.4~f`)}, ${format_num(b, `.4~f`)}, ${format_num(c, `.4~f`)} Å`,
112
255
  key: `cell-abc`,
113
256
  },
114
257
  {
115
258
  label: `α, β, γ`,
116
- value: `${format_num(alpha, `.2~f`)}°, ${format_num(beta, `.2~f`)}°, ${
117
- format_num(gamma, `.2~f`)
118
- }°`,
259
+ value: `${format_num(alpha, `.2~f`)}°, ${format_num(beta, `.2~f`)}°, ${format_num(gamma, `.2~f`)}°`,
119
260
  key: `cell-angles`,
120
261
  },
121
262
  ],
@@ -149,169 +290,104 @@
149
290
  sections.push({
150
291
  title: `Symmetry`,
151
292
  items: [
152
- {
153
- label: `Space Group`,
154
- value: space_group_value,
155
- key: `symmetry-space-group`,
156
- },
157
- {
158
- label: `Hall Number`,
159
- value: String(sym_data.hall_number),
160
- key: `symmetry-hall-number`,
161
- },
162
- {
163
- label: `Pearson Symbol`,
164
- value: sym_data.pearson_symbol,
165
- key: `symmetry-pearson-symbol`,
166
- },
293
+ { label: `Space Group`, value: space_group_value, key: `symmetry-space-group` },
294
+ { label: `Hall Number`, value: String(sym_data.hall_number), key: `symmetry-hall-number` },
295
+ { label: `Pearson Symbol`, value: sym_data.pearson_symbol, key: `symmetry-pearson-symbol` },
167
296
  {
168
297
  label: `Symmetry Ops`,
169
- value:
170
- `${operations.length} (${translations} trans, ${rotations} rot, ${roto_translations} roto-trans)`,
298
+ value: `${operations.length} (${translations} trans, ${rotations} rot, ${roto_translations} roto-trans)`,
171
299
  key: `symmetry-operations-total`,
172
300
  },
173
301
  ],
174
302
  })
175
303
  }
176
304
 
177
- // Sites Section
178
- const atom_count = structure.sites.length
179
- if (atom_count <= max_threshold) {
180
- const site_items: InfoItem[] = []
181
-
182
- // Merged toggle button with Sites title
183
- if (atom_count >= min_threshold) {
184
- const toggle_label = sites_expanded
185
- ? `Hide Sites`
186
- : `Show ${atom_count} sites`
187
- site_items.push({
188
- label: toggle_label,
189
- value: sites_expanded ? `▲` : `▼`,
190
- key: `sites-toggle`,
191
- tooltip: `Click to ${
192
- sites_expanded ? `hide` : `show`
193
- } all site information`,
194
- })
195
- }
305
+ return sections
306
+ })
196
307
 
197
- if (atom_count < min_threshold || sites_expanded) {
198
- structure.sites.forEach((site: Site, idx: number) => {
199
- const element = site.species?.[0]?.element || `Unknown`
200
- const element_name = element_data?.find((el) =>
201
- el.symbol === element
202
- )?.name || element
203
-
204
- site_items.push({
205
- label: `${element}${idx + 1}`,
206
- value: element_name,
207
- key: `site-${idx}-header`,
208
- })
209
-
210
- if (site.abc) {
211
- site_items.push({
212
- label: ` Fractional`,
213
- value: `(${site.abc.map((x) => format_num(x, `.4~f`)).join(`, `)})`,
214
- key: `site-${idx}-fractional`,
215
- })
216
- }
217
- if (site.xyz) {
218
- site_items.push({
219
- label: ` Cartesian`,
220
- value: `(${site.xyz.map((x) => format_num(x, `.4~f`)).join(`, `)}) Å`,
221
- key: `site-${idx}-cartesian`,
222
- })
223
- }
224
-
225
- if (site.properties) {
226
- for (const [prop_key, prop_value] of Object.entries(site.properties)) {
227
- if (prop_value != null && prop_value !== undefined) {
228
- let formatted_value: string
229
- let tooltip: string | undefined
230
-
231
- if (
232
- prop_key === `force` && Array.isArray(prop_value) &&
233
- prop_value.length === 3 && prop_value.every((v) =>
234
- typeof v === `number`
235
- )
236
- ) {
237
- const force_magnitude = Math.hypot(...prop_value)
238
- formatted_value = `${format_num(force_magnitude, `.3~f`)} eV/Å`
239
- tooltip = `Force vector: (${
240
- prop_value.map((force) => format_num(force, `.3~f`)).join(`, `)
241
- }) eV/Å`
242
- } else if (prop_key === `magmom` || prop_key.includes(`magnet`)) {
243
- const num_val = Number(prop_value)
244
- if (isNaN(num_val)) continue
245
- formatted_value = `${format_num(num_val, `.3~f`)} μB`
246
- tooltip = `Magnetic moment in Bohr magnetons`
247
- } else if (Array.isArray(prop_value)) {
248
- formatted_value = `(${
249
- prop_value.map((v) => {
250
- const num_val = Number(v)
251
- return isNaN(num_val) ? String(v) : format_num(num_val, `.3~f`)
252
- }).join(`, `)
253
- })`
254
- } else {
255
- const num_val = Number(prop_value)
256
- formatted_value = isNaN(num_val)
257
- ? String(prop_value)
258
- : format_num(num_val, `.3~f`)
259
- }
260
-
261
- site_items.push({
262
- label: ` ${prop_key}`,
263
- value: formatted_value,
264
- key: `site-${idx}-${prop_key}`,
265
- tooltip,
266
- })
267
- }
268
- }
269
- }
270
- })
271
- }
308
+ let atom_count = $derived(structure?.sites.length ?? 0)
309
+ let sites_allowed_by_threshold = $derived(atom_count <= atom_count_thresholds[1])
310
+ let sites_need_toggle = $derived(
311
+ sites_allowed_by_threshold && atom_count >= atom_count_thresholds[0],
312
+ )
313
+ let site_cards_visible = $derived(
314
+ sites_allowed_by_threshold && (!sites_need_toggle || sites_expanded),
315
+ )
272
316
 
273
- if (site_items.length > 0) {
274
- sections.push({
275
- title: atom_count >= min_threshold ? `` : `Sites`,
276
- items: site_items,
317
+ let site_cards = $derived.by((): SiteCard[] => {
318
+ if (!structure || !site_cards_visible) return []
319
+ return structure.sites.map((site: Site, idx: number) => {
320
+ const element = site.species?.[0]?.element || `Unknown`
321
+ const element_name = get_element_name(element)
322
+ const details: SiteDetail[] = []
323
+ for (const [label, key, coords, unit] of [
324
+ [`Fractional`, `fractional`, site.abc, ``],
325
+ [`Cartesian`, `cartesian`, site.xyz, ` Å`],
326
+ ] as const) {
327
+ if (!coords) continue
328
+ details.push({
329
+ label,
330
+ key,
331
+ value: `(${coords.map((coord) => format_num(coord, `.4~f`)).join(`, `)})${unit}`,
277
332
  })
278
333
  }
279
- }
280
-
281
- // Usage Tips
282
- sections.push({
283
- title: `Usage Tips`,
284
- items: [
285
- {
286
- label: `File Drop`,
287
- value: `Drop POSCAR, XYZ, CIF or JSON files to load structures`,
288
- },
289
- {
290
- label: `Atom Selection`,
291
- value:
292
- `Click atoms to select them, then pick distance or angle mode to measure all pairwise distances/angles`,
293
- },
294
- {
295
- label: `Navigation`,
296
- value: `Hold Shift/Cmd/Ctrl + drag to pan the scene`,
297
- },
298
- {
299
- label: `Camera Reset`,
300
- value: `Double-click anywhere to reset camera to default view`,
301
- },
302
- {
303
- label: `Colors`,
304
- value:
305
- `Click legend labels to change colors, double-click to reset, right-click to remap elements`,
306
- },
307
- {
308
- label: `Keyboard`,
309
- value: `Press 'f' for fullscreen, 'i' to toggle this pane`,
310
- },
311
- ],
334
+ if (site.properties) {
335
+ for (const [prop_key, prop_value] of Object.entries(site.properties)) {
336
+ const detail = format_site_property(prop_key, prop_value)
337
+ if (detail) details.push(detail)
338
+ }
339
+ }
340
+ const title = `${element}${idx + 1}`
341
+ return {
342
+ idx,
343
+ element,
344
+ element_name,
345
+ title,
346
+ details,
347
+ search_text: `${title} ${element} ${element_name} ${
348
+ details.map(({ label, value }) => `${label} ${value}`).join(` `)
349
+ }`.toLowerCase(),
350
+ }
312
351
  })
352
+ })
313
353
 
314
- return sections
354
+ let visible_site_cards = $derived.by(() => {
355
+ const filter = site_filter.trim().toLowerCase()
356
+ if (!filter) return site_cards
357
+ return site_cards.filter(({ search_text }) => search_text.includes(filter))
358
+ })
359
+
360
+ let rendered_site_cards = $derived(
361
+ visible_site_cards.slice(site_window_start, site_window_start + SITE_WINDOW_SIZE),
362
+ )
363
+ let site_window_end = $derived(
364
+ Math.min(site_window_start + SITE_WINDOW_SIZE, visible_site_cards.length),
365
+ )
366
+ let sites_hidden_by_threshold = $derived(sites_need_toggle && !sites_expanded)
367
+ let show_sites_section = $derived(
368
+ site_cards.length > 0 || sites_hidden_by_threshold || sites_need_toggle,
369
+ )
370
+
371
+ $effect(() => {
372
+ if (site_window_start >= visible_site_cards.length) {
373
+ site_window_start = Math.max(0, visible_site_cards.length - SITE_WINDOW_SIZE)
374
+ }
375
+ })
376
+
377
+ $effect(() => {
378
+ const selected_site_idx = selected_sites[0]
379
+ if (!pane_open || selected_site_idx === undefined) return
380
+ const visible_idx = visible_site_cards.findIndex(({ idx }) => idx === selected_site_idx)
381
+ if (visible_idx < 0) return
382
+ const selected_window_start = Math.floor(visible_idx / SITE_WINDOW_SIZE) *
383
+ SITE_WINDOW_SIZE
384
+ if (selected_window_start !== site_window_start) {
385
+ site_window_start = selected_window_start
386
+ return
387
+ }
388
+ site_cards_el
389
+ ?.querySelector(`[data-site-idx="${selected_site_idx}"]`)
390
+ ?.scrollIntoView({ block: `nearest` })
315
391
  })
316
392
 
317
393
  // Compute Wyckoff positions from symmetry data
@@ -340,38 +416,29 @@
340
416
  {/if}
341
417
  {#each section.items as item (item.key ?? item.label)}
342
418
  {@const { key, label, value, tooltip } = item}
343
- {#if section.title === `Usage Tips`}
344
- <div class="tips-item">
345
- <span>{@html sanitize_html(label)}</span>
346
- <span>{@html sanitize_html(value)}</span>
347
- </div>
348
- {:else}
349
- <div
350
- class:site-item={label.startsWith(` `)}
351
- class:toggle-item={key === `sites-toggle`}
352
- class="clickable"
353
- title={key === `sites-toggle` ? tooltip : `Click to copy: ${label}: ${value}`}
354
- onclick={() => handle_click(item, section.title)}
355
- role="button"
356
- tabindex="0"
357
- onkeydown={(event) => {
358
- if ([`Enter`, ` `].includes(event.key)) {
359
- event.preventDefault()
360
- handle_click(item, section.title)
361
- }
362
- }}
363
- >
364
- <span>{@html sanitize_html(label)}</span>
365
- <span title={tooltip}>{@html sanitize_html(value)}</span>
366
- {#if key !== `sites-toggle` && key && copied_items.has(key)}
367
- <Icon
368
- icon="Check"
369
- style="color: var(--success-color, #10b981); width: 12px; height: 12px"
370
- class="copy-checkmark"
371
- />
372
- {/if}
373
- </div>
374
- {/if}
419
+ <div
420
+ class="info-row clickable"
421
+ title={`Click to copy: ${label}: ${value}`}
422
+ onclick={() => copy_info_item(item)}
423
+ role="button"
424
+ tabindex="0"
425
+ onkeydown={(event) => {
426
+ if ([`Enter`, ` `].includes(event.key)) {
427
+ event.preventDefault()
428
+ copy_info_item(item)
429
+ }
430
+ }}
431
+ >
432
+ <span>{@html sanitize_html(label)}</span>
433
+ <span title={tooltip}>{@html sanitize_html(value)}</span>
434
+ {#if key && copied_items.has(key)}
435
+ <Icon
436
+ icon="Check"
437
+ style="color: var(--success-color, #10b981); width: 12px; height: 12px"
438
+ class="copy-checkmark"
439
+ />
440
+ {/if}
441
+ </div>
375
442
  {/each}
376
443
 
377
444
  {#if section.title === `Symmetry` && wyckoff_positions.length > 0}
@@ -384,22 +451,261 @@
384
451
  {/if}
385
452
  </section>
386
453
  {/each}
454
+
455
+ {#if show_sites_section}
456
+ <hr />
457
+ <section class="sites-section">
458
+ <div class="sites-header">
459
+ <h4 id="sites">Sites</h4>
460
+ {#if sites_need_toggle}
461
+ <button
462
+ type="button"
463
+ class="sites-toggle"
464
+ onclick={() => (sites_expanded = !sites_expanded)}
465
+ title="{sites_expanded ? `Hide` : `Show`} all site information"
466
+ >
467
+ {sites_expanded ? `Hide` : `Show ${structure.sites.length} sites`}
468
+ </button>
469
+ {/if}
470
+ </div>
471
+ {#if sites_hidden_by_threshold}
472
+ <p class="sites-note">Site list hidden for this {structure.sites.length}-site structure.</p>
473
+ {:else if site_cards.length > 0}
474
+ <input
475
+ class="site-filter"
476
+ type="search"
477
+ value={site_filter}
478
+ oninput={update_site_filter}
479
+ placeholder="Filter sites by element, index, coordinate, or property"
480
+ aria-label="Filter sites"
481
+ />
482
+ {#if visible_site_cards.length === 0}
483
+ <p class="sites-note">No sites match "{site_filter}".</p>
484
+ {:else}
485
+ {#if visible_site_cards.length > SITE_WINDOW_SIZE}
486
+ <div class="site-window-controls">
487
+ <button
488
+ type="button"
489
+ disabled={site_window_start === 0}
490
+ onclick={() =>
491
+ site_window_start = Math.max(0, site_window_start - SITE_WINDOW_SIZE)}
492
+ >
493
+ Previous
494
+ </button>
495
+ <span>{site_window_start + 1}-{site_window_end} of {visible_site_cards.length}</span>
496
+ <button
497
+ type="button"
498
+ disabled={site_window_end >= visible_site_cards.length}
499
+ onclick={() =>
500
+ site_window_start = Math.min(
501
+ Math.max(0, visible_site_cards.length - SITE_WINDOW_SIZE),
502
+ site_window_start + SITE_WINDOW_SIZE,
503
+ )}
504
+ >
505
+ Next
506
+ </button>
507
+ </div>
508
+ {/if}
509
+ <div class="site-cards" bind:this={site_cards_el}>
510
+ {#each rendered_site_cards as card (card.idx)}
511
+ {@const is_highlighted = highlighted_sites.includes(card.idx) ||
512
+ hovered_site_idx === card.idx}
513
+ {@const is_selected = selected_sites.includes(card.idx)}
514
+ <div
515
+ class="site-card"
516
+ class:highlighted={is_highlighted}
517
+ class:selected={is_selected}
518
+ data-site-idx={card.idx}
519
+ style:--site-color={colors.element?.[card.element as ElementSymbol] ?? `#888`}
520
+ title="Click to select {card.title}. Press c to copy."
521
+ role="button"
522
+ tabindex="0"
523
+ onmouseenter={() => set_site_hover(card.idx)}
524
+ onmouseleave={() => set_site_hover(null)}
525
+ onfocus={() => set_site_hover(card.idx)}
526
+ onblur={() => set_site_hover(null)}
527
+ onclick={(event) => select_site(card.idx, event)}
528
+ onkeydown={(event) => handle_site_keydown(event, card)}
529
+ >
530
+ <div class="site-card-header">
531
+ <span class="site-title">
532
+ <span class="site-color" aria-hidden="true"></span>
533
+ <strong>{card.title}</strong>
534
+ <span>{card.element_name}</span>
535
+ </span>
536
+ <CopyButton
537
+ label="Copy {card.title}"
538
+ title="Copy {card.title}"
539
+ copied={copied_items.has(`site-${card.idx}-summary`)}
540
+ onclick={(event) =>
541
+ copy_event(event, card.title, site_summary(card), `site-${card.idx}-summary`)}
542
+ />
543
+ </div>
544
+ <div class="site-card-details">
545
+ {#each card.details as detail (`site-${card.idx}-${detail.key}`)}
546
+ <div class="site-detail">
547
+ <span>{@html sanitize_html(detail.label)}</span>
548
+ <span title={detail.tooltip}>{@html sanitize_html(detail.value)}</span>
549
+ <CopyButton
550
+ label="Copy {card.title} {detail.label}"
551
+ title="Copy {detail.label}"
552
+ copied={copied_items.has(`site-${card.idx}-${detail.key}`)}
553
+ onclick={(event) =>
554
+ copy_event(
555
+ event,
556
+ `${card.title} ${detail.label}`,
557
+ detail.value,
558
+ `site-${card.idx}-${detail.key}`,
559
+ )}
560
+ />
561
+ </div>
562
+ {/each}
563
+ </div>
564
+ </div>
565
+ {/each}
566
+ </div>
567
+ {/if}
568
+ {/if}
569
+ </section>
570
+ {/if}
571
+
572
+ <hr />
573
+ <section>
574
+ <h4 id="usage-tips">Usage Tips</h4>
575
+ {#each USAGE_TIP_ITEMS as { label, value } (label)}
576
+ <div class="tips-item">
577
+ <span>{@html sanitize_html(label)}</span>
578
+ <span>{@html sanitize_html(value)}</span>
579
+ </div>
580
+ {/each}
581
+ </section>
387
582
  </DraggablePane>
388
583
 
389
584
  <style>
390
- section div {
585
+ .info-row,
586
+ .tips-item {
391
587
  display: flex;
392
588
  justify-content: space-between;
393
589
  gap: 6pt;
394
590
  padding: 1pt;
395
591
  line-height: 1.5;
396
592
  }
397
- section div.clickable {
593
+ .info-row.clickable {
594
+ cursor: pointer;
595
+ position: relative;
596
+ &:hover {
597
+ background: var(--pane-btn-bg-hover, rgba(255, 255, 255, 0.03));
598
+ }
599
+ }
600
+ .sites-header {
601
+ display: flex;
602
+ align-items: center;
603
+ justify-content: space-between;
604
+ gap: 6pt;
605
+ h4 {
606
+ margin: 0.5em 0;
607
+ }
608
+ }
609
+ .sites-toggle,
610
+ .site-window-controls button {
611
+ border: 0;
612
+ border-radius: var(--border-radius, 3pt);
613
+ background: color-mix(in srgb, currentColor 8%, transparent);
614
+ color: inherit;
615
+ cursor: pointer;
616
+ }
617
+ .sites-toggle {
618
+ padding: 2pt 5pt;
619
+ font-size: 0.8em;
620
+ }
621
+ .site-filter {
622
+ box-sizing: border-box;
623
+ width: 100%;
624
+ margin-bottom: 5pt;
625
+ padding: 4pt 6pt;
626
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
627
+ border-radius: var(--border-radius, 3pt);
628
+ background: color-mix(in srgb, var(--pane-bg, Canvas) 88%, currentColor);
629
+ color: inherit;
630
+ }
631
+ .sites-note {
632
+ margin: 0.25em 0 0.5em;
633
+ opacity: 0.75;
634
+ font-size: 0.85em;
635
+ }
636
+ .site-window-controls {
637
+ display: flex;
638
+ align-items: center;
639
+ justify-content: space-between;
640
+ gap: 5pt;
641
+ margin-bottom: 5pt;
642
+ font-size: 0.8em;
643
+ button {
644
+ padding: 2pt 5pt;
645
+ &:disabled {
646
+ cursor: not-allowed;
647
+ opacity: 0.45;
648
+ }
649
+ }
650
+ }
651
+ .site-cards {
652
+ display: grid;
653
+ gap: 5pt;
654
+ }
655
+ .site-card {
656
+ border-left: 3px solid var(--site-color, #888);
657
+ border-radius: var(--border-radius, 3pt);
658
+ background: color-mix(in srgb, currentColor 4%, transparent);
659
+ padding: 5pt;
398
660
  cursor: pointer;
399
- position: relative; /* Add relative positioning for checkmark overlay */
661
+ outline: none;
662
+ &:is(:hover, :focus-visible, .highlighted) {
663
+ background: color-mix(in srgb, var(--site-color, currentColor) 18%, transparent);
664
+ }
665
+ &.selected {
666
+ box-shadow: inset 0 0 0 1px var(--site-color, currentColor);
667
+ background: color-mix(in srgb, var(--site-color, currentColor) 25%, transparent);
668
+ }
669
+ }
670
+ .site-card-header,
671
+ .site-title,
672
+ .site-detail {
673
+ display: flex;
674
+ align-items: center;
675
+ gap: 5pt;
400
676
  }
401
- section div:hover {
402
- background: var(--pane-btn-bg-hover, rgba(255, 255, 255, 0.03));
677
+ .site-card-header {
678
+ justify-content: space-between;
679
+ }
680
+ .site-title {
681
+ min-width: 0;
682
+ span:last-child {
683
+ opacity: 0.75;
684
+ }
685
+ }
686
+ .site-color {
687
+ width: 0.75em;
688
+ height: 0.75em;
689
+ flex: 0 0 auto;
690
+ border-radius: 50%;
691
+ background: var(--site-color, #888);
692
+ }
693
+ .site-card-details {
694
+ display: grid;
695
+ gap: 2pt;
696
+ margin-top: 3pt;
697
+ font-size: 0.86em;
698
+ }
699
+ .site-detail {
700
+ justify-content: space-between;
701
+ span:first-child {
702
+ opacity: 0.75;
703
+ }
704
+ span:nth-child(2) {
705
+ overflow: hidden;
706
+ text-overflow: ellipsis;
707
+ white-space: nowrap;
708
+ }
403
709
  }
404
710
  section :global(.copy-checkmark) {
405
711
  position: absolute;
@@ -419,16 +725,11 @@
419
725
  opacity: 0;
420
726
  }
421
727
  }
422
- section div.site-item {
423
- border-left: 2px solid #3b82f6;
424
- margin-left: 10pt;
425
- padding-left: 6pt;
426
- }
427
- section div.tips-item {
728
+ .tips-item {
428
729
  flex-direction: column;
429
730
  gap: 2pt;
430
- }
431
- section div.tips-item span:last-child {
432
- opacity: 0.8;
731
+ span:last-child {
732
+ opacity: 0.8;
733
+ }
433
734
  }
434
735
  </style>