From 8cae5704b544c7e3485ee160a0cd2a3b01f4481b Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 24 Nov 2025 07:56:12 +0100 Subject: [PATCH 01/12] See if ?preset works to load experiment remotely --- apps/class-solid/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index c834443..e1260eb 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -61,3 +61,25 @@ If you add a preset the `src/lib/presets.ts` file needs to be updated. An experiment from a preset can be opened from a url like `?preset=`. For example to load use `http://localhost:3000/?preset=Death%20Valley`. + +## Loading experiments from url + +A saved experiment can be loaded from a url with the `preset` search query parameter. + +For example `https://classmodel.github.io/class-web?e=https://wildfiredataportal.eu/fire/batea/class.json` will load the experiment from `https://wildfiredataportal.eu/fire/batea/class.json`. + +The server hosting the json file must have CORS enabled for this to work, see https://enable-cors.org/ for details. + +
+Local development + +Besides the `pnpm dev` start a static web server hosting `apps/class-solid/src/lib/presets/` directory. + +```shell +# TODO +``` + + +Visit http://localhost:3000/?preset=http://localhost:8080/death-valley.json . + +
\ No newline at end of file From e7cd062b3ced64ed17873555337e83ae6625a961 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 12:04:04 +0100 Subject: [PATCH 02/12] Only hide toast on success + allow toast of failure to be closed manually --- apps/class-solid/src/components/ui/toast.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/class-solid/src/components/ui/toast.tsx b/apps/class-solid/src/components/ui/toast.tsx index 6558a27..a05cc28 100644 --- a/apps/class-solid/src/components/ui/toast.tsx +++ b/apps/class-solid/src/components/ui/toast.tsx @@ -181,6 +181,8 @@ function showToastPromise( toastId={props.toastId} variant={variant[props.state]} duration={options.duration} + // Only hide toast after duration if it's in success state + persistent={props.state !== "fulfilled"} > {options.loading} @@ -191,6 +193,7 @@ function showToastPromise( {/* biome-ignore lint/style/noNonNullAssertion: */} {options.error?.(props.error!)} + From 8aaa7f869089d43a6de745bc556ae01d15caa31f Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 12:05:04 +0100 Subject: [PATCH 03/12] Load experiment from remote URL via `?e=` Fixes #158 --- apps/class-solid/README.md | 17 +++++++++-------- apps/class-solid/src/lib/state.ts | 25 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index e1260eb..0cf9b5c 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -59,27 +59,28 @@ The format is JSON with content adhering to the [JSON schema](https://github.com The `src/lib/presets.ts` is used as an index of presets. If you add a preset the `src/lib/presets.ts` file needs to be updated. -An experiment from a preset can be opened from a url like `?preset=`. +An experiment from a preset can be opened from a URL like `?preset=`. For example to load use `http://localhost:3000/?preset=Death%20Valley`. -## Loading experiments from url +## Loading experiment from URL -A saved experiment can be loaded from a url with the `preset` search query parameter. +A saved experiment (`.json` file) can be loaded from a URL with the `e` search query parameter. For example `https://classmodel.github.io/class-web?e=https://wildfiredataportal.eu/fire/batea/class.json` will load the experiment from `https://wildfiredataportal.eu/fire/batea/class.json`. -The server hosting the json file must have CORS enabled for this to work, see https://enable-cors.org/ for details. +The server hosting the JSON file must have CORS enabled so the CLASS web application is allowed to download it, see [https://enable-cors.org](https://enable-cors.org) for details.
Local development -Besides the `pnpm dev` start a static web server hosting `apps/class-solid/src/lib/presets/` directory. +The `./mock-wildfiredataportal/` directory contains mocked experiment similar to a wildfire at https://wildfiredataportal.eu/data/wildfire-data-portal/. + +Besides the `pnpm dev` start a static web server hosting the `./mock-wildfiredataportal/` directory. ```shell -# TODO +pnpm exec serve --cors --listen 3001 ./mock-wildfiredataportal ``` - -Visit http://localhost:3000/?preset=http://localhost:8080/death-valley.json . +Visit [http://localhost:3000/?e=http://localhost:3001/batea.json](http://localhost:3000/?e=http://localhost:3001/batea.json).
\ No newline at end of file diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts index a4be0fb..3f6bc20 100644 --- a/apps/class-solid/src/lib/state.ts +++ b/apps/class-solid/src/lib/state.ts @@ -1,5 +1,5 @@ import { useLocation, useNavigate } from "@solidjs/router"; -import { showToast } from "~/components/ui/toast"; +import { showToast, showToastPromise } from "~/components/ui/toast"; import { encodeAppState } from "./encode"; import { findPresetByName } from "./presets"; import { @@ -44,6 +44,10 @@ export function loadFromLocalStorage() { export async function onPageLoad() { const location = useLocation(); const navigate = useNavigate(); + const experimentUrl = location.query.e; + if (experimentUrl) { + return await loadExperimentFromUrl(experimentUrl); + } const presetUrl = location.query.preset; if (presetUrl) { return await loadExperimentPreset(presetUrl); @@ -112,3 +116,22 @@ export function saveToLocalStorage() { duration: 1000, }); } + +async function loadExperimentFromUrl(url: string) { + const navigate = useNavigate(); + showToastPromise( + async () => { + const response = await fetch(url); + const rawData = await response.json(); + await uploadExperiment(rawData); + // clear ?e from URL after loading, as any edits would make URL a lie + navigate("/"); + }, + { + loading: "Loading experiment from URL...", + success: () => "Experiment loaded from URL", + error: (error) => `Failed to load experiment from URL: ${error}`, + duration: 1000, + }, + ); +} From 9e02c299f3e113c9513d5dafc5b0eee530322a9f Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 12:15:51 +0100 Subject: [PATCH 04/12] Embed json file into README --- apps/class-solid/README.md | 59 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index 0cf9b5c..62bd02f 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -73,11 +73,66 @@ The server hosting the JSON file must have CORS enabled so the CLASS web applica
Local development -The `./mock-wildfiredataportal/` directory contains mocked experiment similar to a wildfire at https://wildfiredataportal.eu/data/wildfire-data-portal/. - Besides the `pnpm dev` start a static web server hosting the `./mock-wildfiredataportal/` directory. ```shell +mkdir -p ./mock-wildfiredataportal +# Create a mocked experiment similar to https://wildfiredataportal.eu/fire/batea/ +cat < ./mock-wildfiredataportal/batea.json +{ + "reference": { + "name": "batea", + "description": "Copied from https://wildfiredataportal.eu/fire/batea/ with mocked observations.", + "h": 912, + "theta": 299.1, + "dtheta": 0.816, + "gamma_theta": [0.00509, 0.00216], + "z_theta": [2138, 4000], + "qt": 0.0055, + "dqt": -0.000826, + "gamma_qt": [-8.08e-7, -5.62e-7], + "z_qt": [2253, 4000], + "divU": -6.7e-7, + "u": -3.22, + "ug": -1.9, + "du": 1.33, + "gamma_u": [0.00186, 0.00404], + "z_u": [2125, 4000], + "v": 4.81, + "vg": 5.81, + "dv": 1, + "gamma_v": [-0.00243, -0.001], + "z_v": [1200, 4000], + "ustar": 0.1, + "runtime": 10800, + "wtheta": [0.404, 0.41, 0.375, 0.308, 0.205, 0.12, 0.036, 0, 0, 0, 0, 0], + "wq": [ + 0, 0, 0, 0, 7.6e-7, 0.00000128, 0.00000146, 0.00000125, 0.00000115, + 0.00000115, 0.00000252, 0.00000183 + ], + "fc": 0.000096, + "p0": 97431, + "z0m": 0.45, + "z0h": 0.00281, + "is_tuned": true, + "t0": "2024-05-11T12:00:00Z" + }, + "preset": "Varnavas", + "permutations": [], + "observations": [ + { + "name": "Mocked soundings", + "height": [0, 1000, 2000, 3000, 4000], + "pressure": [900, 800, 700, 600, 500], + "temperature": [16.4, 10.2, 4.0, -2.2, -8.4], + "relativeHumidity": [30, 25, 20, 15, 10], + "windSpeed": [2, 5, 10, 15, 20], + "windDirection": [180, 200, 220, 240, 260] + } + ] +} +EOF + pnpm exec serve --cors --listen 3001 ./mock-wildfiredataportal ``` From c2dfb7618a23ecbaf9f0edf172a2a7e7573dddf5 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 12:20:37 +0100 Subject: [PATCH 05/12] Do not try to parse non-200 responses --- apps/class-solid/src/lib/state.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts index 3f6bc20..9ec4313 100644 --- a/apps/class-solid/src/lib/state.ts +++ b/apps/class-solid/src/lib/state.ts @@ -122,6 +122,11 @@ async function loadExperimentFromUrl(url: string) { showToastPromise( async () => { const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download experiment from ${url}: ${response.status} ${response.statusText}`, + ); + } const rawData = await response.json(); await uploadExperiment(rawData); // clear ?e from URL after loading, as any edits would make URL a lie From 50b3064f705727ad8534244df2dc36fedcda4a1a Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 12:34:22 +0100 Subject: [PATCH 06/12] Plot pressure, temperature and relative humidity from observations Fixes #184 --- apps/class-solid/src/lib/profiles.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/class-solid/src/lib/profiles.ts b/apps/class-solid/src/lib/profiles.ts index a0c9d17..7cd969e 100644 --- a/apps/class-solid/src/lib/profiles.ts +++ b/apps/class-solid/src/lib/profiles.ts @@ -108,9 +108,15 @@ export function observationsForProfile(obs: Observation, variable = "theta") { return { y: h, x: u }; case "v": return { y: h, x: v }; + case "rh": + return { y: h, x: rh }; + case "T": + return { y: h, x: T }; + case "p": + return { y: h, x: p }; default: console.warn( - "Unknown variable '${variable}' for observation profile.", + `Unknown variable '${variable}' for observation profile.`, ); return { y: Number.NaN, x: Number.NaN }; } From edd84c2e5993c8af04301b0ad7bff2a53340cba2 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 13:46:45 +0100 Subject: [PATCH 07/12] If shareable link is too large give hosting state file as alternative --- apps/class-solid/README.md | 104 +++++++++--------- .../src/components/ShareButton.tsx | 75 +++++++++++-- apps/class-solid/src/lib/state.ts | 12 +- 3 files changed, 124 insertions(+), 67 deletions(-) diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index 62bd02f..29c957c 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -80,62 +80,64 @@ mkdir -p ./mock-wildfiredataportal # Create a mocked experiment similar to https://wildfiredataportal.eu/fire/batea/ cat < ./mock-wildfiredataportal/batea.json { - "reference": { - "name": "batea", - "description": "Copied from https://wildfiredataportal.eu/fire/batea/ with mocked observations.", - "h": 912, - "theta": 299.1, - "dtheta": 0.816, - "gamma_theta": [0.00509, 0.00216], - "z_theta": [2138, 4000], - "qt": 0.0055, - "dqt": -0.000826, - "gamma_qt": [-8.08e-7, -5.62e-7], - "z_qt": [2253, 4000], - "divU": -6.7e-7, - "u": -3.22, - "ug": -1.9, - "du": 1.33, - "gamma_u": [0.00186, 0.00404], - "z_u": [2125, 4000], - "v": 4.81, - "vg": 5.81, - "dv": 1, - "gamma_v": [-0.00243, -0.001], - "z_v": [1200, 4000], - "ustar": 0.1, - "runtime": 10800, - "wtheta": [0.404, 0.41, 0.375, 0.308, 0.205, 0.12, 0.036, 0, 0, 0, 0, 0], - "wq": [ - 0, 0, 0, 0, 7.6e-7, 0.00000128, 0.00000146, 0.00000125, 0.00000115, - 0.00000115, 0.00000252, 0.00000183 - ], - "fc": 0.000096, - "p0": 97431, - "z0m": 0.45, - "z0h": 0.00281, - "is_tuned": true, - "t0": "2024-05-11T12:00:00Z" - }, - "preset": "Varnavas", - "permutations": [], - "observations": [ - { - "name": "Mocked soundings", - "height": [0, 1000, 2000, 3000, 4000], - "pressure": [900, 800, 700, 600, 500], - "temperature": [16.4, 10.2, 4.0, -2.2, -8.4], - "relativeHumidity": [30, 25, 20, 15, 10], - "windSpeed": [2, 5, 10, 15, 20], - "windDirection": [180, 200, 220, 240, 260] - } - ] + "experiments": [{ + "reference": { + "name": "batea", + "description": "Copied from https://wildfiredataportal.eu/fire/batea/ with mocked observations.", + "h": 912, + "theta": 299.1, + "dtheta": 0.816, + "gamma_theta": [0.00509, 0.00216], + "z_theta": [2138, 4000], + "qt": 0.0055, + "dqt": -0.000826, + "gamma_qt": [-8.08e-7, -5.62e-7], + "z_qt": [2253, 4000], + "divU": -6.7e-7, + "u": -3.22, + "ug": -1.9, + "du": 1.33, + "gamma_u": [0.00186, 0.00404], + "z_u": [2125, 4000], + "v": 4.81, + "vg": 5.81, + "dv": 1, + "gamma_v": [-0.00243, -0.001], + "z_v": [1200, 4000], + "ustar": 0.1, + "runtime": 10800, + "wtheta": [0.404, 0.41, 0.375, 0.308, 0.205, 0.12, 0.036, 0, 0, 0, 0, 0], + "wq": [ + 0, 0, 0, 0, 7.6e-7, 0.00000128, 0.00000146, 0.00000125, 0.00000115, + 0.00000115, 0.00000252, 0.00000183 + ], + "fc": 0.000096, + "p0": 97431, + "z0m": 0.45, + "z0h": 0.00281, + "is_tuned": true, + "t0": "2024-05-11T12:00:00Z" + }, + "preset": "Varnavas", + "permutations": [], + "observations": [ + { + "name": "Mocked soundings", + "height": [0, 1000, 2000, 3000, 4000], + "pressure": [900, 800, 700, 600, 500], + "temperature": [16.4, 10.2, 4.0, -2.2, -8.4], + "relativeHumidity": [30, 25, 20, 15, 10], + "windSpeed": [2, 5, 10, 15, 20], + "windDirection": [180, 200, 220, 240, 260] + } + ] + }] } EOF pnpm exec serve --cors --listen 3001 ./mock-wildfiredataportal ``` -Visit [http://localhost:3000/?e=http://localhost:3001/batea.json](http://localhost:3000/?e=http://localhost:3001/batea.json). +Visit [http://localhost:3000/?s=http://localhost:3001/batea.json](http://localhost:3000/?s=http://localhost:3001/batea.json).
\ No newline at end of file diff --git a/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index 7285db3..25b7f85 100644 --- a/apps/class-solid/src/components/ShareButton.tsx +++ b/apps/class-solid/src/components/ShareButton.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createSignal } from "solid-js"; +import { Show, createMemo, createSignal, onCleanup } from "solid-js"; import { Button } from "~/components/ui/button"; import { encodeAppState } from "~/lib/encode"; import { analyses, experiments } from "~/lib/store"; @@ -23,18 +23,34 @@ export function ShareButton() { const [open, setOpen] = createSignal(false); const [isCopied, setIsCopied] = createSignal(false); let inputRef: HTMLInputElement | undefined; - const shareableLink = createMemo(() => { + const encodedAppState = createMemo(() => { if (!open()) { return ""; } - - const appState = encodeAppState(experiments, analyses); + return encodeAppState(experiments, analyses); + }); + const shareableLink = createMemo(() => { const basePath = import.meta.env.DEV ? "" : import.meta.env.BASE_URL.replace("/_build", ""); - const url = `${window.location.origin}${basePath}#${appState}`; + const url = `${window.location.origin}${basePath}#${encodedAppState()}`; return url; }); + const downloadUrl = createMemo(() => { + return URL.createObjectURL( + new Blob([decodeURI(encodedAppState())], { + type: "application/json", + }), + ); + }); + onCleanup(() => { + URL.revokeObjectURL(downloadUrl()); + }); + + const filename = createMemo(() => { + const names = experiments.map((e) => e.config.reference.name).join("-"); + return `class-${names.slice(0, 120)}.json`; + }); async function copyToClipboard() { try { @@ -72,11 +88,50 @@ export function ShareButton() { - Cannot share application state, it is too large. Please download - each experiment by itself or make it smaller by removing - permutations and/or experiments. -

+ <> +

+ Cannot embed application state in shareable link, it is too + large. +

+

+ Alternativly you can create your own shareable link by hosting + the state remotely: +

+
    +
  1. + + Download state + {" "} + as file +
  2. +
  3. + Upload the state file to some static hosting service like your + own web server or an AWS S3 bucket. +
  4. +
  5. + Open the CLASS web application with + "https://classmodel.github.io/class-web?s=<your remote + url>". +
  6. +
+

+ Make sure the CLASS web application is{" "} + + allowed to download from remote location + + . +

+ } > { @@ -127,8 +127,8 @@ async function loadExperimentFromUrl(url: string) { `Failed to download experiment from ${url}: ${response.status} ${response.statusText}`, ); } - const rawData = await response.json(); - await uploadExperiment(rawData); + const rawData = await response.text(); + await loadStateFromString(rawData); // clear ?e from URL after loading, as any edits would make URL a lie navigate("/"); }, From 36be4741ca565125075efb9d7544f99382dd4edd Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 13:52:30 +0100 Subject: [PATCH 08/12] Always clear adress bar from `?s=...` --- apps/class-solid/src/lib/state.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts index 0183231..e2ab646 100644 --- a/apps/class-solid/src/lib/state.ts +++ b/apps/class-solid/src/lib/state.ts @@ -46,7 +46,11 @@ export async function onPageLoad() { const navigate = useNavigate(); const stateUrl = location.query.s; if (stateUrl) { - return await loadStateFromURL(stateUrl); + await loadStateFromURL(stateUrl); + // Remove query parameter after loading state from URL, + // as after editing the experiment the URL gets out of sync + navigate("/"); + return; } const presetUrl = location.query.preset; if (presetUrl) { @@ -118,8 +122,7 @@ export function saveToLocalStorage() { } async function loadStateFromURL(url: string) { - const navigate = useNavigate(); - showToastPromise( + await showToastPromise( async () => { const response = await fetch(url); if (!response.ok) { @@ -129,8 +132,6 @@ async function loadStateFromURL(url: string) { } const rawData = await response.text(); await loadStateFromString(rawData); - // clear ?e from URL after loading, as any edits would make URL a lie - navigate("/"); }, { loading: "Loading experiment from URL...", From 9d0c94df2f69879c5a14dc04f56d98f2306637d1 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Mon, 24 Nov 2025 13:57:50 +0100 Subject: [PATCH 09/12] more e to s replacements + spelling --- apps/class-solid/README.md | 6 +++--- apps/class-solid/src/components/ShareButton.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index 29c957c..54b5387 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -64,9 +64,9 @@ For example to load use `http://localhost:30 ## Loading experiment from URL -A saved experiment (`.json` file) can be loaded from a URL with the `e` search query parameter. +A saved state (`class-.json` file) can be loaded from a URL with the `s` search query parameter. -For example `https://classmodel.github.io/class-web?e=https://wildfiredataportal.eu/fire/batea/class.json` will load the experiment from `https://wildfiredataportal.eu/fire/batea/class.json`. +For example `https://classmodel.github.io/class-web?s=https://wildfiredataportal.eu/fire/batea/class.json` will load the experiment from `https://wildfiredataportal.eu/fire/batea/class.json`. The server hosting the JSON file must have CORS enabled so the CLASS web application is allowed to download it, see [https://enable-cors.org](https://enable-cors.org) for details. @@ -77,7 +77,7 @@ Besides the `pnpm dev` start a static web server hosting the `./mock-wildfiredat ```shell mkdir -p ./mock-wildfiredataportal -# Create a mocked experiment similar to https://wildfiredataportal.eu/fire/batea/ +# Create a mocked state with experiment similar to https://wildfiredataportal.eu/fire/batea/ cat < ./mock-wildfiredataportal/batea.json { "experiments": [{ diff --git a/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index 25b7f85..8816f0c 100644 --- a/apps/class-solid/src/components/ShareButton.tsx +++ b/apps/class-solid/src/components/ShareButton.tsx @@ -94,7 +94,7 @@ export function ShareButton() { large.

- Alternativly you can create your own shareable link by hosting + Alternatively you can create your own shareable link by hosting the state remotely:

    From 4a9392e2d7785fe8c410c44060c045c4d4854a19 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 24 Nov 2025 14:41:56 +0100 Subject: [PATCH 10/12] Use union type for Analysis + setAnalysis on loading state It is before setExperiments to have now plots being added by runExperiment --- apps/class-solid/src/lib/store.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 190bc42..ea05307 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -233,35 +233,34 @@ export function swapPermutationAndReferenceConfiguration( export async function loadStateFromString(rawState: string): Promise { const [loadedExperiments, loadedAnalyses] = decodeAppState(rawState); + setAnalyses(loadedAnalyses); setExperiments(loadedExperiments); await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); } -export interface Analysis { +export interface BaseAnalysis { id: string; description: string; type: string; name: string; } -export type TimeseriesAnalysis = Analysis & { +export type TimeseriesAnalysis = BaseAnalysis & { xVariable: string; yVariable: string; }; -export type ProfilesAnalysis = Analysis & { +export type ProfilesAnalysis = BaseAnalysis & { variable: string; time: number; }; -export type SkewTAnalysis = Analysis & { +export type SkewTAnalysis = BaseAnalysis & { time: number; }; -export type AnalysisType = - | TimeseriesAnalysis - | ProfilesAnalysis - | SkewTAnalysis; +// When modifying type of Analysis also update JSON schema in Analysis.tsx +export type Analysis = TimeseriesAnalysis | ProfilesAnalysis | SkewTAnalysis; export const analysisNames = [ "Vertical profiles", "Timeseries", From a54320206e2aaa8024be71dc51d96e3e3c986e02 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 24 Nov 2025 14:51:29 +0100 Subject: [PATCH 11/12] Encode/decode analyses array using a JSON schema validator + move Analysis types to src/lib/analysis_type.ts Moved types so type and its derived JSON schema are in same file. --- apps/class-solid/src/components/Analysis.tsx | 16 ++-- apps/class-solid/src/lib/analysis_type.ts | 86 ++++++++++++++++++++ apps/class-solid/src/lib/encode.ts | 14 +++- apps/class-solid/src/lib/store.ts | 35 ++------ apps/class-solid/src/routes/index.tsx | 3 +- 5 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 apps/class-solid/src/lib/analysis_type.ts diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 130b8e8..18c3d64 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -27,20 +27,18 @@ import { createUniqueId, } from "solid-js"; import { createStore } from "solid-js/store"; +import type { + Analysis, + ProfilesAnalysis, + SkewTAnalysis, + TimeseriesAnalysis, +} from "~/lib/analysis_type"; import type { Observation } from "~/lib/experiment_config"; import { observationsForProfile, observationsForSounding, } from "~/lib/profiles"; -import { - type Analysis, - type ProfilesAnalysis, - type SkewTAnalysis, - type TimeseriesAnalysis, - deleteAnalysis, - experiments, - updateAnalysis, -} from "~/lib/store"; +import { deleteAnalysis, experiments, updateAnalysis } from "~/lib/store"; import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons"; import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes"; import { Chart, ChartContainer, type ChartData } from "./plots/ChartContainer"; diff --git a/apps/class-solid/src/lib/analysis_type.ts b/apps/class-solid/src/lib/analysis_type.ts new file mode 100644 index 0000000..5164a68 --- /dev/null +++ b/apps/class-solid/src/lib/analysis_type.ts @@ -0,0 +1,86 @@ +import { ajv } from "@classmodel/class/validate"; +import { type DefinedError, type JSONSchemaType, ValidationError } from "ajv"; + +export interface BaseAnalysis { + id: string; + description: string; + type: string; + name: string; +} + +export type TimeseriesAnalysis = BaseAnalysis & { + xVariable: string; + yVariable: string; +}; + +export type ProfilesAnalysis = BaseAnalysis & { + variable: string; + time: number; +}; + +export type SkewTAnalysis = BaseAnalysis & { + time: number; +}; + +export type Analysis = TimeseriesAnalysis | ProfilesAnalysis | SkewTAnalysis; +export const analysisNames = [ + "Vertical profiles", + "Timeseries", + "Thermodynamic diagram", +]; + +export function parseAnalysis(raw: unknown): Analysis { + const schema = { + oneOf: [ + { + required: [ + "id", + "description", + "type", + "name", + "xVariable", + "yVariable", + ], + properties: { + id: { type: "string" }, + description: { type: "string" }, + type: { const: "timeseries" }, + name: { type: "string" }, + xVariable: { type: "string" }, + yVariable: { type: "string" }, + }, + additionalProperties: false, + }, + { + type: "object", + required: ["id", "description", "type", "name", "variable", "time"], + properties: { + id: { type: "string" }, + description: { type: "string" }, + type: { const: "profiles" }, + name: { type: "string" }, + variable: { type: "string" }, + time: { type: "number" }, + }, + additionalProperties: false, + }, + { + type: "object", + required: ["id", "description", "type", "name", "time"], + properties: { + id: { type: "string" }, + description: { type: "string" }, + type: { const: "skewT" }, + name: { type: "string" }, + time: { type: "number" }, + }, + additionalProperties: false, + }, + ], + } as unknown as JSONSchemaType; + const validate = ajv.compile(schema); + if (!validate(raw)) { + throw new ValidationError(validate.errors as DefinedError[]); + } + return raw; +} diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index b871a0d..c1082be 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -1,12 +1,14 @@ import { pruneConfig } from "@classmodel/class/config_utils"; import { unwrap } from "solid-js/store"; +import { parseAnalysis } from "./analysis_type"; +import type { Analysis } from "./analysis_type"; import { type ExperimentConfig, type PartialExperimentConfig, parseExperimentConfig, } from "./experiment_config"; import { findPresetByName } from "./presets"; -import type { Analysis, Experiment } from "./store"; +import type { Experiment } from "./store"; export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { const decoded = decodeURI(encoded); @@ -28,8 +30,14 @@ export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { } else { console.error("No experiments found in ", encoded); } - const analyses: Analysis[] = []; + if (typeof parsed === "object" && Array.isArray(parsed.analyses)) { + for (const analysisRaw of parsed.analyses) { + const analysis = parseAnalysis(analysisRaw); + analyses.push(analysis); + } + } + return [experiments, analyses]; } @@ -38,8 +46,10 @@ export function encodeAppState( analyses: Analysis[], ) { const rawExperiments = unwrap(experiments); + const rawAnalyses = unwrap(analyses); const minimizedState = { experiments: rawExperiments.map((exp) => toPartial(exp.config)), + analyses: rawAnalyses, }; return encodeURI(JSON.stringify(minimizedState, undefined, 0)); } diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index ea05307..d68bc4a 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -8,6 +8,12 @@ import { mergeConfigurations, pruneConfig, } from "@classmodel/class/config_utils"; +import type { + Analysis, + ProfilesAnalysis, + SkewTAnalysis, + TimeseriesAnalysis, +} from "./analysis_type"; import { decodeAppState } from "./encode"; import { parseExperimentConfig } from "./experiment_config"; import type { ExperimentConfig } from "./experiment_config"; @@ -238,35 +244,6 @@ export async function loadStateFromString(rawState: string): Promise { await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); } -export interface BaseAnalysis { - id: string; - description: string; - type: string; - name: string; -} - -export type TimeseriesAnalysis = BaseAnalysis & { - xVariable: string; - yVariable: string; -}; - -export type ProfilesAnalysis = BaseAnalysis & { - variable: string; - time: number; -}; - -export type SkewTAnalysis = BaseAnalysis & { - time: number; -}; - -// When modifying type of Analysis also update JSON schema in Analysis.tsx -export type Analysis = TimeseriesAnalysis | ProfilesAnalysis | SkewTAnalysis; -export const analysisNames = [ - "Vertical profiles", - "Timeseries", - "Thermodynamic diagram", -]; - export function addAnalysis(name: string) { let newAnalysis: Analysis; diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index 7a2fc9c..7f87983 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -16,7 +16,8 @@ import { Flex } from "~/components/ui/flex"; import { Toaster } from "~/components/ui/toast"; import { onPageLoad } from "~/lib/state"; -import { addAnalysis, analysisNames, experiments } from "~/lib/store"; +import { analysisNames } from "~/lib/analysis_type"; +import { addAnalysis, experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; export default function Home() { From 640d83319018a634954311d05bdd024c308d72b9 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 24 Nov 2025 15:04:14 +0100 Subject: [PATCH 12/12] Make oneOfs the same --- apps/class-solid/src/lib/analysis_type.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/class-solid/src/lib/analysis_type.ts b/apps/class-solid/src/lib/analysis_type.ts index 5164a68..7495dbc 100644 --- a/apps/class-solid/src/lib/analysis_type.ts +++ b/apps/class-solid/src/lib/analysis_type.ts @@ -33,6 +33,7 @@ export function parseAnalysis(raw: unknown): Analysis { const schema = { oneOf: [ { + type: "object", required: [ "id", "description",