Hooking Up to External APIs
OPTIMADE Example
The OPTIMADE standard gives a common API over 50+ materials databases. The demo below fetches and renders a structure from any OPTIMADE provider:
Providers (0)
Loading providers...
Source Code
OptimadeStructureViewer.svelte
svelte
<script lang="ts"> import { sanitize_html } from '$lib/sanitize' import { page } from '$app/state' import { Icon } from 'svelte-widgets' import { Database, Globe, Link } from 'svelte-widgets/icons' import { decode_structure_id, detect_provider_from_slug, encode_structure_id, fetch_optimade_providers, fetch_optimade_structure, fetch_suggested_structures, } from '$lib/api/optimade' import type { OptimadeProvider, OptimadeStructure } from '$lib/api/optimade' import { Composition, get_electro_neg_formula } from '$lib/composition' import { Structure } from '$lib/structure' import type { Crystal } from '$lib/structure' import { optimade_to_crystal } from '$lib/structure/parse' import { untrack } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import { tooltip } from 'svelte-widgets/attachments' let { structure_id: init_structure_id, selected_provider: init_provider, ...rest }: { structure_id?: string selected_provider?: string } & HTMLAttributes<HTMLDivElement> = $props() let structure = $state<Crystal | null>(null) let [loading_struct, loading_suggestions] = $state([false, false]) let struct_error = $state<string | null>(null) let available_providers = $state<OptimadeProvider[]>([]) let providers_error = $state<string | null>(null) // Using $state with untrack() - these are initialized from props but mutated by user interactions let selected_db = $state(untrack(() => init_provider ?? `mp`)) let input_value = $state(untrack(() => init_structure_id ?? ``)) let suggested_structures = $state<OptimadeStructure[]>([]) let last_loaded_db = $state<string | null>(null) let structure_id = $derived(input_value.trim()) let provider_config = $derived( available_providers.find((provider) => provider.id === selected_db), ) $effect(() => { // Initialize from URL slug (only if no props provided) if (init_structure_id || init_provider) return // Props take precedence const decoded_slug = decode_structure_id(page.params.slug ?? ``) if (available_providers.length > 0) { const provider = detect_provider_from_slug(decoded_slug, available_providers) if (provider) { selected_db = provider input_value = decoded_slug.startsWith(`${provider}-`) ? decoded_slug : `${provider}-${decoded_slug}` } else input_value = decoded_slug } }) $effect(() => { // Load providers on mount load_providers() }) // Load data when database or structure ID changes $effect(() => { if (selected_db && available_providers.length > 0) { // Only load suggested structures when switching to different database // prevents refetching when navigating between structures within same database if (last_loaded_db !== selected_db) { load_suggested_structures() last_loaded_db = selected_db } } if (structure_id && selected_db) load_structure_data() }) async function load_providers() { providers_error = null available_providers = await fetch_optimade_providers().catch((err) => { console.error(`Failed to load providers:`, err) providers_error = `Failed to load providers. Click Retry to try again.` return [] }) } // Every keystroke starts a fetch; only the latest may write back, else a slow older // response lands on top of a newer structure let structure_request_id = 0 async function load_structure_data() { const request_id = ++structure_request_id const requested_id = structure_id loading_struct = true struct_error = null let error: string | null = null const data = await fetch_optimade_structure( requested_id, selected_db, available_providers, ).catch((err) => { error = `Failed to load structure: ${err}` return null }) if (request_id !== structure_request_id) return if (data) { structure = optimade_to_crystal(data) if (!structure) error = `Failed to convert structure data` } else error ??= `Structure ${requested_id} not found` struct_error = error loading_struct = false } async function load_suggested_structures() { loading_suggestions = true suggested_structures = await fetch_suggested_structures( selected_db, available_providers, 12, ) loading_suggestions = false } function navigate_to_structure(id: string) { input_value = id history.pushState({}, ``, `/optimade-${encode_structure_id(id)}`) } </script> <div class="input-section"> <input class="structure-input" placeholder="Enter structure ID" bind:value={input_value} onkeydown={(event) => { if (event.key === `Enter`) navigate_to_structure(structure_id) }} /> <button class="fetch-button" onclick={() => navigate_to_structure(structure_id)} disabled={loading_struct || !structure_id} > {loading_struct ? `Loading...` : `Fetch`} </button> </div> <div class="main-layout full-bleed" {...rest}> <div class="db-column"> <h3> Providers <span style="font-weight: lighter">({available_providers.length})</span> </h3> {#if providers_error} <div class="error-message"> <p>{providers_error}</p> <button class="retry-button" onclick={load_providers}>Retry</button> </div> {:else if available_providers.length === 0} <p>Loading providers...</p> {:else} <div class="db-grid"> {#each available_providers as { id, attributes } (id)} <div class:selected={id === selected_db}> <button class="db-select" {@attach tooltip({ allow_html: true, content: sanitize_html(attributes.name) })} onclick={() => { selected_db = id input_value = `` }} > <Icon icon={Database} /> {id} </button> <a href={attributes.base_url} title="API" target="_blank" rel="noopener noreferrer" > <Icon icon={Link} /> </a> {#if attributes.homepage} <a href={attributes.homepage} title="Home" target="_blank" rel="noopener noreferrer" > <Icon icon={Globe} /> </a> {/if} </div> {/each} </div> {/if} </div> <div class="suggestions-column"> {#if suggested_structures.length > 0} <h3> Suggested Structures <span style="font-weight: lighter">({suggested_structures.length})</span> </h3> {#if loading_suggestions} <p>Loading...</p> {:else} <div class="structure-suggestions"> {#each suggested_structures as struct (struct.id)} {@const formula = get_electro_neg_formula( struct.attributes.chemical_formula_descriptive ?? ``, )} <button onclick={() => navigate_to_structure(struct.id)}> <span style="font-family: monospace">{struct.id}</span> {#if formula} <span style="font-weight: lighter">{@html sanitize_html(formula)}</span> {/if} {#if struct.attributes.chemical_formula_descriptive} <Composition composition={struct.attributes.chemical_formula_descriptive} mode="pie" style="min-height: 80px; height: 80px; grid-row: 1/span 2; grid-column: 2" /> {/if} </button> {/each} </div> {/if} {/if} </div> <div class="structure-column"> {#if struct_error} <div class="error-message"> <p>{struct_error}</p> </div> {/if} {#if loading_struct} <p>Loading structure from {provider_config?.attributes.name}...</p> {/if} {#if structure} <h2 style="margin: 0 2pt 10pt"> {@html sanitize_html(get_electro_neg_formula(structure))} {#if structure_id} <span>({structure_id})</span> {/if} </h2> <Structure {structure} style="height: 100%" /> {/if} </div> </div> <style> .input-section { display: flex; gap: 0.5em; margin-bottom: 1.5em; justify-content: center; } .structure-input { flex: 1; max-width: 400px; padding: 0.4em 0.6em; font-size: 0.95em; border-radius: 4pt; border: 1px solid var(--border-color); background: var(--surface-bg); } .fetch-button { padding: 0.4em 0.8em; font-size: 0.95em; border-radius: 4pt; border: 1px solid var(--border-color); background: var(--btn-bg); } .fetch-button:hover { background: var(--btn-bg-hover); } .main-layout { display: grid; grid-template-columns: minmax(250px, 280px) minmax(280px, 320px) 1fr; gap: clamp(1em, 2vw, 1.5em); max-height: 80vh; } .db-column, .suggestions-column, .structure-column { max-height: inherit; } .db-column h3, .suggestions-column h3 { margin: 0 0 0.75em; padding: 0.5em 0 0 0; } .db-grid { display: grid; gap: 6pt; overflow-y: auto; height: 100%; } .db-grid div { display: flex; align-items: center; gap: 6pt; padding: 0.3em 0.5em; border: 1px solid var(--border-color); border-radius: 4pt; } .db-grid div:hover { background: var(--btn-bg-hover); } .db-grid div.selected { border: 1px solid var(--accent-color); } .db-select { display: flex; align-items: center; gap: 6pt; background: none; font: inherit; flex: 1; } .db-grid a { padding: 2pt; border-radius: 3pt; font-size: 0.9em; } .db-grid a:hover { background: var(--btn-bg-hover); } .structure-suggestions { display: grid; gap: 0.5em; overflow-y: auto; height: 100%; } .structure-suggestions button { display: grid; justify-content: space-between; gap: 0.75em; align-items: center; padding: 0.5em 0.75em; border: 1px solid var(--border-color); border-radius: 4pt; background: none; font: inherit; text-align: left; } .structure-suggestions button:hover { background: var(--btn-bg-hover); } .structure-column h2 span { font-weight: lighter; color: var(--text-color-muted); } .error-message { text-align: center; color: #ff6b6b; margin: 1em 0; } .retry-button { padding: 0.4em 0.8em; font-size: 0.9em; border-radius: 4pt; border: 1px solid var(--border-color); background: var(--btn-bg); } @media (max-width: 1250px) { .main-layout { grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; } .structure-column { grid-column: 1 / -1; order: -1; } } </style>