/**
* The selection — one value chosen from every discrete list — and its URL
* form.
*
* The main page keeps its whole state in the URL fragment, every value
* written explicitly, so a link keeps meaning the same spec even if a default
* changes later. The sweep page carries the same fragment plus one extra
* entry (`sweep=`, which model parameter the knob runs over), and the
* command line's `sweep ` accepts that page's URL as its argument. Three
* readers of one serialization is the reason it lives here rather than in any
* of them.
*
* Values are only accepted if they are exactly entries of the discrete lists
* (src/cache/options.ts); anything else keeps the default. That is what makes
* a fragment safe to hand to the cache: nothing typed or mistyped can name a
* spec that the dropdowns could not.
*/
import type { Params } from '../mgpu/registry.ts';
import { DEFAULT_GEOMETRY_KEY } from '../geom/registry.ts';
import {
DEFAULT_MODEL_KEY,
GEOMETRY_CHOICES,
LAM3,
LMAX,
MODEL_CHOICES,
NITER,
SEED_CHOICE,
T_END_CHOICE,
defaultChoiceParams,
fmtChoice,
type DiscreteChoice,
} from './options.ts';
import { APP_NAME, FORMAT_VERSION, type CacheSpec } from './spec.ts';
export interface Selection {
model: string;
params: Params;
geometry: string;
geometryParams: Params;
seed: number;
tEnd: number;
}
export function defaultSelection(): Selection {
return {
model: DEFAULT_MODEL_KEY,
params: defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]),
geometry: DEFAULT_GEOMETRY_KEY,
geometryParams: defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]),
seed: SEED_CHOICE.value,
tEnd: T_END_CHOICE.value,
};
}
/** The fragment form: `model=…&a=…&…&geometry=…&…&seed=…&tend=…`. The keys
* are the choices' own, except tEnd, which the URL spells `tend`. */
export function selectionToParams(sel: Selection): URLSearchParams {
const p = new URLSearchParams();
p.set('model', sel.model);
for (const c of MODEL_CHOICES[sel.model]) p.set(c.key, fmtChoice(sel.params[c.key]));
p.set('geometry', sel.geometry);
for (const c of GEOMETRY_CHOICES[sel.geometry]) {
p.set(c.key, fmtChoice(sel.geometryParams[c.key]));
}
p.set('seed', String(sel.seed));
p.set('tend', fmtChoice(sel.tEnd));
return p;
}
/**
* A fragment string from those parameters. URLSearchParams percent-encodes
* commas, which turns a sweep's value list into `0.7%2C0.9%2C1.1` — readable
* to a parser and to nobody else. A fragment is allowed to carry commas
* literally (RFC 3986 counts them among the sub-delims), and the parser
* reads an unencoded comma back as the same character, so they are put back.
*/
export const fragmentFor = (p: URLSearchParams): string =>
p.toString().replace(/%2C/g, ',');
/** Read a selection back from a fragment, defaults standing in for anything
* absent or not exactly a listed value. */
export function readSelection(p: URLSearchParams): Selection {
const sel = defaultSelection();
const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
const raw = p.get(name);
if (raw === null) return current;
const v = Number(raw);
return choice.values.includes(v) ? v : current;
};
const m = p.get('model');
if (m && MODEL_CHOICES[m]) {
sel.model = m;
sel.params = defaultChoiceParams(MODEL_CHOICES[m]);
}
const g = p.get('geometry');
if (g && GEOMETRY_CHOICES[g]) {
sel.geometry = g;
sel.geometryParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
}
for (const c of MODEL_CHOICES[sel.model]) sel.params[c.key] = pick(c, sel.params[c.key]);
for (const c of GEOMETRY_CHOICES[sel.geometry]) {
sel.geometryParams[c.key] = pick(c, sel.geometryParams[c.key]);
}
sel.seed = pick(SEED_CHOICE, sel.seed);
sel.tEnd = pick(T_END_CHOICE, sel.tEnd, 'tend');
return sel;
}
/** The one solution a selection names. */
export function specForSelection(sel: Selection): CacheSpec {
return {
app: APP_NAME,
formatVersion: FORMAT_VERSION,
model: sel.model,
params: { ...sel.params },
geometry: sel.geometry,
geometryParams: { ...sel.geometryParams },
lmax: LMAX,
niter: NITER,
lam3: LAM3,
seed: sel.seed,
tEnd: sel.tEnd,
};
}
// ---------------------------------------------------------------- sweeps
/**
* A sweep: the same selection, with one model parameter designated as the
* swept one and a list of values for it. The list defaults to the
* parameter's own choices but may be an explicit list the user typed, which
* is the one place the app steps outside its dropdown lists. That is safe
* for the cache, since a typed value is parsed to a number once and
* serialized in canonical shortest form ever after (src/cache/spec.ts), so
* that it names one spec as reliably as a listed value does. It merely names
* one the main page's dropdowns cannot reach. The selection's own value for
* the swept parameter is the knob's current position, so a shared sweep link
* opens at the same place.
*/
export interface SweepSelection {
sel: Selection;
/** Which of the model's parameters the knob runs over. */
key: string;
/** The values it runs over, in knob order. */
values: number[];
}
/** The swept parameter's underlying choice (its label and default list). */
export function sweepChoice(sweep: { sel: Selection; key: string }): DiscreteChoice {
const choice = MODEL_CHOICES[sweep.sel.model].find((c) => c.key === sweep.key);
if (!choice) {
throw new Error(`${sweep.sel.model} has no parameter '${sweep.key}'`);
}
return choice;
}
/**
* An explicit value list, as typed: numbers separated by commas or spaces.
* Anything that is not a finite number is dropped and duplicates collapse,
* but the order is kept as given, an explicit list being taken at its word.
*/
export function parseValueList(text: string): number[] {
return [
...new Set(
text
.split(/[,\s]+/)
.filter((s) => s.length)
.map(Number)
.filter((v) => Number.isFinite(v)),
),
];
}
/** The sweep page's fragment: the selection plus which parameter sweeps and
* the values it runs over, every value written explicitly. */
export function sweepToParams(sweep: SweepSelection): URLSearchParams {
const p = selectionToParams(sweep.sel);
// The swept parameter's own entry is the knob position, which for a custom
// list may be a value selectionToParams could not have written.
p.set(sweep.key, fmtChoice(sweep.sel.params[sweep.key]));
p.set('sweep', sweep.key);
p.set('values', sweep.values.map(fmtChoice).join(','));
return p;
}
/**
* Read a sweep from a fragment. Null when the fragment names no swept
* parameter (or one the model does not have): the page falls back to its
* default, the command line says the URL is not a sweep link. A missing or
* empty `values` entry means the parameter's own list.
*/
export function readSweep(p: URLSearchParams): SweepSelection | null {
const sel = readSelection(p);
const key = p.get('sweep');
if (!key || !MODEL_CHOICES[sel.model].some((c) => c.key === key)) return null;
const sweep: SweepSelection = { sel, key, values: [] };
const listed = p.get('values');
const parsed = listed === null ? [] : parseValueList(listed);
sweep.values = parsed.length ? parsed : [...sweepChoice(sweep).values];
// The knob position: readSelection validated the swept entry against the
// dropdown list, which a custom value is deliberately not on, so it is
// read again against the sweep's own list.
const raw = p.get(key);
const v = raw === null ? NaN : Number(raw);
sel.params[key] = sweep.values.includes(v)
? v
: sweep.values.includes(sel.params[key])
? sel.params[key]
: sweep.values[0];
return sweep;
}
/** The sweep's solutions, one per value, in knob order. */
export function specsForSweep(
sweep: SweepSelection,
): { value: number; spec: CacheSpec }[] {
return sweep.values.map((value) => ({
value,
spec: specForSelection({
...sweep.sel,
params: { ...sweep.sel.params, [sweep.key]: value },
}),
}));
}