diff --git a/src/lib/components/home/HowItWorks.svelte b/src/lib/components/home/HowItWorks.svelte new file mode 100644 index 0000000..29ba43e --- /dev/null +++ b/src/lib/components/home/HowItWorks.svelte @@ -0,0 +1,167 @@ + + + + +
+

How it works

+

Three steps from an empty query to a downloaded file

+ +
+ +{#if open} +
+ {#each STEPS as step, index (step.number)} + +
+ {step.number} +

{step.title}

+
+

{step.body}

+
+ + {#if index < STEPS.length - 1} + + {/if} + {/each} +
+{/if} + + diff --git a/src/lib/components/home/QuickStartExamples.svelte b/src/lib/components/home/QuickStartExamples.svelte new file mode 100644 index 0000000..00c9338 --- /dev/null +++ b/src/lib/components/home/QuickStartExamples.svelte @@ -0,0 +1,221 @@ + + + + +
+

Quick start examples

+

Three real queries. Open one to review the query in the query builder and plot it on the map.

+ + + +
+ +
+ {#each $homeExamples as example (example.title)} + + The result of the {example.title} query in the Map Viewer + +
+
+ + {example.sourceName} + {example.tableName} +
+ +

{example.title}

+

{example.description}

+ +
+ {example.rows.toLocaleString()} rows + {example.seconds} s + {example.format} +
+ +
+ + + + + +
+
+
+ {/each} +
+ + diff --git a/src/lib/data/home-examples.default.json b/src/lib/data/home-examples.default.json new file mode 100644 index 0000000..cff8b52 --- /dev/null +++ b/src/lib/data/home-examples.default.json @@ -0,0 +1,35 @@ +[ + { + "title": "Trans-equatorial Atlantic transect", + "description": "Temperature, salinity and oxygen in a 500 km wide cross section at 24°W, from 32°S to 34°N.", + "sourceName": "World Ocean Database", + "tableName": "easy-wod", + "image": "HomeQueryExample1-small.jpg", + "rows": 1428571, + "seconds": 4.45, + "format": "Parquet", + "shareQuery": "?query=H4sIAAAAAAAAA5VUTW%2BjMBD9K5XPQP2BAee2%2F2APu6coirzETS0Zm5pBWxTlv69xCJsmFaU3y%2B%2FNvJl5Y5%2FQW6%2F8gDYn9OJdgzZIyW5I%2F7oDSi7QvpVeNgqU79Bme0K1M31jAxF0owJJGi0DYntjzskNbJw9augPixwJX1FANa3yEnq%2FxOoCYDUMCxT3PhyVXSAcVAuvd%2FguQS%2FazL3rbm8d7EcoTsz5%2Fd2MrnM5j6k%2Fx28H0%2BignDIcTvI9nAhdiPs%2FrLswFqPWFXczzoUaP049yl3FOF4tNruyoHTjXJSZm8rX60zWLqjM5n9oheH1GpftiBKzgV%2BYezVs2cujcuH28gRhaFVAfzozHN1Ybu2cP2grQY0LuN2mtMgKwSrCRU5JWRFSJCnhGedCiJIyzgnGgu6SwCwzzAnJWcmryCwTijNMSyaIYDSvhCAsEmkILxgNaVlFSVUVkVjSnLOyYDHjxCNCYEx4UUYev0iTXDCe43zMiKvIXFfkbrf0Su63%2FTHB5OJDsd95ep9MaXqKj%2B2Ov4Hroe1hWpNGhhMKAkEJxt1ANkiFq19e2i5Vb70E57U0Tz%2FASAu6foIRUTWEArTtQNpa%2FfZh7dArQNttnp%2F%2FKFk7O37AWSO97jJr0Pkfv3hREqYFAAA%3D" + }, + { + "title": "Global temperature, summer 2025", + "description": "Daily maximum air temperature at 2 m across the whole grid, from 12 to 16 July 2025.", + "sourceName": "ERA5 reanalysis", + "tableName": "era5_daily_max_2m_temperature", + "image": "HomeQueryExample2-small.jpg", + "rows": 2000000, + "seconds": 1.477, + "format": "Parquet", + "shareQuery": "?query=H4sIAAAAAAAAA4WRTWrEMAyFrxK0TmbSwLSQC%2FQCnU1LMUritAbJThW5zBBy99pTKCGLKXgh9D7r6WeBr2jlCu0CowSGFqzgyQzo6GoYL6Zho5anlNUoFspf3kwoyFatzNC%2BLdAHiuzT728kNxh1nNEUY9J9JFrLDUSoTuNwFwn%2B4z9GG76j%2BsidlR3wXsLo6K%2FtMYjZzbOfgV0u1tTNqaqfqofmpa7b23vNIl624uNGzFYh6hT1ttsgjCmC5JMMFda1BJ8cU%2BqZQodUbLZcFnNktlLkysnG%2BVnR9%2FYslPhP1Wluj8fOYh98le91YBQ3HzzB%2BgOZZzjm0QEAAA%3D%3D" + }, + { + "title": "Mediterranean Basin", + "description": "Temperature, salinity and oxygen inside a 14 point polygon that covers the whole basin.", + "sourceName": "World Ocean Database", + "tableName": "easy-wod", + "image": "HomeQueryExample3-small.jpg", + "rows": 1428571, + "seconds": 2.318, + "format": "Parquet", + "shareQuery": "?query=H4sIAAAAAAAAA41Uy27bMBD8lYJnmSG5fPqYe4FeejIMg7UZR4BEuhKFxjD8713KjypBqvgkgTO7M7sc6UR%2BD6E7kuWJvHSpJUsSfH9c%2FEk7Ul2gzcF3vg05dD1Zrk5km5qhjUjMdRuQ5JvaIxKHpjlXE7hJcV%2FnYTfL8fkrSg7tIXQ%2BD90cq0cg1vk4Q0lvx32IM4RdOOTXD%2Fi6Ii91c5%2B97jcx5U2Bxo2lbvNhR7e9nEvrx%2BiTAceqr2kVaWt0zPDp38hSsYfF7nuaUZrscpSBm458XOe67BmV%2B3W8GwXY4xqX%2Bxol7ln7hDbN4S1vn9H%2BRXEfEp5ePop8PAREf6TmuE%2FF7jalbldHn0OJxGq1UNRIzhxwpipQVDDBmJEg1tVqIankEqQSSlcgKRfSKQQNYopqK4yw1ulSpsEIpyUDhCw1VgpuNYMKgHKmuRNcO4Q4MrGBU1glHNWCceOksQgJTiVwyQQwWwErRrSSjBUMBRj6cJYLW%2BqsckoZbnTBDA5gtbXSoBNNtRIOW7oyAFKVs1pZBbaSnDKwxVpBAP3bAkpTSYGj8bIFJi8mwaKaAYNVmuIk3AqhxTibFALPOZgCMa2NFkbxUkZBMi25EWNHa0GhJ1U2gkvWRnDDlEWPhoJCQDrQF%2Bw%2FF7Bez%2BRvct9jAqfbvOV9Yn2m0SRfY6d3hq6xnuy4%2FFPSkA9Dvka79fhGsCX2ziXPJGJzPPoedjVqdD4GH789%2B74uAaxjn33chp8dfhvkNedDv3x6%2BhX8NsXy36at7%2Bqexoac%2FwKLNbwO3QUAAA%3D%3D" + } +] diff --git a/src/lib/data/home-examples.ts b/src/lib/data/home-examples.ts new file mode 100644 index 0000000..56a4c3e --- /dev/null +++ b/src/lib/data/home-examples.ts @@ -0,0 +1,121 @@ +/** + * The Quick start examples on the home page. + * + * `home-examples.default.json` seeds `localStorage` on the very first launch. + * `loadHomeExamples` then fetches {@link HOME_EXAMPLES_URL} and overwrites + * `localStorage` with the result, so MARIS can change the list without a + * release of the app, and the home page always shows the last list it + * managed to fetch, from any past launch, not the bundled default. + */ + +import { get, readonly, type Readable } from 'svelte/store'; +import { persisted } from 'svelte-local-storage-store'; +import bundledExamples from './home-examples.default.json'; + +/** The address of the examples list. */ +export const HOME_EXAMPLES_URL = 'https://beacon-datalake.org/home-examples.json'; + +/** The localStorage key of the cached list. */ +const STORAGE_KEY = 'beacon-studio.home-examples'; + +/** The time after which the fetch of the list counts as a failure. */ +const FETCH_TIMEOUT_MS = 10_000; + +/** + * One card of the Quick start examples section on the home page. + * + * `rows` and `seconds` come from a reference run of the query. The card states + * them, so a new user knows the size of the result before the run starts. + */ +export type HomeExample = { + /** Card heading. */ + title: string; + /** One sentence about the selection. */ + description: string; + /** The node that holds the data, in words. */ + sourceName: string; + /** The table on that node. */ + tableName: string; + /** File name of the screenshot, under `static/images/`. */ + image: string; + /** Row count of the reference run. */ + rows: number; + /** Duration of the reference run, in seconds. */ + seconds: number; + /** Output format of the query, in words. */ + format: string; + /** + * The `?query=...` part of a share link for this example, copied from the + * workbench's own Share button. To update an example, share the query + * again and paste the new value here. + */ + shareQuery: string; +}; + +/** True if the value has every field of a `HomeExample`, with the right type. */ +function isHomeExample(value: unknown): value is HomeExample { + if (typeof value !== 'object' || value === null) return false; + + const entry = value as Record; + + return typeof entry.title === 'string' + && typeof entry.description === 'string' + && typeof entry.sourceName === 'string' + && typeof entry.tableName === 'string' + && typeof entry.image === 'string' + && typeof entry.rows === 'number' + && typeof entry.seconds === 'number' + && typeof entry.format === 'string' + && typeof entry.shareQuery === 'string'; +} + +/** Keeps the usable entries, in file order, and drops a malformed one. */ +function parseExamples(payload: unknown): HomeExample[] { + if (!Array.isArray(payload)) { + console.warn('The home examples list is not an array. The cache keeps its last value.'); + return []; + } + + return payload.filter(isHomeExample); +} + +/** + * Seed shown before the first successful fetch: the bundled defaults, run + * through the same validation as an online payload. + */ +const homeExamplesStore = persisted(STORAGE_KEY, parseExamples(bundledExamples)); + +/** + * The cached examples, in the order that the home page shows them. Reads from + * `localStorage` at once, so a component has the last successful fetch to show + * before `loadHomeExamples` answers. Use `$homeExamples` in a component. + */ +export const homeExamples: Readable = readonly(homeExamplesStore); + +/** + * Reads the examples list from {@link HOME_EXAMPLES_URL} and writes it to + * `localStorage`, overwriting the bundled defaults or an earlier fetch. A + * failure leaves the cached list as it was, so the home page still shows the + * last successful fetch. + */ +export async function loadHomeExamples(): Promise { + try { + const response = await fetch(HOME_EXAMPLES_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`The server answered ${response.status}.`); + } + + const examples = parseExamples(await response.json()); + + homeExamplesStore.set(examples); + + return examples; + } catch (error) { + console.warn(`Could not read the home examples list at ${HOME_EXAMPLES_URL}.`, error); + + return get(homeExamplesStore); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index da88a64..558b3ff 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import Toasts from '@/components/toasts/toasts.svelte'; import { checkAllInstances, startHealthMonitor } from '@/services/beacon-instance-connect'; import { loadOpenInstances } from '@/services/open-instances'; + import { loadHomeExamples } from '@/data/home-examples'; import { FRESH_MS } from '@/services/beacon-instance-health'; import { importOpenInstances } from '@/services/open-instances-import'; import { onMount } from 'svelte'; @@ -17,6 +18,8 @@ .then(importOpenInstances) .then(() => checkAllInstances(FRESH_MS)); + void loadHomeExamples(); + return startHealthMonitor(); }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7b35dc7..cd38e19 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,43 +1,26 @@