matterviz 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/BohrAtom.svelte +105 -0
  2. package/dist/BohrAtom.svelte.d.ts +21 -0
  3. package/dist/ControlPanel.svelte +158 -0
  4. package/dist/ControlPanel.svelte.d.ts +18 -0
  5. package/dist/Icon.svelte +23 -0
  6. package/dist/Icon.svelte.d.ts +8 -0
  7. package/dist/InfoCard.svelte +79 -0
  8. package/dist/InfoCard.svelte.d.ts +23 -0
  9. package/dist/Nucleus.svelte +64 -0
  10. package/dist/Nucleus.svelte.d.ts +16 -0
  11. package/dist/Spinner.svelte +44 -0
  12. package/dist/Spinner.svelte.d.ts +7 -0
  13. package/dist/api.d.ts +6 -0
  14. package/dist/api.js +30 -0
  15. package/dist/colors/alloy-colors.json +111 -0
  16. package/dist/colors/dark-mode-colors.json +111 -0
  17. package/dist/colors/index.d.ts +26 -0
  18. package/dist/colors/index.js +72 -0
  19. package/dist/colors/jmol-colors.json +111 -0
  20. package/dist/colors/muted-colors.json +111 -0
  21. package/dist/colors/pastel-colors.json +111 -0
  22. package/dist/colors/vesta-colors.json +111 -0
  23. package/dist/composition/BarChart.svelte +260 -0
  24. package/dist/composition/BarChart.svelte.d.ts +33 -0
  25. package/dist/composition/BubbleChart.svelte +166 -0
  26. package/dist/composition/BubbleChart.svelte.d.ts +30 -0
  27. package/dist/composition/Composition.svelte +73 -0
  28. package/dist/composition/Composition.svelte.d.ts +27 -0
  29. package/dist/composition/PieChart.svelte +236 -0
  30. package/dist/composition/PieChart.svelte.d.ts +36 -0
  31. package/dist/composition/index.d.ts +5 -0
  32. package/dist/composition/index.js +5 -0
  33. package/dist/composition/parse.d.ts +14 -0
  34. package/dist/composition/parse.js +307 -0
  35. package/dist/element/ElementHeading.svelte +21 -0
  36. package/dist/element/ElementHeading.svelte.d.ts +8 -0
  37. package/dist/element/ElementPhoto.svelte +56 -0
  38. package/dist/element/ElementPhoto.svelte.d.ts +9 -0
  39. package/dist/element/ElementStats.svelte +73 -0
  40. package/dist/element/ElementStats.svelte.d.ts +8 -0
  41. package/dist/element/ElementTile.svelte +449 -0
  42. package/dist/element/ElementTile.svelte.d.ts +25 -0
  43. package/dist/element/data.d.ts +4958 -0
  44. package/dist/element/data.js +5628 -0
  45. package/dist/element/index.d.ts +4 -0
  46. package/dist/element/index.js +4 -0
  47. package/dist/icons.d.ts +435 -0
  48. package/dist/icons.js +435 -0
  49. package/dist/index.d.ts +82 -0
  50. package/dist/index.js +43 -0
  51. package/dist/io/decompress.d.ts +16 -0
  52. package/dist/io/decompress.js +78 -0
  53. package/dist/io/export.d.ts +9 -0
  54. package/dist/io/export.js +205 -0
  55. package/dist/io/parse.d.ts +53 -0
  56. package/dist/io/parse.js +747 -0
  57. package/dist/labels.d.ts +31 -0
  58. package/dist/labels.js +209 -0
  59. package/dist/material/MaterialCard.svelte +135 -0
  60. package/dist/material/MaterialCard.svelte.d.ts +10 -0
  61. package/dist/material/SymmetryCard.svelte +23 -0
  62. package/dist/material/SymmetryCard.svelte.d.ts +9 -0
  63. package/dist/material/index.d.ts +2 -0
  64. package/dist/material/index.js +2 -0
  65. package/dist/math.d.ts +24 -0
  66. package/dist/math.js +216 -0
  67. package/dist/periodic-table/PeriodicTable.svelte +284 -0
  68. package/dist/periodic-table/PeriodicTable.svelte.d.ts +50 -0
  69. package/dist/periodic-table/PropertySelect.svelte +20 -0
  70. package/dist/periodic-table/PropertySelect.svelte.d.ts +13 -0
  71. package/dist/periodic-table/TableInset.svelte +18 -0
  72. package/dist/periodic-table/TableInset.svelte.d.ts +9 -0
  73. package/dist/periodic-table/index.d.ts +9 -0
  74. package/dist/periodic-table/index.js +3 -0
  75. package/dist/plot/ColorBar.svelte +414 -0
  76. package/dist/plot/ColorBar.svelte.d.ts +22 -0
  77. package/dist/plot/ColorScaleSelect.svelte +31 -0
  78. package/dist/plot/ColorScaleSelect.svelte.d.ts +15 -0
  79. package/dist/plot/ElementScatter.svelte +38 -0
  80. package/dist/plot/ElementScatter.svelte.d.ts +14 -0
  81. package/dist/plot/Line.svelte +42 -0
  82. package/dist/plot/Line.svelte.d.ts +15 -0
  83. package/dist/plot/PlotLegend.svelte +206 -0
  84. package/dist/plot/PlotLegend.svelte.d.ts +18 -0
  85. package/dist/plot/ScatterPlot.svelte +1753 -0
  86. package/dist/plot/ScatterPlot.svelte.d.ts +114 -0
  87. package/dist/plot/ScatterPlotControls.svelte +505 -0
  88. package/dist/plot/ScatterPlotControls.svelte.d.ts +33 -0
  89. package/dist/plot/ScatterPoint.svelte +72 -0
  90. package/dist/plot/ScatterPoint.svelte.d.ts +17 -0
  91. package/dist/plot/index.d.ts +168 -0
  92. package/dist/plot/index.js +46 -0
  93. package/dist/state.svelte.d.ts +12 -0
  94. package/dist/state.svelte.js +11 -0
  95. package/dist/structure/Bond.svelte +68 -0
  96. package/dist/structure/Bond.svelte.d.ts +13 -0
  97. package/dist/structure/Lattice.svelte +115 -0
  98. package/dist/structure/Lattice.svelte.d.ts +15 -0
  99. package/dist/structure/Structure.svelte +298 -0
  100. package/dist/structure/Structure.svelte.d.ts +28 -0
  101. package/dist/structure/StructureCard.svelte +26 -0
  102. package/dist/structure/StructureCard.svelte.d.ts +9 -0
  103. package/dist/structure/StructureControls.svelte +383 -0
  104. package/dist/structure/StructureControls.svelte.d.ts +23 -0
  105. package/dist/structure/StructureLegend.svelte +130 -0
  106. package/dist/structure/StructureLegend.svelte.d.ts +17 -0
  107. package/dist/structure/StructureScene.svelte +331 -0
  108. package/dist/structure/StructureScene.svelte.d.ts +47 -0
  109. package/dist/structure/bonding.d.ts +16 -0
  110. package/dist/structure/bonding.js +150 -0
  111. package/dist/structure/index.d.ts +98 -0
  112. package/dist/structure/index.js +114 -0
  113. package/dist/structure/pbc.d.ts +6 -0
  114. package/dist/structure/pbc.js +72 -0
  115. package/dist/trajectory/Sidebar.svelte +412 -0
  116. package/dist/trajectory/Sidebar.svelte.d.ts +14 -0
  117. package/dist/trajectory/Trajectory.svelte +1084 -0
  118. package/dist/trajectory/Trajectory.svelte.d.ts +49 -0
  119. package/dist/trajectory/TrajectoryError.svelte +120 -0
  120. package/dist/trajectory/TrajectoryError.svelte.d.ts +12 -0
  121. package/dist/trajectory/extract.d.ts +5 -0
  122. package/dist/trajectory/extract.js +157 -0
  123. package/dist/trajectory/index.d.ts +16 -0
  124. package/dist/trajectory/index.js +49 -0
  125. package/dist/trajectory/parse.d.ts +13 -0
  126. package/dist/trajectory/parse.js +1093 -0
  127. package/dist/trajectory/plotting.d.ts +12 -0
  128. package/dist/trajectory/plotting.js +148 -0
  129. package/license +21 -0
  130. package/package.json +131 -0
  131. package/readme.md +95 -0
