Skip to content

Commit 91d3d37

Browse files
authored
Load experiment from remote URL (#187)
* See if ?preset works to load experiment remotely * Only hide toast on success + allow toast of failure to be closed manually * Load experiment from remote URL via `?e=<remote-url>` Fixes #158 * Embed json file into README * Do not try to parse non-200 responses * If shareable link is too large give hosting state file as alternative * Always clear adress bar from `?s=...` * more e to s replacements + spelling * Clearify where to run extra http server
1 parent 7da9999 commit 91d3d37

File tree

4 files changed

+180
-12
lines changed

4 files changed

+180
-12
lines changed

apps/class-solid/README.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,86 @@ The format is JSON with content adhering to the [JSON schema](https://github.com
5959
The `src/lib/presets.ts` is used as an index of presets.
6060
If you add a preset the `src/lib/presets.ts` file needs to be updated.
6161

62-
An experiment from a preset can be opened from a url like `?preset=<preset-name>`.
62+
An experiment from a preset can be opened from a URL like `?preset=<preset-name>`.
6363
For example to load <src/lib/presets/death-valley.json> use `http://localhost:3000/?preset=Death%20Valley`.
64+
65+
## Loading experiment from URL
66+
67+
A saved state (`class-<experiment-name>.json` file) can be loaded from a URL with the `s` search query parameter.
68+
69+
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`.
70+
71+
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.
72+
73+
<details>
74+
<summary>Local development</summary>
75+
76+
Besides the `pnpm dev` start a static web server hosting the `./mock-wildfiredataportal/` directory.
77+
78+
```shell
79+
cd apps/class-solid # If you are not already in the directory of this README.md
80+
mkdir -p ./mock-wildfiredataportal
81+
# Create a mocked state with experiment similar to https://wildfiredataportal.eu/fire/batea/
82+
cat <<EOF > ./mock-wildfiredataportal/batea.json
83+
{
84+
"experiments": [{
85+
"reference": {
86+
"name": "batea",
87+
"description": "Copied from https://wildfiredataportal.eu/fire/batea/ with mocked observations.",
88+
"h": 912,
89+
"theta": 299.1,
90+
"dtheta": 0.816,
91+
"gamma_theta": [0.00509, 0.00216],
92+
"z_theta": [2138, 4000],
93+
"qt": 0.0055,
94+
"dqt": -0.000826,
95+
"gamma_qt": [-8.08e-7, -5.62e-7],
96+
"z_qt": [2253, 4000],
97+
"divU": -6.7e-7,
98+
"u": -3.22,
99+
"ug": -1.9,
100+
"du": 1.33,
101+
"gamma_u": [0.00186, 0.00404],
102+
"z_u": [2125, 4000],
103+
"v": 4.81,
104+
"vg": 5.81,
105+
"dv": 1,
106+
"gamma_v": [-0.00243, -0.001],
107+
"z_v": [1200, 4000],
108+
"ustar": 0.1,
109+
"runtime": 10800,
110+
"wtheta": [0.404, 0.41, 0.375, 0.308, 0.205, 0.12, 0.036, 0, 0, 0, 0, 0],
111+
"wq": [
112+
0, 0, 0, 0, 7.6e-7, 0.00000128, 0.00000146, 0.00000125, 0.00000115,
113+
0.00000115, 0.00000252, 0.00000183
114+
],
115+
"fc": 0.000096,
116+
"p0": 97431,
117+
"z0m": 0.45,
118+
"z0h": 0.00281,
119+
"is_tuned": true,
120+
"t0": "2024-05-11T12:00:00Z"
121+
},
122+
"preset": "Varnavas",
123+
"permutations": [],
124+
"observations": [
125+
{
126+
"name": "Mocked soundings",
127+
"height": [0, 1000, 2000, 3000, 4000],
128+
"pressure": [900, 800, 700, 600, 500],
129+
"temperature": [16.4, 10.2, 4.0, -2.2, -8.4],
130+
"relativeHumidity": [30, 25, 20, 15, 10],
131+
"windSpeed": [2, 5, 10, 15, 20],
132+
"windDirection": [180, 200, 220, 240, 260]
133+
}
134+
]
135+
}]
136+
}
137+
EOF
138+
139+
pnpm exec serve --cors --listen 3001 ./mock-wildfiredataportal
140+
```
141+
142+
Visit [http://localhost:3000/?s=http://localhost:3001/batea.json](http://localhost:3000/?s=http://localhost:3001/batea.json).
143+
144+
</details>

apps/class-solid/src/components/ShareButton.tsx

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show, createMemo, createSignal } from "solid-js";
1+
import { Show, createMemo, createSignal, onCleanup } from "solid-js";
22
import { Button } from "~/components/ui/button";
33
import { encodeAppState } from "~/lib/encode";
44
import { analyses, experiments } from "~/lib/store";
@@ -23,18 +23,34 @@ export function ShareButton() {
2323
const [open, setOpen] = createSignal(false);
2424
const [isCopied, setIsCopied] = createSignal(false);
2525
let inputRef: HTMLInputElement | undefined;
26-
const shareableLink = createMemo(() => {
26+
const encodedAppState = createMemo(() => {
2727
if (!open()) {
2828
return "";
2929
}
30-
31-
const appState = encodeAppState(experiments, analyses);
30+
return encodeAppState(experiments, analyses);
31+
});
32+
const shareableLink = createMemo(() => {
3233
const basePath = import.meta.env.DEV
3334
? ""
3435
: import.meta.env.BASE_URL.replace("/_build", "");
35-
const url = `${window.location.origin}${basePath}#${appState}`;
36+
const url = `${window.location.origin}${basePath}#${encodedAppState()}`;
3637
return url;
3738
});
39+
const downloadUrl = createMemo(() => {
40+
return URL.createObjectURL(
41+
new Blob([decodeURI(encodedAppState())], {
42+
type: "application/json",
43+
}),
44+
);
45+
});
46+
onCleanup(() => {
47+
URL.revokeObjectURL(downloadUrl());
48+
});
49+
50+
const filename = createMemo(() => {
51+
const names = experiments.map((e) => e.config.reference.name).join("-");
52+
return `class-${names.slice(0, 120)}.json`;
53+
});
3854

