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:
+
+
+ -
+
+ Download state
+ {" "}
+ as file
+
+ -
+ Upload the state file to some static hosting service like your
+ own web server or an AWS S3 bucket.
+
+ -
+ Open the CLASS web application with
+ "https://classmodel.github.io/class-web?s=<your remote
+ url>".
+
+
+
+ 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",