Skip to content

Commit

Permalink
make initial camera_position responsive to crystal size
Browse files Browse the repository at this point in the history
rename prop zoom -> initial_zoom
add props min_zoom, max_zoom, orbit_controls
add button to download structure as JSON
  • Loading branch information
janosh committed May 29, 2023
1 parent 7f3fb5c commit 5b1e82a
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 26 deletions.
63 changes: 50 additions & 13 deletions src/lib/Structure.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
<script lang="ts">
import { Canvas, OrbitControls, T } from '@threlte/core'
import { Tooltip } from 'svelte-zoo'
import Icon from './Icon.svelte'
import StructureLegend from './StructureLegend.svelte'
import { element_color_schemes } from './colors'
import { element_colors } from './stores'
import type { PymatgenStructure } from './structure'
import { atomic_radii, get_elements } from './structure'
import {
alphabetical_formula,
atomic_radii,
get_elements,
type PymatgenStructure,
} from './structure'
// output of pymatgen.core.Structure.as_dict()
export let structure: PymatgenStructure | undefined = undefined
Expand All @@ -17,7 +22,10 @@
// initial camera position from which to render the scene
export let camera_position: [number, number, number] = [10, 10, 10]
// zoom level of the camera
export let zoom: number = 1 / 50
export let initial_zoom: number | undefined = undefined
export let orbit_controls: OrbitControls | undefined = undefined
export let max_zoom: number | undefined = undefined
export let min_zoom: number | undefined = undefined
// zoom speed. set to 0 to disable zooming.
export let zoom_speed: number = 0.3
// pan speed. set to 0 to disable panning.
Expand All @@ -33,9 +41,9 @@
// whether to show the structure's lattice cell as a wireframe
export let show_cell: 'surface' | 'wireframe' | null = 'wireframe'
// the control panel DOM element
export let controls: HTMLElement | null = null
export let controls: HTMLElement | undefined = undefined
// the button to toggle the control panel
export let toggle_controls_btn: HTMLButtonElement | null = null
export let toggle_controls_btn: HTMLButtonElement | undefined = undefined
// cell opacity
export let cell_opacity: number | undefined = undefined
// whether to show the lattice vectors
Expand All @@ -61,7 +69,10 @@
}
$: ({ a, b, c } = structure?.lattice ?? { a: 0, b: 0, c: 0 })
$: {
const scale = initial_zoom ?? 2400 / (width + height)
camera_position = [scale * a, 0.5 * scale * b, scale * c]
}
const on_window_click =
(node: (HTMLElement | null)[], cb: () => void) => (event: MouseEvent) => {
if (!node || !event.target) return // ignore invalid input
Expand All @@ -70,16 +81,30 @@
cb() // invoke callback
}
const initial_zoom = zoom
let orbit_controls: OrbitControls
$: orbit_controls?.saveState() // record orbit target for reset
$: visible_buttons =
reveal_buttons == true ||
(typeof reveal_buttons == 'number' && reveal_buttons < width)
const reset_camera = () => {
zoom = initial_zoom
orbit_controls?.reset()
// Function to download data to a file
function download(data: string, filename: string, type: string) {
const file = new Blob([data], { type: type })
const anchor = document.createElement('a')
const url = URL.createObjectURL(file)
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
function download_json() {
if (!structure) alert('No structure to download')
const data = JSON.stringify(structure, null, 2)
const filename = structure?.id
? `${structure?.id} (${alphabetical_formula(structure)}).json`
: `${alphabetical_formula(structure)}.json`
download(data, filename, 'application/json')
}
</script>

Expand All @@ -100,7 +125,9 @@
>
<div class="controls" class:visible={hovered || visible_buttons}>
<section>
<button class="reset-camera" on:click={reset_camera}>{reset_text}</button>
<!-- TODO show only when camera was moved -->
<button class="reset-camera" on:click={orbit_controls?.reset}>{reset_text}</button
>
<button
on:click={() => (controls_open = !controls_open)}
bind:this={toggle_controls_btn}
Expand Down Expand Up @@ -158,18 +185,24 @@
{/each}
</select>
</label>
<button type="button" on:click={download_json} title="Download Structure as JSON">
<Icon icon="mdi:download" />
Download Structure as JSON
</button>
</form>
</div>

<Canvas>
<T.PerspectiveCamera makeDefault position={camera_position} fov={1 / zoom}>
<T.PerspectiveCamera makeDefault position={camera_position}>
<OrbitControls
enableZoom={zoom_speed > 0}
zoomSpeed={zoom_speed}
enablePan={pan_speed > 0}
panSpeed={pan_speed}
target={{ x: a / 2, y: b / 2, z: c / 2 }}
bind:controls={orbit_controls}
maxZoom={max_zoom}
minZoom={min_zoom}
/>
</T.PerspectiveCamera>

Expand Down Expand Up @@ -272,6 +305,10 @@
max-width: var(--controls-max-width, 90cqw);
margin: var(--controls-margin, 1ex 0 0 0);
}
.controls > form > button {
width: max-content;
background-color: rgba(255, 255, 255, 0.4);
}
.controls > form.open {
visibility: visible;
opacity: 1;
Expand Down
4 changes: 3 additions & 1 deletion src/site/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { PymatgenStructure } from '../lib'

export { default as DemoNav } from './DemoNav.svelte'
export { default as Footer } from './Footer.svelte'

export const structures = Object.entries(
import.meta.glob(`./structures/*.json`, {
eager: true,
import: `default`,
})
}) as Record<string, PymatgenStructure>
).map(([path, structure]) => {
const id = path.split(`/`).at(-1)?.split(`.`)[0] as string
structure.id = id
Expand Down
34 changes: 22 additions & 12 deletions tests/unit/Structure.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,43 @@
import { Structure } from '$lib'
import { structures } from '$site'
import { tick } from 'svelte'
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { doc_query } from '.'

const structure = structures[0]

describe(`Structure`, () => {
test(`open control panel when clicking toggle button`, async () => {
new Structure({
target: document.body,
props: { structure: structures[0] },
})
new Structure({ target: document.body, props: { structure } })

doc_query<HTMLButtonElement>(`button.controls-toggle`).click()
await tick()

const form = doc_query<HTMLDivElement>(`div.controls form`)
expect(form).not.toBe(null)
})

test(`reset view button should reset zoom`, async () => {
const component = new Structure({
test(`JSON file download when clicking download button`, async () => {
window.URL.createObjectURL = vi.fn()

new Structure({
target: document.body,
props: { structure: structures[0] },
props: { structure },
})
const spy = vi.spyOn(document.body, `appendChild`)

component.zoom = 0.5
doc_query<HTMLButtonElement>(
`button[title='Download Structure as JSON']`
).click()
await tick()

expect(component.zoom).toBe(0.5)
doc_query<HTMLButtonElement>(`button.reset-camera`).click()
expect(window.URL.createObjectURL).toHaveBeenCalledOnce()
expect(window.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(spy).toHaveBeenCalledOnce()
expect(spy).toHaveBeenCalledWith(expect.any(HTMLAnchorElement))

expect(component.zoom).toBe(1 / 50)
spy.mockRestore()
// @ts-expect-error - function is mocked
window.URL.createObjectURL.mockRestore()
})
})

0 comments on commit 5b1e82a

Please sign in to comment.