@@ -0,0 +1,1084 @@
1
+ <script lang="ts">import { Icon, Spinner, Structure } from '..';
2
+ import { decompress_file } from '../io/decompress';
3
+ import { format_num, trajectory_labels } from '../labels';
4
+ import { ScatterPlot } from '../plot';
5
+ import { scaleLinear } from 'd3-scale';
6
+ import { untrack } from 'svelte';
7
+ import { titles_as_tooltips } from 'svelte-zoo';
8
+ import { full_data_extractor } from './extract';
9
+ import { Sidebar, TrajectoryError } from './index';
10
+ import { data_url_to_array_buffer, get_unsupported_format_message, load_trajectory_from_url, parse_trajectory_data, } from './parse';
11
+ import { generate_plot_series, should_hide_plot } from './plotting';
12
+ let { trajectory = $bindable(undefined), trajectory_url, current_step_idx = $bindable(0), data_extractor = full_data_extractor, allow_file_drop = true, on_file_drop = handle_trajectory_file_drop, layout = `horizontal`, structure_props = {}, plot_props = {}, spinner_props = {}, trajectory_controls, error_snippet, show_controls = true, show_fullscreen_button = true, display_mode = $bindable(`both`), property_labels = trajectory_labels, units = {
13
+ energy: `eV`,
14
+ energy_per_atom: `eV/atom`,
15
+ force_max: `eV/Å`,
16
+ force_norm: `eV/Å`,
17
+ stress_max: `GPa`,
18
+ volume: `ų`,
19
+ density: `g/cm³`,
20
+ temperature: `K`,
21
+ pressure: `GPa`,
22
+ length: `Å`,
23
+ }, step_labels = 5, } = $props();
24
+ let dragover = $state(false);
25
+ let loading = $state(false);
26
+ let error_message = $state(null);
27
+ let is_playing = $state(false);
28
+ let frame_rate_fps = $state(5);
29
+ let play_interval = $state(undefined);
30
+ let current_filename = $state(null);
31
+ let current_file_path = $state(null);
32
+ let file_size = $state(null);
33
+ let file_object = $state(null);
34
+ let wrapper = $state(undefined);
35
+ let sidebar_open = $state(false);
36
+ // Current frame structure for display
37
+ let current_structure = $derived(trajectory && current_step_idx < trajectory.frames.length
38
+ ? trajectory.frames[current_step_idx]?.structure
39
+ : undefined);
40
+ // Calculate step label positions using D3's pretty ticks for even distribution
41
+ let step_label_positions = $derived.by(() => {
42
+ if (!trajectory || !step_labels)
43
+ return [];
44
+ const total_frames = trajectory.frames.length;
45
+ if (total_frames <= 1)
46
+ return [];
47
+ if (Array.isArray(step_labels)) {
48
+ // Exact positions provided as array
49
+ return step_labels.filter((idx) => idx >= 0 && idx < total_frames);
50
+ }
51
+ if (typeof step_labels === `number`) {
52
+ if (step_labels > 0) {
53
+ // Use D3's pretty ticks for even distribution
54
+ const scale = scaleLinear().domain([0, total_frames - 1]);
55
+ const ticks = scale.nice().ticks(Math.min(step_labels, total_frames));
56
+ // Round and filter to valid frame indices
57
+ return ticks
58
+ .map((t) => Math.round(t))
59
+ .filter((t) => t >= 0 && t < total_frames)
60
+ .filter((t, idx, arr) => arr.indexOf(t) === idx); // Remove duplicates
61
+ }
62
+ else if (step_labels < 0) {
63
+ // Negative number: spacing between ticks
64
+ const spacing = Math.abs(step_labels);
65
+ const positions = [];
66
+ for (let idx = 0; idx < total_frames; idx += spacing) {
67
+ positions.push(idx);
68
+ }
69
+ // Always include the last frame if it's not already included
70
+ if (positions[positions.length - 1] !== total_frames - 1) {
71
+ positions.push(total_frames - 1);
72
+ }
73
+ return positions;
74
+ }
75
+ }
76
+ return [];
77
+ });
78
+ // Generate plot data using extracted plotting utilities
79
+ let plot_series = $derived.by(() => {
80
+ if (!trajectory)
81
+ return [];
82
+ // Filter out undefined values from units
83
+ const filtered_units = {};
84
+ for (const [key, value] of Object.entries(units)) {
85
+ if (value !== undefined) {
86
+ filtered_units[key] = value;
87
+ }
88
+ }
89
+ return generate_plot_series(trajectory, data_extractor, {
90
+ property_labels,
91
+ units: filtered_units,
92
+ });
93
+ });
94
+ // Check if all plotted values are constant (no variation) using extracted utility
95
+ let show_plot = $derived(display_mode !== `structure` && !should_hide_plot(trajectory, plot_series));
96
+ // Determine what to show based on display mode
97
+ let show_structure = $derived(display_mode !== `plot`);
98
+ let actual_show_plot = $derived(display_mode !== `structure` && show_plot);
99
+ // Generate intelligent axis labels based on first series on each axis
100
+ let y_axis_labels = $derived.by(() => {
101
+ if (plot_series.length === 0)
102
+ return { y1: `Value`, y2: `Value` };
103
+ const y1_series = plot_series.filter((s) => (s.y_axis ?? `y1`) === `y1`);
104
+ const y2_series = plot_series.filter((s) => s.y_axis === `y2`);
105
+ const get_axis_label = (series) => {
106
+ if (series.length === 0)
107
+ return `Value`;
108
+ // Use the first series label as the axis label
109
+ const first_series = series[0];
110
+ return first_series?.label || `Value`;
111
+ };
112
+ return {
113
+ y1: get_axis_label(y1_series),
114
+ y2: get_axis_label(y2_series),
115
+ };
116
+ });
117
+ // Check if there are any Y2 series to determine padding
118
+ let has_y2_series = $derived(plot_series.some((s) => s.y_axis === `y2` && s.visible));
119
+ // Handle file drop events
120
+ async function handle_file_drop(event) {
121
+ event.preventDefault();
122
+ dragover = false;
123
+ if (!allow_file_drop)
124
+ return;
125
+ // Check for our custom internal file format first
126
+ const internal_data = event.dataTransfer?.getData(`application/x-matterviz-file`);
127
+ if (internal_data) {
128
+ try {
129
+ const file_info = JSON.parse(internal_data);
130
+ // Check if this is a binary file
131
+ if (file_info.is_binary) {
132
+ const array_buffer = data_url_to_array_buffer(file_info.content);
133
+ await handle_trajectory_binary_drop(array_buffer, file_info.name);
134
+ }
135
+ else {
136
+ await on_file_drop(file_info.content, file_info.name);
137
+ }
138
+ return;
139
+ }
140
+ catch (error) {
141
+ console.warn(`Failed to parse internal file data:`, error);
142
+ // Fall through to other methods
143
+ }
144
+ }
145
+ // Check for plain text data (fallback)
146
+ const text_data = event.dataTransfer?.getData(`text/plain`);
147
+ if (text_data) {
148
+ file_size = null; // Size not available for text drops
149
+ await on_file_drop(text_data, `trajectory.json`);
150
+ return;
151
+ }
152
+ // Handle actual file drops from file system
153
+ const file = event.dataTransfer?.files[0];
154
+ if (!file)
155
+ return;
156
+ loading = true;
157
+ file_size = file.size; // Capture file size
158
+ current_file_path = file.webkitRelativePath || file.name; // Capture full path if available
159
+ file_object = file;
160
+ try {
161
+ // Check if this is an HDF5 file (handle as binary)
162
+ if (file.name.toLowerCase().endsWith(`.h5`) ||
163
+ file.name.toLowerCase().endsWith(`.hdf5`)) {
164
+ const buffer = await file.arrayBuffer();
165
+ await handle_trajectory_binary_drop(buffer, file.name);
166
+ return;
167
+ }
168
+ // Check for known unsupported binary formats before trying to read
169
+ const unsupported_message = get_unsupported_format_message(file.name, ``);
170
+ if (unsupported_message) {
171
+ error_message = unsupported_message;
172
+ current_filename = null;
173
+ file_size = null;
174
+ return;
175
+ }
176
+ const { content, filename } = await decompress_file(file);
177
+ if (content)
178
+ await on_file_drop(content, filename);
179
+ }
180
+ catch (error) {
181
+ error_message = `Failed to read file: ${error}`;
182
+ current_filename = null;
183
+ file_size = null;
184
+ console.error(`File reading error:`, error);
185
+ }
186
+ finally {
187
+ loading = false;
188
+ }
189
+ }
190
+ // Step navigation functions
191
+ function next_step() {
192
+ if (trajectory && current_step_idx < trajectory.frames.length - 1) {
193
+ current_step_idx++;
194
+ }
195
+ }
196
+ function prev_step() {
197
+ if (current_step_idx > 0) {
198
+ current_step_idx--;
199
+ }
200
+ }
201
+ function go_to_step(idx) {
202
+ if (trajectory && idx >= 0 && idx < trajectory.frames.length) {
203
+ current_step_idx = idx;
204
+ }
205
+ }
206
+ // Handle plot point clicks to jump to that step
207
+ function handle_plot_change(data) {
208
+ if (data?.x !== undefined && typeof data.x === `number`) {
209
+ const step_idx = Math.round(data.x);
210
+ go_to_step(step_idx);
211
+ }
212
+ }
213
+ // Play/pause functionality
214
+ function toggle_play() {
215
+ if (is_playing) {
216
+ pause_playback();
217
+ }
218
+ else {
219
+ start_playback();
220
+ }
221
+ }
222
+ function start_playback() {
223
+ if (!trajectory || trajectory.frames.length <= 1)
224
+ return;
225
+ is_playing = true;
226
+ }
227
+ function pause_playback() {
228
+ is_playing = false;
229
+ }
230
+ // Effect to manage playback interval
231
+ $effect(() => {
232
+ // Only watch is_playing and frame_rate_ms, not play_interval itself
233
+ const playing = is_playing;
234
+ const rate_ms = 1000 / frame_rate_fps;
235
+ if (playing) {
236
+ // Clear existing interval if it exists - use untrack to avoid circular dependency
237
+ const current_interval = untrack(() => play_interval);
238
+ if (current_interval !== undefined)
239
+ clearInterval(current_interval);
240
+ // Create new interval with current frame rate
241
+ play_interval = setInterval(() => {
242
+ if (current_step_idx >= trajectory.frames.length - 1)
243
+ go_to_step(0); // Loop back to 1st step
244
+ else
245
+ next_step();
246
+ }, rate_ms);
247
+ }
248
+ else {
249
+ // Clear interval when not playing - use untrack to avoid circular dependency
250
+ const current_interval = untrack(() => play_interval);
251
+ if (current_interval !== undefined) {
252
+ clearInterval(current_interval);
253
+ play_interval = undefined;
254
+ }
255
+ }
256
+ });
257
+ // Cleanup interval on component destroy
258
+ $effect(() => {
259
+ return () => {
260
+ if (play_interval !== undefined)
261
+ clearInterval(play_interval);
262
+ };
263
+ });
264
+ // Load trajectory from URL when trajectory_url is provided
265
+ $effect(() => {
266
+ if (trajectory_url && !trajectory) {
267
+ loading = true;
268
+ error_message = null;
269
+ load_trajectory_from_url(trajectory_url)
270
+ .then((loaded_trajectory) => {
271
+ trajectory = loaded_trajectory;
272
+ current_step_idx = 0;
273
+ // Extract filename from URL
274
+ current_filename = trajectory_url.split(`/`).pop() || trajectory_url;
275
+ file_size = null; // Size not available for URL loads
276
+ loading = false;
277
+ })
278
+ .catch((err) => {
279
+ console.error(`Failed to load trajectory from URL:`, err);
280
+ error_message = `Failed to load trajectory: ${err.message}`;
281
+ current_filename = null;
282
+ file_size = null;
283
+ loading = false;
284
+ });
285
+ }
286
+ });
287
+ async function handle_trajectory_file_drop(content, filename) {
288
+ loading = true;
289
+ error_message = null;
290
+ try {
291
+ // Check for unsupported formats first
292
+ const unsupported_message = get_unsupported_format_message(filename, content);
293
+ if (unsupported_message) {
294
+ error_message = unsupported_message;
295
+ current_filename = null;
296
+ file_size = null;
297
+ return;
298
+ }
299
+ // Use the new parser that can handle multiple formats including XDATCAR
300
+ trajectory = await parse_trajectory_data(content, filename);
301
+ current_step_idx = 0;
302
+ current_filename = filename;
303
+ // Note: file_size remains as set by the caller (could be null for text drops)
304
+ }
305
+ catch (err) {
306
+ // Check if this might be an unsupported format even if not detected initially
307
+ const unsupported_message = get_unsupported_format_message(filename, content);
308
+ if (unsupported_message) {
309
+ error_message = unsupported_message;
310
+ }
311
+ else {
312
+ error_message = `Failed to parse trajectory file: ${err}`;
313
+ }
314
+ current_filename = null;
315
+ file_size = null;
316
+ console.error(`Trajectory parsing error:`, err);
317
+ }
318
+ finally {
319
+ loading = false;
320
+ }
321
+ }
322
+ async function handle_trajectory_binary_drop(buffer, filename) {
323
+ loading = true;
324
+ error_message = null;
325
+ try {
326
+ // Parse binary data (e.g., HDF5 files)
327
+ trajectory = await parse_trajectory_data(buffer, filename);
328
+ current_step_idx = 0;
329
+ current_filename = filename;
330
+ // Note: file_size should already be set by the caller
331
+ }
332
+ catch (err) {
333
+ error_message = `Failed to parse binary trajectory file: ${err}`;
334
+ current_filename = null;
335
+ file_size = null;
336
+ console.error(`Binary trajectory parsing error:`, err);
337
+ }
338
+ finally {
339
+ loading = false;
340
+ }
341
+ }
342
+ // Fullscreen functionality
343
+ function toggle_fullscreen() {
344
+ if (!document.fullscreenElement && wrapper) {
345
+ wrapper.requestFullscreen().catch(console.error);
346
+ }
347
+ else {
348
+ document.exitFullscreen();
349
+ }
350
+ }
351
+ // Display mode cycling
352
+ function cycle_display_mode() {
353
+ const modes = [`both`, `structure`, `plot`];
354
+ const current_index = modes.indexOf(display_mode);
355
+ const next_index = (current_index + 1) % modes.length;
356
+ display_mode = modes[next_index];
357
+ }
358
+ // Display mode labels and icons
359
+ const display_mode_config = {
360
+ both: {
361
+ label: `Show Both`,
362
+ icon: `TwoColumns`,
363
+ title: `Show both structure and plot`,
364
+ },
365
+ structure: {
366
+ label: `Structure Only`,
367
+ icon: `Atom`,
368
+ title: `Show structure only`,
369
+ },
370
+ plot: { label: `Plot Only`, icon: `ScatterPlot`, title: `Show plot only` },
371
+ };
372
+ // Handle click outside sidebar to close it
373
+ function handle_click_outside(event) {
374
+ if (!sidebar_open)
375
+ return;
376
+ const target = event.target;
377
+ const sidebar = target.closest(`.info-sidebar`);
378
+ const info_button = target.closest(`.info-button`);
379
+ // Don't close if clicking on sidebar or info button
380
+ if (!sidebar && !info_button)
381
+ sidebar_open = false;
382
+ }
383
+ // Handle keyboard shortcuts
384
+ function onkeydown(event) {
385
+ if (!trajectory)
386
+ return;
387
+ // Don't handle shortcuts if user is typing in an input field (but allow if it's our step input and not focused)
388
+ const target = event.target;
389
+ const is_step_input = target.classList.contains(`step-input`);
390
+ const is_input_focused = target.tagName === `INPUT` ||
391
+ target.tagName === `TEXTAREA`;
392
+ // Skip if typing in an input that's not our step input
393
+ if (is_input_focused && !is_step_input)
394
+ return;
395
+ // If typing in step input, only handle certain navigation keys
396
+ if (is_step_input && is_input_focused) {
397
+ // Allow normal typing, but handle special navigation keys
398
+ if ([`Escape`, `Enter`].includes(event.key))
399
+ target.blur(); // Remove focus from input
400
+ return;
401
+ }
402
+ const total_frames = trajectory.frames.length;
403
+ const is_cmd_or_ctrl = event.metaKey || event.ctrlKey;
404
+ // Navigation shortcuts
405
+ if (event.key === ` `)
406
+ toggle_play();
407
+ else if (event.key === `ArrowLeft`) {
408
+ if (is_cmd_or_ctrl)
409
+ go_to_step(0);
410
+ else
411
+ prev_step();
412
+ }
413
+ else if (event.key === `ArrowRight`) {
414
+ if (is_cmd_or_ctrl)
415
+ go_to_step(total_frames - 1);
416
+ else
417
+ next_step();
418
+ }
419
+ else if (event.key === `Home`)
420
+ go_to_step(0);
421
+ else if (event.key === `End`)
422
+ go_to_step(total_frames - 1);
423
+ else if (event.key === `j`) {
424
+ go_to_step(Math.max(0, current_step_idx - 10));
425
+ }
426
+ else if (event.key === `l`) {
427
+ go_to_step(Math.min(total_frames - 1, current_step_idx + 10));
428
+ }
429
+ else if (event.key === `PageUp`) {
430
+ go_to_step(Math.max(0, current_step_idx - 25));
431
+ }
432
+ else if (event.key === `PageDown`) {
433
+ go_to_step(Math.min(total_frames - 1, current_step_idx + 25));
434
+ } // Interface shortcuts
435
+ else if (event.key === `f`)
436
+ toggle_fullscreen();
437
+ else if (event.key === `i`)
438
+ sidebar_open = !sidebar_open;
439
+ else if (event.key === `d` && plot_series.length > 0) {
440
+ cycle_display_mode();
441
+ } // Playback speed shortcuts (only when playing)
442
+ else if ((event.key === `=` || event.key === `+`) && is_playing) {
443
+ frame_rate_fps = Math.min(5, frame_rate_fps + 0.2);
444
+ }
445
+ else if (event.key === `-` && is_playing) {
446
+ frame_rate_fps = Math.max(0.2, frame_rate_fps - 0.2);
447
+ } // System shortcuts
448
+ else if (event.key === `Escape`) {
449
+ if (document.fullscreenElement)
450
+ document.exitFullscreen();
451
+ else
452
+ sidebar_open = false;
453
+ } // Number keys 0-9 - jump to percentage of trajectory
454
+ else if (event.key >= `0` && event.key <= `9`) {
455
+ go_to_step(Math.floor((parseInt(event.key, 10) / 10) * (total_frames - 1)));
456
+ }
457
+ else if (event.key === `Escape` && sidebar_open) { // Escape key to close sidebar
458
+ event.stopPropagation();
459
+ sidebar_open = false;
460
+ }
461
+ }
462
+ </script>
463
+
464
+ <div
465
+ class="trajectory-viewer"
466
+ class:horizontal={layout === `horizontal`}
467
+ class:vertical={layout === `vertical`}
468
+ class:dragover
469
+ bind:this={wrapper}
470
+ role="button"
471
+ tabindex="0"
472
+ aria-label="Drop trajectory file here to load"
473
+ ondrop={handle_file_drop}
474
+ ondragover={(event) => {
475
+ event.preventDefault()
476
+ if (!allow_file_drop) return
477
+ dragover = true
478
+ }}
479
+ ondragleave={(event) => {
480
+ event.preventDefault()
481
+ dragover = false
482
+ }}
483
+ onclick={handle_click_outside}
484
+ {onkeydown}
485
+ >
486
+ {#if loading}
487
+ <Spinner text="Loading trajectory..." {...spinner_props} />
488
+ {:else if error_message}
489
+ <TrajectoryError
490
+ {error_message}
491
+ on_dismiss={() => (error_message = null)}
492
+ {error_snippet}
493
+ />
494
+ {:else if trajectory}
495
+ <!-- Trajectory Controls -->
496
+ {#if show_controls}
497
+ <div class="trajectory-controls">
498
+ {#if trajectory_controls}
499
+ {@render trajectory_controls({
500
+ trajectory,
501
+ current_step_idx,
502
+ total_frames: trajectory.frames.length,
503
+ on_step_change: go_to_step,
504
+ })}
505
+ {:else}
506
+ {@const input_width = Math.max(25, String(current_step_idx).length * 8 + 6)}
507
+ {#if current_filename}
508
+ <div class="filename-section">
509
+ <button
510
+ use:titles_as_tooltips
511
+ title="Click to copy filename"
512
+ onclick={() => {
513
+ if (current_filename) navigator.clipboard.writeText(current_filename)
514
+ }}
515
+ >
516
+ {current_filename}
517
+ </button>
518
+ </div>
519
+ {/if}
520
+
521
+ <!-- Navigation controls -->
522
+ <div class="nav-section">
523
+ <button
524
+ onclick={prev_step}
525
+ disabled={current_step_idx === 0 || is_playing}
526
+ title="Previous step"
527
+ class="nav-button"
528
+ >
529
+
530
+ </button>
531
+
532
+ <button
533
+ onclick={toggle_play}
534
+ disabled={trajectory.frames.length <= 1}
535
+ title={is_playing ? `Pause playback` : `Play trajectory`}
536
+ class="play-button nav-button"
537
+ class:playing={is_playing}
538
+ >
539
+ {is_playing ? `⏸` : `▶`}
540
+ </button>
541
+
542
+ <button
543
+ onclick={next_step}
544
+ disabled={current_step_idx === trajectory.frames.length - 1 || is_playing}
545
+ title="Next step"
546
+ class="nav-button"
547
+ >
548
+
549
+ </button>
550
+ </div>
551
+
552
+ <!-- Frame slider and counter -->
553
+ <div class="step-section">
554
+ <input
555
+ type="number"
556
+ min="0"
557
+ max={trajectory.frames.length - 1}
558
+ bind:value={current_step_idx}
559
+ oninput={(event) => {
560
+ const target = event.target as HTMLInputElement
561
+ const width = Math.max(25, Math.min(80, target.value.length * 8 + 6))
562
+ target.style.width = `${width}px`
563
+ }}
564
+ style:width="{input_width}px"
565
+ class="step-input"
566
+ title="Enter step number to jump to"
567
+ />
568
+ <span>/ {trajectory.frames.length}</span>
569
+ <div class="slider-container">
570
+ <input
571
+ type="range"
572
+ min="0"
573
+ max={trajectory.frames.length - 1}
574
+ bind:value={current_step_idx}
575
+ class="step-slider"
576
+ title="Drag to navigate steps"
577
+ />
578
+ {#if step_label_positions.length > 0}
579
+ <div class="step-labels">
580
+ {#each step_label_positions as step_idx (step_idx)}
581
+ {@const position_percent = (step_idx / (trajectory.frames.length - 1)) *
582
+ 100}
583
+ {@const adjusted_position = 1.5 + (position_percent * (100 - 2)) / 100}
584
+ <div class="step-tick" style:left="{adjusted_position}%"></div>
585
+ <div class="step-label" style:left="{adjusted_position}%">
586
+ {step_idx}
587
+ </div>
588
+ {/each}
589
+ </div>
590
+ {/if}
591
+ </div>
592
+ </div>
593
+
594
+ <!-- Frame rate control - only shown when playing -->
595
+ {#if is_playing}
596
+ <div class="speed-section">
597
+ <label for="step-rate-slider" style="font-weight: 500; white-space: nowrap"
598
+ >Speed:</label>
599
+ <input
600
+ id="step-rate-slider"
601
+ type="range"
602
+ min="0.2"
603
+ max="5"
604
+ step="0.1"
605
+ bind:value={frame_rate_fps}
606
+ class="speed-slider"
607
+ title="Frame rate: {format_num(frame_rate_fps, `.2~s`)} fps"
608
+ />
609
+ <input
610
+ type="number"
611
+ min="0.2"
612
+ max="5"
613
+ step="0.1"
614
+ bind:value={frame_rate_fps}
615
+ class="speed-input"
616
+ title="Enter precise FPS value"
617
+ />
618
+ fps
619
+ </div>
620
+ {/if}
621
+
622
+ <!-- Frame info section -->
623
+ <div class="info-section">
624
+ <!-- Info button to open sidebar -->
625
+ {#if trajectory}
626
+ <button
627
+ onclick={() => (sidebar_open = !sidebar_open)}
628
+ title={sidebar_open ? `Close info panel` : `Open info panel`}
629
+ aria-label={sidebar_open ? `Close info panel` : `Open info panel`}
630
+ class="info-button nav-button"
631
+ class:active={sidebar_open}
632
+ >
633
+ <Icon icon="Info" style="width: 22px; height: 22px" />
634
+ </button>
635
+ {/if}
636
+ <!-- Display mode button - after info button -->
637
+ {#if plot_series.length > 0}
638
+ <button
639
+ onclick={cycle_display_mode}
640
+ title={display_mode_config[display_mode].title}
641
+ class="display-mode nav-button"
642
+ >
643
+ <Icon icon={display_mode_config[display_mode].icon} />
644
+ </button>
645
+ {/if}
646
+ <!-- Fullscreen button - rightmost position -->
647
+ {#if show_fullscreen_button}
648
+ <button
649
+ onclick={toggle_fullscreen}
650
+ title="Toggle fullscreen"
651
+ aria-label="Toggle fullscreen"
652
+ class="fullscreen-button nav-button"
653
+ >
654
+ <Icon icon="Fullscreen" />
655
+ </button>
656
+ {/if}
657
+ </div>
658
+ {/if}
659
+ </div>
660
+ {/if}
661
+
662
+ <div
663
+ class="content-area"
664
+ class:hide-plot={!actual_show_plot}
665
+ class:hide-structure={!show_structure}
666
+ class:show-both={display_mode === `both`}
667
+ class:show-structure-only={display_mode === `structure`}
668
+ class:show-plot-only={display_mode === `plot`}
669
+ >
670
+ {#if show_structure}
671
+ <Structure
672
+ structure={current_structure}
673
+ allow_file_drop={false}
674
+ style="height: 100%; border-radius: 0"
675
+ enable_tips={false}
676
+ fullscreen_toggle={false}
677
+ {...{ show_image_atoms: false, ...structure_props }}
678
+ />
679
+ {/if}
680
+
681
+ {#if actual_show_plot}
682
+ <ScatterPlot
683
+ series={plot_series}
684
+ x_label="Step"
685
+ y_label={y_axis_labels.y1}
686
+ y_label_shift={{ y: 20 }}
687
+ y_format=".2~s"
688
+ y2_format=".2~s"
689
+ y2_label={y_axis_labels.y2}
690
+ y2_label_shift={{ y: 80 }}
691
+ current_x_value={current_step_idx}
692
+ change={handle_plot_change}
693
+ markers="line"
694
+ x_ticks={step_label_positions}
695
+ show_controls
696
+ legend={{
697
+ responsive: true,
698
+ layout: `horizontal`,
699
+ layout_tracks: 3,
700
+ item_gap: 0,
701
+ padding: { t: 5, b: 5, l: 5, r: 5 },
702
+ }}
703
+ padding={{ t: 20, b: 60, l: 100, r: has_y2_series ? 100 : 20 }}
704
+ range_padding={0}
705
+ style="height: 100%"
706
+ {...plot_props}
707
+ >
708
+ {#snippet tooltip({ x, y, metadata })}
709
+ {#if metadata?.series_label}
710
+ Step: {Math.round(x)}<br />
711
+ {@html metadata.series_label}: {typeof y === `number` ? format_num(y) : y}
712
+ {:else}
713
+ Step: {Math.round(x)}<br />
714
+ Value: {typeof y === `number` ? format_num(y) : y}
715
+ {/if}
716
+ {/snippet}
717
+ </ScatterPlot>
718
+ {/if}
719
+ </div>
720
+ {:else}
721
+ <div class="empty-state">
722
+ <div class="drop-zone">
723
+ <h3>Load Trajectory</h3>
724
+ <p>
725
+ Drop a trajectory file here (.xyz, .extxyz, .json, .json.gz, XDATCAR) or provide
726
+ trajectory data via props
727
+ </p>
728
+ <div class="supported-formats">
729
+ <strong>Supported formats:</strong>
730
+ <ul>
731
+ <li>Multi-frame XYZ trajectory files (.xyz, .extxyz)</li>
732
+ <li>Pymatgen trajectory JSON</li>
733
+ <li>Array of structures with metadata</li>
734
+ <li>VASP XDATCAR files</li>
735
+ <li>Compressed files (.gz)</li>
736
+ </ul>
737
+ </div>
738
+ </div>
739
+ </div>
740
+ {/if}
741
+
742
+ <!-- Info Sidebar -->
743
+ {#if trajectory}
744
+ <Sidebar
745
+ {trajectory}
746
+ {current_step_idx}
747
+ {current_filename}
748
+ {current_file_path}
749
+ {file_size}
750
+ {file_object}
751
+ is_open={sidebar_open}
752
+ onclose={() => (sidebar_open = false)}
753
+ />
754
+ {/if}
755
+ </div>
756
+
757
+ <style>
758
+ .trajectory-viewer {
759
+ display: flex;
760
+ flex-direction: column;
761
+ height: 100%;
762
+ width: 100%;
763
+ position: relative;
764
+ min-height: 500px;
765
+ border-radius: 4px;
766
+ border: 2px dashed transparent;
767
+ transition: border-color 0.2s ease;
768
+ box-sizing: border-box;
769
+ overflow: hidden;
770
+ contain: layout;
771
+ }
772
+ .trajectory-viewer:fullscreen {
773
+ height: 100vh !important;
774
+ width: 100vw !important;
775
+ border-radius: 0;
776
+ border: none;
777
+ }
778
+ /* Content area - grid container for equal sizing */
779
+ .content-area {
780
+ display: grid;
781
+ flex: 1;
782
+ min-height: 0;
783
+ overflow: visible;
784
+ }
785
+ .trajectory-viewer.horizontal .content-area {
786
+ grid-template-columns: 1fr 1fr;
787
+ }
788
+ .trajectory-viewer.vertical .content-area {
789
+ grid-template-rows: 1fr 1fr;
790
+ }
791
+ /* When plot is hidden, structure takes full space */
792
+ .content-area.hide-plot {
793
+ grid-template-columns: 1fr !important;
794
+ }
795
+ /* When structure is hidden, plot takes full space */
796
+ .content-area.hide-structure {
797
+ grid-template-columns: 1fr !important;
798
+ grid-template-rows: 1fr !important;
799
+ }
800
+ /* Display mode specific layouts */
801
+ .content-area.show-structure-only {
802
+ grid-template-columns: 1fr !important;
803
+ grid-template-rows: 1fr !important;
804
+ }
805
+ .content-area.show-plot-only {
806
+ grid-template-columns: 1fr !important;
807
+ grid-template-rows: 1fr !important;
808
+ }
809
+ .trajectory-viewer.dragover {
810
+ border-color: var(--trajectory-dragover-border, #007acc);
811
+ background-color: var(--trajectory-dragover-bg, rgba(0, 122, 204, 0.1));
812
+ }
813
+
814
+ .trajectory-controls {
815
+ display: flex;
816
+ align-items: center;
817
+ gap: 1rem;
818
+ padding: 0.5rem;
819
+ background: var(--traj-surface, rgba(45, 55, 72, 0.95));
820
+ backdrop-filter: blur(4px);
821
+ border-bottom: var(--trajectory-border, 1px solid #e1e4e8);
822
+ color: var(--trajectory-text-color, #24292e);
823
+ font-size: 0.8rem;
824
+ }
825
+ .nav-section {
826
+ display: flex;
827
+ align-items: center;
828
+ gap: 0.25rem;
829
+ }
830
+ .nav-button {
831
+ min-width: 28px;
832
+ height: 28px;
833
+ padding: 0.125rem 0.25rem;
834
+ font-size: 0.8rem;
835
+ }
836
+ .step-section {
837
+ display: flex;
838
+ align-items: center;
839
+ gap: 0.5rem;
840
+ flex: 1;
841
+ min-width: 0;
842
+ }
843
+ .step-input {
844
+ border: 1px solid rgba(99, 179, 237, 0.3);
845
+ border-radius: 3px;
846
+ text-align: center;
847
+ margin: 1.5px -5px 0 0;
848
+ }
849
+ .slider-container {
850
+ position: relative;
851
+ flex: 1;
852
+ min-width: 80px;
853
+ }
854
+ .step-slider {
855
+ width: 100%;
856
+ accent-color: var(--traj-accent, #63b3ed);
857
+ }
858
+ .step-labels {
859
+ position: absolute;
860
+ top: 100%;
861
+ left: 0;
862
+ right: 0;
863
+ height: 16px;
864
+ pointer-events: none;
865
+ }
866
+ .step-tick {
867
+ position: absolute;
868
+ transform: translateX(-50%);
869
+ width: 2px;
870
+ height: 4px;
871
+ background: var(--traj-muted, rgba(148, 163, 184, 0.8));
872
+ top: -10px;
873
+ }
874
+ .step-label {
875
+ position: absolute;
876
+ transform: translateX(-50%);
877
+ font-size: 0.65rem;
878
+ color: var(--traj-muted, rgba(148, 163, 184, 0.85));
879
+ white-space: nowrap;
880
+ text-align: center;
881
+ top: -6px;
882
+ }
883
+ .speed-slider {
884
+ width: 90px;
885
+ accent-color: var(--traj-accent, #63b3ed);
886
+ }
887
+ .speed-input {
888
+ width: 45px;
889
+ text-align: center;
890
+ background: var(--traj-bg, rgba(26, 32, 44, 0.8));
891
+ border: var(--traj-border, 1px solid rgba(74, 85, 104, 0.5));
892
+ border-radius: 3px;
893
+ color: var(--traj-text, #e2e8f0);
894
+ font-size: 0.8rem;
895
+ padding: 0.125rem 0.25rem;
896
+ box-sizing: border-box;
897
+ }
898
+ .speed-section {
899
+ display: flex;
900
+ align-items: center;
901
+ gap: 0.25rem;
902
+ color: var(--traj-text, #e2e8f0);
903
+ }
904
+ .filename-section {
905
+ display: flex;
906
+ align-items: center;
907
+ color: var(--traj-text, #e2e8f0);
908
+ }
909
+ .filename-section button {
910
+ white-space: nowrap;
911
+ padding: 0.125rem 0.375rem;
912
+ background: var(--traj-bg, rgba(26, 32, 44, 0.8));
913
+ border-radius: 2px;
914
+ border: var(--traj-border, 1px solid rgba(74, 85, 104, 0.5));
915
+ max-width: 200px;
916
+ overflow: hidden;
917
+ text-overflow: ellipsis;
918
+ cursor: pointer;
919
+ line-height: inherit;
920
+ }
921
+ .display-mode {
922
+ min-width: 28px;
923
+ height: 28px;
924
+ background: var(--trajectory-display-mode-bg, rgba(255, 255, 255, 0.05));
925
+ }
926
+ .display-mode:hover:not(:disabled) {
927
+ background: var(--trajectory-display-mode-hover-bg, #6b7280);
928
+ }
929
+ .fullscreen-button {
930
+ min-width: 28px;
931
+ height: 28px;
932
+ background: var(--trajectory-fullscreen-bg, rgba(255, 255, 255, 0.05));
933
+ }
934
+ .fullscreen-button:hover:not(:disabled) {
935
+ background: var(--trajectory-fullscreen-hover-bg, rgba(255, 255, 255, 0.1));
936
+ }
937
+ .info-button {
938
+ width: 28px;
939
+ height: 28px;
940
+ min-width: 28px;
941
+ border-radius: 50%;
942
+ background: var(--trajectory-info-bg, #4b5563);
943
+ display: flex;
944
+ align-items: center;
945
+ justify-content: center;
946
+ padding: 0;
947
+ }
948
+ .info-button:hover:not(:disabled) {
949
+ background: var(--trajectory-info-hover-bg, #6b7280);
950
+ }
951
+ .info-button.active {
952
+ background: var(--trajectory-info-active-bg, #3b82f6);
953
+ }
954
+ .info-section {
955
+ display: flex;
956
+ align-items: center;
957
+ gap: 0.5rem;
958
+ color: var(--traj-text, #e2e8f0);
959
+ margin-left: auto;
960
+ }
961
+
962
+ .play-button {
963
+ background: var(--trajectory-play-button-bg, #6b7280);
964
+ min-width: 36px;
965
+ font-size: 0.9rem;
966
+ }
967
+ .play-button:hover:not(:disabled) {
968
+ background: var(--trajectory-play-button-hover-bg, #7f8793);
969
+ }
970
+ .play-button.playing {
971
+ background: var(--trajectory-pause-button-bg, #6b7280);
972
+ }
973
+ .play-button.playing:hover:not(:disabled) {
974
+ background: var(--trajectory-pause-button-hover-bg, #9ca3af);
975
+ }
976
+
977
+ .empty-state {
978
+ display: flex;
979
+ align-items: center;
980
+ justify-content: center;
981
+ height: 100%;
982
+ padding: 2rem;
983
+ color: var(--trajectory-text-color, #24292e);
984
+ }
985
+ .drop-zone {
986
+ text-align: center;
987
+ padding: 3rem;
988
+ border: 2px dashed var(--trajectory-dropzone-border, #ccc);
989
+ border-radius: 8px;
990
+ background: var(--trajectory-dropzone-bg, #f9f9f9);
991
+ color: var(--trajectory-text-color, #24292e);
992
+ max-width: 500px;
993
+ }
994
+ .drop-zone h3 {
995
+ color: var(--trajectory-heading-color, var(--trajectory-text-color, #24292e));
996
+ margin-bottom: 1rem;
997
+ }
998
+ .supported-formats {
999
+ margin-top: 1.5rem;
1000
+ text-align: left;
1001
+ }
1002
+ .supported-formats strong {
1003
+ color: var(--trajectory-text-color, #24292e);
1004
+ }
1005
+ .supported-formats ul {
1006
+ margin: 0.5rem 0;
1007
+ padding-left: 1.5rem;
1008
+ }
1009
+ .supported-formats li {
1010
+ color: var(--trajectory-text-muted, #586069);
1011
+ }
1012
+ button {
1013
+ background: var(--traj-border-bg, #4a5568);
1014
+ color: var(--traj-text, #e2e8f0);
1015
+ border: none;
1016
+ border-radius: 4px;
1017
+ padding: 0.25rem 0.5rem;
1018
+ cursor: pointer;
1019
+ font-size: 0.85rem;
1020
+ min-width: 2rem;
1021
+ transition: background-color 0.2s;
1022
+ }
1023
+ button:hover:not(:disabled) {
1024
+ background: var(--traj-surface-hover, #4a5568);
1025
+ }
1026
+ button:disabled {
1027
+ background: var(--traj-text-muted, #a0aec0);
1028
+ color: var(--traj-border-bg, #4a5568);
1029
+ cursor: not-allowed;
1030
+ }
1031
+ /* Hide number input spinner arrows */
1032
+ .trajectory-controls input[type='number']::-webkit-outer-spin-button,
1033
+ .trajectory-controls input[type='number']::-webkit-inner-spin-button {
1034
+ -webkit-appearance: none;
1035
+ margin: 0;
1036
+ }
1037
+ /* Responsive design */
1038
+ @media (max-width: 768px) {
1039
+ .trajectory-viewer.horizontal {
1040
+ flex-direction: column;
1041
+ }
1042
+ .trajectory-controls {
1043
+ flex-wrap: wrap;
1044
+ gap: 0.375rem;
1045
+ }
1046
+ .step-section {
1047
+ order: 1;
1048
+ width: 100%;
1049
+ min-width: 0;
1050
+ }
1051
+ .speed-section {
1052
+ justify-content: center;
1053
+ }
1054
+ .filename-section {
1055
+ order: -1;
1056
+ width: 100%;
1057
+ justify-content: center;
1058
+ }
1059
+ .info-section {
1060
+ margin-left: 0;
1061
+ justify-content: center;
1062
+ gap: 0.375rem;
1063
+ }
1064
+ }
1065
+ @media (max-width: 480px) {
1066
+ .trajectory-controls {
1067
+ padding: 0.125rem 0.375rem;
1068
+ font-size: 0.75rem;
1069
+ gap: 0.25rem;
1070
+ }
1071
+ .nav-button {
1072
+ min-width: 24px;
1073
+ height: 24px;
1074
+ font-size: 0.7rem;
1075
+ }
1076
+ .info-section {
1077
+ flex-direction: column;
1078
+ gap: 0.125rem;
1079
+ }
1080
+ .speed-section {
1081
+ font-size: 0.65rem;
1082
+ }
1083
+ }
1084
+ </style>