Skip to content

Commit ae171d4

Browse files
authored
Allow toggling between histogram and line plot of properties in Trajectory viewer (#85)
* Histogram.svelte with vitest and playwright tests, to be used in trajectory viewer - .gitignore exclude large traj test files - set CSS variables for dark-mode histogram in app.css - split data transformation, scales, formatting, tooltip positioning helpers in src/lib/plot into separate files * pymatgen Structure parsing from nested JSON and enhance tests - add `find_structure_in_json` and `is_valid_structure_object` functions to recursively search for valid structure objects in nested JSON - update `parse_structure_file` to utilize the new functions for JSON parsing - add a new compressed JSON structure file for testing - enhance unit tests in `pbc.test.ts` and `Structure.test.ts` to cover nested JSON handling and validation logic * add StructureSidebar used by Structure.svelte in place of overlaid modal dialog - manage sidebar visibility in Structure.svelte - remove deprecated tips modal from StructureLegend.svelte - enhance structure interaction with keyboard shortcuts for toggling the sidebar - update structure tests to reflect changes in component structure * add Histogram Trajectory.svelte with new display mode dropdown to select between Scatter, Histogram with or without structure + fix trajectory parsing of ASE's binary ULM format - rename Sidebar to TrajectorySidebar * vitest/plot/scales.test.ts: use `test.each` for parameterized tests to cover multiple scenarios efficiently - vitest/plot/ScatterPlot.test.ts: common test data for sample points - vitest/plot/scales.test.ts: update assertions for log scale handling and time scale creation - vitest/trajectory/parse.test.ts: add test for torch-sim HDF5 file parsing * pin deno-version: 2.3.7 in CI * address coderabbit comments * clean up histogram examples to use shared sample-data-generation utilities - move data generation functions from histogram examples to a new `plot-utils.ts` - add stricter trajectory load progress reporting tests * address more coderabbit comments * prevent infinite recursion in nested JSON structure parsing - `find_structure_in_json` handle circular references using a `WeakSet` to prevent infinite recursion - `StructureSidebar.svelte` refactor force magnitude calculation use shared math.ts - `parse.test.ts` add a test for handling deeply nested JSON structures to ensure performance and correctness * fix vitest erroring on missing Canvas API * share tick generation logic between Histogram and ScatterPlot extracted to scales.ts - Histogram.svelte support and test customizable x_ticks and y_ticks props with dynamic updates - Refactored tick generation logic in ScatterPlot.svelte import generate_ticks from scales.ts
1 parent 5e6b874 commit ae171d4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+7421
-5043
lines changed

.github/workflows/gh-pages.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ on:
1010
jobs:
1111
build:
1212
uses: janosh/workflows/.github/workflows/deno-gh-pages.yml@main
13+
with:
14+
deno-version: 2.3.7

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ jobs:
1616
test-cmd: deno task vitest
1717
e2e-install-cmd: npx playwright install chromium
1818
e2e-test-cmd: npx playwright test
19+
deno-version: 2.3.7

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ coverage
1616
# auto-generated
1717
src/types
1818

19-
# coverage
20-
test-results
19+
# coverage and large test files
20+
test-results*
21+
src/site/trajectories/large
2122

2223
# vscode extension
2324
*.vsix

deno.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"preview": "deno run -A --node-modules-dir npm:vite preview",
66
"serve": "deno task build && deno task preview",
77
"test": "deno task vitest && deno task e2e",
8-
"vitest": "deno run -A --node-modules-dir npm:vitest run tests/vitest/**/*.ts",
8+
"vitest": "deno run -A --node-modules-dir npm:vitest",
99
"e2e": "npx playwright test",
1010
"test:install": "npx playwright install chromium",
1111
"package": "svelte-package",

extensions/vscode/tests/extension.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ describe(`MatterViz Extension`, () => {
375375
}
376376
const nonces = new Set<string>()
377377

378-
for (let i = 0; i < 1000; i++) {
378+
for (let idx = 0; idx < 1000; idx++) {
379379
const html = create_html(
380380
mock_webview as vscode.Webview,
381381
mock_context as vscode.ExtensionContext,

src/app.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
/* darken browser form controls like input[type='color'] */
2222
color-scheme: dark;
2323
--max-text-width: 40em;
24+
25+
/* Histogram dark mode colors */
26+
--histogram-axis-color: #e5e5e5;
27+
--histogram-text-color: #e5e5e5;
28+
--histogram-tooltip-bg: rgba(60, 60, 60, 0.95);
29+
--histogram-tooltip-color: white;
30+
--histogram-tooltip-border: rgba(255, 255, 255, 0.2);
2431
}
2532
body {
2633
background: var(--page-bg);

src/lib/icons.ts

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -88,27 +88,13 @@ export const icon_data = {
8888
path:
8989
`M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4M9 18c-4.51 2-5-2-7-2`,
9090
},
91-
Contact: {
92-
viewBox: `0 0 24 24`,
93-
width: `24`,
94-
height: `24`,
95-
path:
96-
`M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 7l-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7`,
97-
},
9891
Changelog: {
9992
viewBox: `0 0 24 24`,
10093
width: `24`,
10194
height: `24`,
10295
path:
10396
`M13 3a9 9 0 0 0-9 9H1l4 4l4-4H6c0-3.87 3.13-7 7-7s7 3.13 7 7s-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0 0-18m-1 5v5l4.25 2.52l.77-1.28l-3.52-2.09V8z`,
10497
},
105-
GitHubMark: {
106-
viewBox: `0 0 16 16`,
107-
width: `24`,
108-
height: `24`,
109-
path:
110-
`M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z`,
111-
},
11298
Info: {
11399
viewBox: `0 0 24 24`,
114100
width: `24`,
@@ -123,20 +109,6 @@ export const icon_data = {
123109
path:
124110
`M14 21v-1.65q0-.2.075-.387t.225-.338l5.225-5.2q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.2 5.2q-.15.15-.337.225T16.65 22H15q-.425 0-.712-.287T14 21m7.5-5.575l-.925-.925zm-6 5.075h.95l3.025-3.05l-.925-.925l-3.05 3.025zM6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762v1.425q0 .425-.288.713T19 11.25t-.712-.288T18 10.25V9h-4q-.425 0-.712-.288T13 8V4H6v16h5q.425 0 .713.288T12 21t-.288.713T11 22zm0-2V4zm13.025-3.025l-.475-.45l.925.925z`,
125111
},
126-
Mail: {
127-
viewBox: `0 0 24 24`,
128-
width: `24`,
129-
height: `24`,
130-
path:
131-
`M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z M22 6l-10 7L2 6`,
132-
},
133-
Globe: {
134-
viewBox: `0 0 24 24`,
135-
width: `24`,
136-
height: `24`,
137-
path:
138-
`M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z`,
139-
},
140112
CheckCircle: {
141113
viewBox: `0 0 24 24`,
142114
width: `24`,
@@ -498,6 +470,14 @@ export const icon_data = {
498470
path:
499471
`M2 2h2v18h18v2H2zm7 8a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m4-8a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m5 10a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3`,
500472
},
473+
Histogram: { // IconPark Solid by ByteDance
474+
viewBox: `0 0 48 48`,
475+
width: `24`,
476+
height: `24`,
477+
stroke: `currentColor`,
478+
fill: `currentColor`,
479+
path: `M4 42h40z M4 42h40 M8 28h6v14H8zm13-10h6v24h-6zM34 6h6v36h-6z`,
480+
},
501481
} as const
502482

503483
export type IconName = keyof typeof icon_data

src/lib/io/parse.ts

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { elem_symbols, type ElementSymbol, type Site, type Vec3 } from '$lib'
22
import type { Matrix3x3 } from '$lib/math'
33
import * as math from '$lib/math'
4+
import type { AnyStructure } from '$lib/structure'
45
import { load as yaml_load } from 'js-yaml'
56

67
// Check if filename indicates a trajectory file
@@ -844,6 +845,74 @@ export function parse_phonopy_yaml(
844845
}
845846
}
846847

848+
// Recursively search for a valid structure object in nested JSON
849+
function find_structure_in_json(
850+
obj: unknown,
851+
visited = new WeakSet(),
852+
): ParsedStructure | null {
853+
// Check if current object is null or undefined
854+
if (obj === null || obj === undefined) {
855+
return null
856+
}
857+
858+
// If it's not an object, skip it
859+
if (typeof obj !== `object`) {
860+
return null
861+
}
862+
863+
// Check for circular references
864+
if (visited.has(obj)) return null
865+
visited.add(obj)
866+
867+
// If it's an array, search through each element
868+
if (Array.isArray(obj)) {
869+
for (const item of obj) {
870+
const result = find_structure_in_json(item, visited)
871+
if (result) return result
872+
}
873+
return null
874+
}
875+
876+
// Check if this object looks like a valid structure
877+
const potential_structure = obj as Record<string, unknown>
878+
if (is_valid_structure_object(potential_structure)) {
879+
return potential_structure as unknown as ParsedStructure
880+
}
881+
882+
// Otherwise, recursively search through all properties
883+
for (const value of Object.values(potential_structure)) {
884+
const result = find_structure_in_json(value, visited)
885+
if (result) return result
886+
}
887+
888+
return null
889+
}
890+
891+
// Check if an object looks like a valid structure
892+
function is_valid_structure_object(obj: Record<string, unknown>): boolean {
893+
// Must have sites array
894+
if (!obj.sites || !Array.isArray(obj.sites)) {
895+
return false
896+
}
897+
898+
// Sites array must not be empty and contain valid site objects
899+
if (obj.sites.length === 0) {
900+
return false
901+
}
902+
903+
// Check if first site looks valid (has species and coordinates)
904+
const first_site = obj.sites[0] as Record<string, unknown>
905+
if (!first_site || typeof first_site !== `object`) {
906+
return false
907+
}
908+
909+
// Must have species (array) and either abc or xyz coordinates
910+
const has_species = Array.isArray(first_site.species) && first_site.species.length > 0
911+
const has_coordinates = Array.isArray(first_site.abc) || Array.isArray(first_site.xyz)
912+
913+
return has_species && has_coordinates
914+
}
915+
847916
// Auto-detect file format and parse accordingly
848917
export function parse_structure_file(
849918
content: string,
@@ -867,13 +936,13 @@ export function parse_structure_file(
867936
return parse_cif(content)
868937
}
869938

870-
// JSON files (pymatgen structures) - just parse directly
939+
// JSON files (pymatgen structures) - parse and search for nested structures
871940
if (ext === `json`) {
872941
try {
873942
const parsed = JSON.parse(content)
874-
// Validate that it has the required structure format
875-
if (parsed.sites && Array.isArray(parsed.sites)) {
876-
return parsed as ParsedStructure
943+
const structure = find_structure_in_json(parsed)
944+
if (structure) {
945+
return structure
877946
}
878947
console.error(`JSON file does not contain a valid structure format`)
879948
return null
@@ -903,9 +972,10 @@ export function parse_structure_file(
903972
// JSON format detection: try to parse as JSON first
904973
try {
905974
const parsed = JSON.parse(content)
906-
// If it parses as JSON, validate that it's a structure
907-
if (parsed.sites && Array.isArray(parsed.sites)) {
908-
return parsed as ParsedStructure
975+
// If it parses as JSON, search for a valid structure
976+
const structure = find_structure_in_json(parsed)
977+
if (structure) {
978+
return structure
909979
}
910980
} catch {
911981
// Not JSON, continue with other format detection
@@ -973,3 +1043,44 @@ export function parse_structure_file(
9731043
console.error(`Unable to determine file format`)
9741044
return null
9751045
}
1046+
1047+
// Universal parser that handles JSON and structure files
1048+
export function parse_any_structure(
1049+
content: string,
1050+
filename: string,
1051+
): AnyStructure | null {
1052+
// Try JSON first, but handle nested structures properly
1053+
try {
1054+
const parsed = JSON.parse(content)
1055+
1056+
// Check if it's already a valid structure
1057+
if (parsed.sites && Array.isArray(parsed.sites)) {
1058+
return parsed as AnyStructure
1059+
}
1060+
1061+
// If not, use parse_structure_file to find nested structures
1062+
const structure = parse_structure_file(content, filename)
1063+
1064+
if (structure) {
1065+
return {
1066+
sites: structure.sites,
1067+
charge: 0,
1068+
...(structure.lattice && {
1069+
lattice: { ...structure.lattice, pbc: [true, true, true] },
1070+
}),
1071+
}
1072+
} else return null
1073+
} catch {
1074+
// Try structure file formats
1075+
const parsed = parse_structure_file(content, filename)
1076+
return parsed
1077+
? {
1078+
sites: parsed.sites,
1079+
charge: 0,
1080+
...(parsed.lattice && {
1081+
lattice: { ...parsed.lattice, pbc: [true, true, true] },
1082+
}),
1083+
}
1084+
: null
1085+
}
1086+
}

src/lib/math.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,5 @@ export function cell_to_lattice_matrix(
283283
[c1, c2, c3],
284284
]
285285
}
286+
287+
export const LOG_MIN_EPS = 1e-9 // Constants

src/lib/plot/ColorBar.svelte

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
2-
import { format_num, LOG_MIN_EPS } from '$lib'
2+
import { format_num } from '$lib'
33
import { luminance } from '$lib/labels'
4+
import * as math from '$lib/math'
45
import { format } from 'd3-format'
56
import * as d3 from 'd3-scale'
67
import * as d3_sc from 'd3-scale-chromatic'
@@ -107,9 +108,9 @@
107108
use_log_for_ticks = false
108109
} else if (scale_min <= 0) {
109110
console.warn(
110-
`Log scale received non-positive min value (${scale_min}) for ticks. Using epsilon=${LOG_MIN_EPS} instead.`,
111+
`Log scale received non-positive min value (${scale_min}) for ticks. Using epsilon=${math.LOG_MIN_EPS} instead.`,
111112
)
112-
scale_min = LOG_MIN_EPS // Substitute with epsilon
113+
scale_min = math.LOG_MIN_EPS // Substitute with epsilon
113114
}
114115
}
115116
@@ -164,13 +165,13 @@
164165
165166
// Ensure domain endpoints are included if they are powers of 10 and missed by loop
166167
if (
167-
Math.abs(Math.log10(nice_min) % 1) < LOG_MIN_EPS &&
168+
Math.abs(Math.log10(nice_min) % 1) < math.LOG_MIN_EPS &&
168169
!power_of_10_ticks.includes(nice_min)
169170
) {
170171
power_of_10_ticks.unshift(nice_min)
171172
}
172173
if (
173-
Math.abs(Math.log10(nice_max) % 1) < LOG_MIN_EPS &&
174+
Math.abs(Math.log10(nice_max) % 1) < math.LOG_MIN_EPS &&
174175
!power_of_10_ticks.includes(nice_max)
175176
) {
176177
power_of_10_ticks.push(nice_max)
@@ -257,9 +258,9 @@
257258
use_log_fallback = false
258259
} else if (min_val <= 0) {
259260
console.warn(
260-
`Log scale received non-positive min value (${min_val}) for fallback scale. Using epsilon=${LOG_MIN_EPS} instead.`,
261+
`Log scale received non-positive min value (${min_val}) for fallback scale. Using epsilon=${math.LOG_MIN_EPS} instead.`,
261262
)
262-
min_val = LOG_MIN_EPS // Substitute with epsilon
263+
min_val = math.LOG_MIN_EPS // Substitute with epsilon
263264
}
264265
}
265266
@@ -294,9 +295,9 @@
294295
use_log_interp = false
295296
} else if (min_ramp_domain <= 0) {
296297
console.warn(
297-
`Log scale specified for gradient, but min domain value (${min_ramp_domain}) is not positive. Using epsilon=${LOG_MIN_EPS} instead.`,
298+
`Log scale specified for gradient, but min domain value (${min_ramp_domain}) is not positive. Using epsilon=${math.LOG_MIN_EPS} instead.`,
298299
)
299-
adjusted_min_ramp = LOG_MIN_EPS // Substitute with epsilon
300+
adjusted_min_ramp = math.LOG_MIN_EPS // Substitute with epsilon
300301
}
301302
}
302303

0 commit comments

Comments
 (0)