Skip to content
83 changes: 82 additions & 1 deletion apps/class-solid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<preset-name>`.
An experiment from a preset can be opened from a URL like `?preset=<preset-name>`.
For example to load <src/lib/presets/death-valley.json> use `http://localhost:3000/?preset=Death%20Valley`.

## Loading experiment from URL

A saved state (`class-<experiment-name>.json` file) can be loaded from a URL with the `s` search query parameter.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s or e? The PR top post also says e, but the screenshot of what to do when url too long says s 😕

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be consistent s everywhere except in PR description

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, changed my mind to store whole state (s) of app instead of just one experiment (e).


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.

<details>
<summary>Local development</summary>

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 <<EOF > ./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).

</details>
75 changes: 65 additions & 10 deletions apps/class-solid/src/components/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -72,11 +88,50 @@ export function ShareButton() {
<Show
when={shareableLink().length < MAX_SHAREABLE_LINK_LENGTH}
fallback={
<p>
Cannot share application state, it is too large. Please download
each experiment by itself or make it smaller by removing
permutations and/or experiments.
</p>
<>
<p>
Cannot embed application state in shareable link, it is too
large.
</p>
<p>
Alternatively you can create your own shareable link by hosting
the state remotely:
</p>
<ol class="list-inside list-decimal space-y-1">
<li>
<a
class="underline"
href={downloadUrl()}
download={filename()}
type="application/json"
>
Download state
</a>{" "}
as file
</li>
<li>
Upload the state file to some static hosting service like your
own web server or an AWS S3 bucket.
</li>
<li>
Open the CLASS web application with
"https://classmodel.github.io/class-web?s=&lt;your remote
url&gt;".
</li>
</ol>
<p>
Make sure the CLASS web application is{" "}
<a
href="https://enable-cors.org/server.html"
target="_blank"
rel="noreferrer"
class="underline"
>
allowed to download from remote location
</a>
.
</p>
</>
}
>
<Show
Expand Down
3 changes: 3 additions & 0 deletions apps/class-solid/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ function showToastPromise<T, U>(
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"}
>
<Switch>
<Match when={props.state === "pending"}>{options.loading}</Match>
Expand All @@ -191,6 +193,7 @@ function showToastPromise<T, U>(
<Match when={props.state === "rejected"}>
{/* biome-ignore lint/style/noNonNullAssertion: <explanation> */}
{options.error?.(props.error!)}
<ToastClose />
</Match>
</Switch>
</Toast>
Expand Down
31 changes: 30 additions & 1 deletion apps/class-solid/src/lib/state.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
},
);
}