3955
async function copyToClipboard() {
4056
try {
@@ -72,11 +88,50 @@ export function ShareButton() {
7288
<Show
7389
when={shareableLink().length < MAX_SHAREABLE_LINK_LENGTH}
7490
fallback={
75-
<p>
76-
Cannot share application state, it is too large. Please download
77-
each experiment by itself or make it smaller by removing
78-
permutations and/or experiments.
79-
</p>
91+
<>
92+
<p>
93+
Cannot embed application state in shareable link, it is too
94+
large.
95+
</p>
96+
<p>
97+
Alternatively you can create your own shareable link by hosting
98+
the state remotely:
99+
</p>
100+
<ol class="list-inside list-decimal space-y-1">
101+
<li>
102+
<a
103+
class="underline"
104+
href={downloadUrl()}
105+
download={filename()}
106+
type="application/json"
107+
>
108+
Download state
109+
</a>{" "}
110+
as file
111+
</li>
112+
<li>
113+
Upload the state file to some static hosting service like your
114+
own web server or an AWS S3 bucket.
115+
</li>
116+
<li>
117+
Open the CLASS web application with
118+
"https://classmodel.github.io/class-web?s=&lt;your remote
119+
url&gt;".
120+
</li>
121+
</ol>
122+
<p>
123+
Make sure the CLASS web application is{" "}
124+
<a
125+
href="https://enable-cors.org/server.html"
126+
target="_blank"
127+
rel="noreferrer"
128+
class="underline"
129+
>
130+
allowed to download from remote location
131+
</a>
132+
.
133+
</p>
134+
</>
80135
}
81136
>
82137
<Show

apps/class-solid/src/components/ui/toast.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ function showToastPromise<T, U>(
181181
toastId={props.toastId}
182182
variant={variant[props.state]}
183183
duration={options.duration}
184+
// Only hide toast after duration if it's in success state
185+
persistent={props.state !== "fulfilled"}
184186
>
185187
<Switch>
186188
<Match when={props.state === "pending"}>{options.loading}</Match>
@@ -191,6 +193,7 @@ function showToastPromise<T, U>(
191193
<Match when={props.state === "rejected"}>
192194
{/* biome-ignore lint/style/noNonNullAssertion: <explanation> */}
193195
{options.error?.(props.error!)}
196+
<ToastClose />
194197
</Match>
195198
</Switch>
196199
</Toast>

apps/class-solid/src/lib/state.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useLocation, useNavigate } from "@solidjs/router";
2-
import { showToast } from "~/components/ui/toast";
2+
import { showToast, showToastPromise } from "~/components/ui/toast";
33
import { encodeAppState } from "./encode";
44
import { findPresetByName } from "./presets";
55
import {
@@ -44,6 +44,14 @@ export function loadFromLocalStorage() {
4444
export async function onPageLoad() {
4545
const location = useLocation();
4646
const navigate = useNavigate();
47+
const stateUrl = location.query.s;
48+
if (stateUrl) {
49+
await loadStateFromURL(stateUrl);
50+
// Remove query parameter after loading state from URL,
51+
// as after editing the experiment the URL gets out of sync
52+
navigate("/");
53+
return;
54+
}
4755
const presetUrl = location.query.preset;
4856
if (presetUrl) {
4957
return await loadExperimentPreset(presetUrl);
@@ -112,3 +120,24 @@ export function saveToLocalStorage() {
112120
duration: 1000,
113121
});
114122
}
123+
124+
async function loadStateFromURL(url: string) {
125+
await showToastPromise(
126+
async () => {
127+
const response = await fetch(url);
128+
if (!response.ok) {
129+
throw new Error(
130+
`Failed to download experiment from ${url}: ${response.status} ${response.statusText}`,
131+
);
132+
}
133+
const rawData = await response.text();
134+
await loadStateFromString(rawData);
135+
},
136+
{
137+
loading: "Loading experiment from URL...",
138+
success: () => "Experiment loaded from URL",
139+
error: (error) => `Failed to load experiment from URL: ${error}`,
140+
duration: 1000,
141+
},
142+
);
143+
}

0 commit comments

Comments
 (0)