Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion apps/class-solid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,85 @@ 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.

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
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>
16 changes: 7 additions & 9 deletions apps/class-solid/src/components/Analysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
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
86 changes: 86 additions & 0 deletions apps/class-solid/src/lib/analysis_type.ts
Original file line number Diff line number Diff line change
@@ -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: [
{
Comment thread
sverhoeven marked this conversation as resolved.
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<Analysis>;
const validate = ajv.compile(schema);
if (!validate(raw)) {
throw new ValidationError(validate.errors as DefinedError[]);
}
return raw;
}
14 changes: 12 additions & 2 deletions apps/class-solid/src/lib/encode.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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];
}

Expand All @@ -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));
}
Expand Down
8 changes: 7 additions & 1 deletion apps/class-solid/src/lib/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
Loading
Loading