diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md
index c834443..58cff96 100644
--- a/apps/class-solid/README.md
+++ b/apps/class-solid/README.md
@@ -59,5 +59,86 @@ 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 experiment from URL
+
+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?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.
+
+
+Local development
+
+Besides the `pnpm dev` start a static web server hosting the `./mock-wildfiredataportal/` directory.
+
+```shell
+cd apps/class-solid # If you are not already in the directory of this README.md
+mkdir -p ./mock-wildfiredataportal
+# Create a mocked state with experiment similar to https://wildfiredataportal.eu/fire/batea/
+cat < ./mock-wildfiredataportal/batea.json
+{
+ "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/?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..8816f0c 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.
+
+
+ Alternatively 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
+
+ .
+
+ >
}
>
(
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!)}
+
diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts
index a4be0fb..e2ab646 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,14 @@ export function loadFromLocalStorage() {
export async function onPageLoad() {
const location = useLocation();
const navigate = useNavigate();
+ const stateUrl = location.query.s;
+ if (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) {
return await loadExperimentPreset(presetUrl);
@@ -112,3 +120,24 @@ export function saveToLocalStorage() {
duration: 1000,
});
}
+
+async function loadStateFromURL(url: string) {
+ await 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.text();
+ await loadStateFromString(rawData);
+ },
+ {
+ loading: "Loading experiment from URL...",
+ success: () => "Experiment loaded from URL",
+ error: (error) => `Failed to load experiment from URL: ${error}`,
+ duration: 1000,
+ },
+ );
+}