/**
* Built-in controls panel (plain DOM, no framework). Hosts with their own UI pass
* { controls: false } to SharpJS() and call the handle / engine methods instead.
*/
import { PRESET_VALUES } from '../settings/schema.js';
import { createBufkitMapButton } from '../io/bufkit_map.js';
import { expandBufkitTimeSeries } from './time-slider.js';
const PARCELS = [
['SB', 'Surface-Based'],
['ML', 'Mixed-Layer'],
['MU', 'Most-Unstable'],
['CU', 'Custom Pressure'],
];
const MOTIONS = [
['RM', 'Right'],
['LM', 'Left'],
['MM', 'Mean'],
['US', 'Corfidi Up'],
['DS', 'Corfidi Down'],
['DTMR', 'Nixon Dev. R'],
['DTML', 'Nixon Dev. L'],
['CM', 'Custom'],
];
const PRESET_LABELS = {
WindLapse: { Default: 'Tornado', Severe: 'Surface-Based Severe/MCS', Dryline: 'Dryline/Confluence', Elevated: 'Elevated Severe/MCS', Winter: 'Winter', Fire: 'Fire' },
Inset: { Default: 'Tornado', Severe: 'Severe/MCS', Ambient: 'Fog/Ambient', Winter: 'Winter', Fire: 'Fire' },
Vertical: { Default: 'Severe/Tornado', Winter: 'Winter', Fire: 'Fire' },
SidePanel: { Graph: 'Graph w/ Height', Tornado: 'Tornado Climatology (CONUS)', Winter: 'Winter Stats' },
};
const PRESET_TITLES = { WindLapse: 'Wind / Lapse', Inset: 'Inset Parameters', Vertical: 'Vertical Levels', SidePanel: 'Side Panel' };
const COLOR_TABLES = ['Default', 'SounderPY', 'Rainbow', 'Greyscale'];
const SCALE_MODES = ['None', 'Storm Relative', 'Dynamic'];
const SKEWS = { 0.5: 'Low', 0.625: 'Medium', 0.75: 'Default', 0.825: 'High' };
const UNITS = { default: 'Default (kt / °C)', metric: 'Metric', imperial: 'Imperial' };
const EXPORTS = { Default: 'SHARPpy', SharpJS: 'SharpJS (SHARPpy + omega)', CM1: 'CM1 input_sounding' };
const BUFKIT_MODELS = { hrrr: 'HRRR', rap: 'RAP', ruc: 'RUC', nam: 'NAM', nam4km: 'NAM 3 km', gfs: 'GFS' };
/**
* Create a DOM element, apply attributes/listeners, and append flattened children.
* @param {Document} doc Owner document for the element.
* @param {string} tag HTML tag name.
* @param {object} attrs Element properties, attributes, styles, and event handlers.
* @param {...(Node|string|number|null|boolean)} children Child nodes or text values.
* @returns {HTMLElement} Created element.
*/
function h(doc, tag, attrs = {}, ...children) {
const el = doc.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v === undefined || v === null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'style') el.style.cssText = v;
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
else if (k in el && typeof v !== 'string') el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const child of children.flat()) {
if (child === null || child === undefined || child === false) continue;
el.append(child instanceof Node ? child : doc.createTextNode(String(child)));
}
return el;
}
const utcDate = (d) => d.toISOString().slice(0, 10);
/** Most recent 00/12 Z launch at least 2 h old. */
function lastSynoptic(now = new Date()) {
const d = new Date(now.getTime() - 2 * 3600 * 1000);
d.setUTCHours(d.getUTCHours() >= 12 ? 12 : 0, 0, 0, 0);
return d;
}
/**
* @param {HTMLElement} host
* @param {{ handle, engine, store, sources?, options?, setTimeSeries, setTimeSeriesLoading }} ctx
*/
export function createControls(host, {
handle,
engine,
store,
sources = null,
options = {},
setTimeSeries,
setTimeSeriesLoading,
}) {
const doc = host.ownerDocument;
const H = (...args) => h(doc, ...args);
const opts = options && typeof options === 'object' ? options : {};
let currentSources = sources;
let bufkitMap = null;
const bufkitCache = new Map();
const refs = {};
const sections = {};
let removeLoadListener = null;
// ── helpers ──────────────────────────────────────────────────────────────────────────────
const button = (label, onclick, attrs = {}) => H('button', { type: 'button', class: 'sjs-button', onclick, ...attrs }, label);
const toggle = (key, title, onclick) => {
const value = H('span');
const b = H('button', { type: 'button', class: 'sjs-button', onclick, 'aria-pressed': 'false' }, H('span', {}, title), value);
refs[key] = { button: b, value };
return b;
};
const field = (label, control) => H('label', { class: 'sjs-field' }, H('span', {}, label), control);
const input = (key, attrs = {}) => (refs[key] = H('input', { class: 'sjs-input', ...attrs }));
const select = (key, options, attrs = {}) =>
(refs[key] = H('select', { class: 'sjs-select', ...attrs }, Object.entries(options).map(([v, t]) => H('option', { value: v }, t))));
const row = (cols, ...children) => H('div', { class: 'sjs-row', style: `--cols:${cols}` }, ...children);
const fieldRow = (cols, ...children) => H('div', { class: 'sjs-field-row', style: `--cols:${cols}` }, ...children);
const num = (el) => (el.value.trim() === '' ? undefined : Number(el.value));
/**
* Create a collapsible controls section and persist its expanded state.
* @param {string} id Stable section identifier.
* @param {string} title Visible section heading.
* @param {...Node} children Content nodes for the section.
* @returns {HTMLDetailsElement} Created details element.
*/
function section(id, title, ...children) {
const collapsed = store.get().collapsedSections.includes(id);
const el = H('details', { class: 'sjs-section', 'data-section': id, open: !collapsed }, H('summary', {}, title), ...children);
el.addEventListener('toggle', () => {
const set = new Set(store.get().collapsedSections);
if (el.open) set.delete(id);
else set.add(id);
store.set({ collapsedSections: [...set] });
});
sections[id] = el;
return el;
}
const statusEl = H('p', { class: 'sjs-status', role: 'status', 'aria-live': 'polite' });
let statusTimer = null;
/**
* Display a status message and mark errors for assistive technology and styling.
* @param {string} message Text to display.
* @param {boolean} isError Whether the message represents an error.
* @returns {void} Updates the controls status element.
*/
function status(message, isError = false) {
clearTimeout(statusTimer);
statusEl.textContent = message || '';
statusEl.classList.toggle('is-error', !!isError);
if (message && !isError) statusTimer = setTimeout(() => { statusEl.textContent = ''; }, 6000);
}
/**
* Run an asynchronous controls action and show its progress or error.
* @param {string} label Progress message shown while the action runs.
* @param {Function} fn Asynchronous action to invoke.
* @returns {Promise<void>} Resolves after the action or after its error is displayed.
*/
async function run(label, fn) {
status(label);
try {
await fn();
status('');
} catch (err) {
status(err.message || String(err), true);
}
}
// ── file pickers ─────────────────────────────────────────────────────────────────────────
const fileInput = H('input', { type: 'file', multiple: true, hidden: true });
let fileMode = 'auto';
fileInput.addEventListener('change', () => {
const files = [...fileInput.files];
fileInput.value = '';
if (files.length) handle.loadFiles(files, fileMode);
});
const pick = (mode) => {
fileMode = mode;
fileInput.click();
};
// ── sections ─────────────────────────────────────────────────────────────────────────────
const fileSection = section('file', 'Sounding',
row(1, button('Open sounding file…', () => pick('auto'))),
row(2, button('Add comparison…', () => pick('compare')), button('Clear comparisons', () => handle.clearComparisons())),
row(1, button('Average several files…', () => pick('average'))),
H('p', { class: 'sjs-hint' }, 'Or drop files on the chart (Shift+drop adds comparisons).'),
H('div', { class: 'sjs-sub' }, 'Export'),
field('Data format', select('exportFormat', EXPORTS, { onchange: (e) => engine.setExportFormat(e.target.value) })),
row(2, button('Download data', () => { if (!handle.downloadText()) status('Load a sounding first', true); }), button('Save image', () => run('Rendering image…', async () => { if (!(await handle.downloadImage())) throw new Error('Load a sounding first'); }))),
row(1, button('Copy image', () => run('Copying…', async () => { if (!(await handle.copyImage())) throw new Error('Load a sounding first'); status('Image copied'); }))),
fileInput,
);
const archive = H('div');
const archiveSection = section('archive', 'Archive Data', archive);
/**
* Rebuild the archive controls from the currently available data-source callbacks.
* @returns {void} Updates archive panel contents and visibility.
*/
function renderArchive() {
archive.innerHTML = '';
if (!currentSources) {
archiveSection.hidden = true;
return;
}
archiveSection.hidden = false;
const syn = lastSynoptic();
const children = [];
if (currentSources.fetchIemRaob) {
children.push(
H('div', { class: 'sjs-sub' }, 'Observed RAOB (IEM)'),
fieldRow(2,
field('Station', input('raobStation', { placeholder: 'OAX', value: opts.raobStation || 'OAX', autocomplete: 'off' })),
field('Hour (UTC)', input('raobHour', { type: 'number', min: 0, max: 23, value: 12 }))),
field('Date (UTC)', input('raobDate', { type: 'date', value: utcDate(syn) })),
row(1, button('Load RAOB', () => run('Searching the IEM archive…', async () => {
const time = new Date(`${refs.raobDate.value}T${String(refs.raobHour.value).padStart(2, '0')}:00:00Z`);
handle.load(await currentSources.fetchIemRaob({ station: refs.raobStation.value, time }));
}))),
);
refs.raobHour.value = String(syn.getUTCHours());
}
if (currentSources.fetchIemBufkit) {
bufkitMap ??= createBufkitMapButton({
document: doc,
host,
getModel: () => refs.bufkitModel.value,
getRun: () => new Date(`${refs.bufkitDate.value}T${String(refs.bufkitRun.value || 0).padStart(2, '0')}:00:00Z`),
onSelect: (station) => { refs.bufkitStation.value = station; },
});
children.push(
H('div', { class: 'sjs-sub' }, 'Model BUFKIT (IEM)'),
fieldRow(2,
field('Model', select('bufkitModel', BUFKIT_MODELS)),
field('Run date', input('bufkitDate', { type: 'date', value: utcDate(syn) })),
fieldRow(2,
field('Station', input('bufkitStation', { placeholder: 'dsm', value: opts.bufkitStation || 'kdsm', autocomplete: 'off' }))),
row(1, bufkitMap.button),
),
fieldRow(2,
field('Run (Z)', input('bufkitRun', { type: 'number', min: 0, max: 23, value: syn.getUTCHours() })),
field('F hour', input('bufkitFh', { type: 'number', min: 0, max: 384, value: 0 }))),
H('p', { class: 'sjs-hint' }, 'RAP/RUC use 4-letter ids (kdsm); HRRR/NAM/GFS often use 3 (dsm).'),
row(1, button('Load BUFKIT', () => run('Downloading BUFKIT…', async () => {
const run = new Date(`${refs.bufkitDate.value}T${String(refs.bufkitRun.value || 0).padStart(2, '0')}:00:00Z`);
const query = {
model: refs.bufkitModel.value,
station: refs.bufkitStation.value,
run,
};
if (currentSources.fetchIemBufkitForecasts) {
const cacheKey = `${query.model}|${query.station.trim().toLowerCase()}|${run.toISOString()}`;
if (!bufkitCache.has(cacheKey)) {
const promise = currentSources.fetchIemBufkitForecasts(query);
bufkitCache.set(cacheKey, promise);
promise.catch(() => {
if (bufkitCache.get(cacheKey) === promise) bufkitCache.delete(cacheKey);
});
while (bufkitCache.size > 3) {
bufkitCache.delete(bufkitCache.keys().next().value);
}
}
const soundings = await bufkitCache.get(cacheKey);
const timeline = expandBufkitTimeSeries(soundings);
const selectedHour = Number(refs.bufkitFh.value) || 0;
const selected = timeline.findIndex((item) => item.sounding?.meta?.forecastHour === selectedHour);
if (selected === -1) throw new Error(`F${selectedHour} is not available in this BUFKIT file`);
setTimeSeries(timeline, selected);
} else {
handle.load(await currentSources.fetchIemBufkit({
...query,
forecastHour: Number(refs.bufkitFh.value) || 0,
}));
}
}))),
);
refs.bufkitModel.value = 'rap';
}
if (currentSources.fetchNexradVwp) {
children.push(
H('div', { class: 'sjs-sub' }, 'NEXRAD VAD Wind Profile'),
field('Radar', input('vwpRadar', { placeholder: 'OAX', value: opts.vwpRadar || 'OAX', autocomplete: 'off' })),
...(currentSources.fetchNexradVwpArchive ? [field('Archive date (UTC)', input('vwpDate', {
type: 'date',
min: '2020-01-01',
max: utcDate(new Date()),
value: utcDate(new Date()),
}))] : []),
...(currentSources.fetchNexradVwpArchive ? [field('Archive hour (UTC)', input('vwpHour', {
type: 'number',
min: 0,
max: 23,
value: new Date().getUTCHours(),
}))] : []),
row(2,
button('Latest VWP', () => run('Fetching VWP…', async () => {
const snd = await currentSources.fetchNexradVwp({ radar: refs.vwpRadar.value, background: handle.getState().vwpActive ? undefined : handle.getSounding() || undefined });
handle.setVwp(snd);
})),
(refs.vwpExit = button('Back to sounding', () => handle.setVwp(null)))),
...(currentSources.fetchNexradVwpArchive ? [row(1, button('Load 3-hour VWP archive', () => run('Loading VWP archive…', async () => {
const hour = Number(refs.vwpHour.value);
if (!refs.vwpDate.value || !Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error('Choose a valid VWP archive date and hour');
}
const end = new Date(`${refs.vwpDate.value}T${String(hour).padStart(2, '0')}:00:00Z`);
if (end.getTime() > Date.now()) throw new Error('VWP archive time must not be in the future');
const archiveStart = Date.UTC(2020, 0, 1);
const start = new Date(Math.max(end.getTime() - 3 * 60 * 60 * 1000, archiveStart));
const items = [];
setTimeSeries([]);
setTimeSeriesLoading(true);
try {
await currentSources.fetchNexradVwpArchive({
radar: refs.vwpRadar.value,
start,
end,
background: handle.getState().vwpActive ? undefined : handle.getSounding() || undefined,
onVolume(sounding, progress) {
items.push({ kind: 'vwp', time: sounding.volumeUnix * 1000, sounding });
status(`Loaded VWP volume ${progress.index} of ${progress.total}`);
},
});
setTimeSeries(items, items.length - 1);
} finally {
setTimeSeriesLoading(false);
}
})))] : []),
H('p', { class: 'sjs-hint' }, 'Uses the loaded sounding for temperature/moisture, or a standard atmosphere.'),
);
}
archive.append(...children);
}
const parcelSection = section('parcel', 'Parcel',
row(2, ...PARCELS.map(([id, label]) => (refs[`parcel${id}`] = button(label, () => selectParcel(id))))),
field('Custom parcel pressure (mb)', input('parcelPressure', { type: 'number', min: 100, max: 1100, step: 5, placeholder: '900', onchange: () => selectParcel('CU') })),
);
const motionSection = section('motion', 'Storm Motion',
row(2, ...MOTIONS.map(([id, label]) => (refs[`motion${id}`] = button(label, () => selectMotion(id))))),
fieldRow(2,
field('Custom dir (°)', input('motionDir', { type: 'number', min: 0, max: 360, placeholder: '240', onchange: () => selectMotion('CM') })),
field('Custom speed (kt)', input('motionSpd', { type: 'number', min: 0, max: 100, placeholder: '30', onchange: () => selectMotion('CM') }))),
);
const displaySection = section('display', 'Display',
H('div', { class: 'sjs-stack' },
toggle('verticalBars', 'Vertical bars', () => engine.ChangeWBox()),
toggle('hodoNumbers', 'Hodograph heights', () => engine.ChangeStateHodographNumbers()),
toggle('colorTable', 'Hodograph colors', () => engine.ChangeStateHodographColorTable()),
toggle('hodoScale', 'Hodograph scale', () => engine.ChangeStateDynamicHodo()),
toggle('trimWind', 'Hodograph wind', () => engine.setHodographTrimWindProfile(!engine.getState().hodographTrimWindProfile)),
toggle('stormMotions', 'Storm motions', () => engine.LoadStormMotions()),
toggle('skew', 'Skew-T skew', () => engine.ChangeStateSkewTSkew()),
),
field('Hodograph mode', select('hodoMode', { Relative: 'Hodo-relative (centered)', Storm: 'Storm-relative (fixed)' }, { onchange: (e) => engine.SetHodoMode(e.target.value) })),
field('Units', select('units', UNITS, { onchange: (e) => engine.applyState({ units: e.target.value }) })),
);
const presetChildren = Object.keys(PRESET_VALUES).map((type) =>
field(PRESET_TITLES[type], (refs[`preset${type}`] = H('select', { class: 'sjs-select', onchange: (e) => engine.setPreset(type, e.target.value) },
PRESET_VALUES[type].map((v) => H('option', { value: v }, v === 'auto' ? 'Auto' : PRESET_LABELS[type][v] || v))))),
);
const efField = field('Tornado filter', select('efScale', { 'EF2+': 'Significant (EF2+)', 'EF0+': 'All tornadoes (EF0+)' }, { onchange: (e) => engine.SetEFScale(e.target.value) }));
const presetSection = section('presets', 'Presets', ...presetChildren, efField,
H('p', { class: 'sjs-hint' }, 'Auto picks presets from each sounding (winter, fire, elevated, …). Pinned choices are remembered.'));
const cursorSection = section('cursor', 'Cursor Tools',
H('div', { class: 'sjs-stack' },
toggle('readout', 'Read-out', () => setReadout(!engine.getState().readOut)),
toggle('front', 'Draw front (hodograph)', () => engine.ChangeFront()),
toggle('modify', 'Modify sounding', () => engine.setModifyMode(!engine.getState().modifyActive))),
row(2, button('Reset Skew-T', () => engine.ResetSkewT()), button('Reset hodograph', () => engine.ResetHodograph())),
);
const surfaceSection = section('surface', 'Modify Surface',
fieldRow(3,
field('Temp', input('sfcTemp', { type: 'number', step: 'any' })),
field('Dewpoint', input('sfcDew', { type: 'number', step: 'any' })),
field('Unit', select('sfcUnit', { F: '°F', C: '°C', K: 'K' }, { onchange: (e) => store.set({ surfaceTempUnit: e.target.value }) }))),
row(2,
button('Reset temps', () => engine.LoadNewSfc('Old', 'tmp')),
button('Apply temps', () => engine.LoadNewSfc('New', 'tmp', { temperature: num(refs.sfcTemp), dewpoint: num(refs.sfcDew), unit: refs.sfcUnit.value }))),
fieldRow(2,
field('Wind dir (°)', input('sfcWd', { type: 'number', min: 0, max: 360 })),
field('Wind speed (kt)', input('sfcWs', { type: 'number', min: 0 }))),
row(2,
button('Reset wind', () => engine.LoadNewSfc('Old', 'wnd')),
button('Apply wind', () => engine.LoadNewSfc('New', 'wnd', { direction: num(refs.sfcWd), speed: num(refs.sfcWs) }))),
);
const layerSection = section('layers', 'Custom Layers',
H('div', { class: 'sjs-sub' }, 'Lapse rate'),
fieldRow(3,
field('Bottom', input('lapseBot', { type: 'number', step: 'any', placeholder: '0' })),
field('Top', input('lapseTop', { type: 'number', step: 'any', placeholder: '3' })),
field('Mode', select('lapseMode', { Hght: 'km AGL', Pres: 'mb' }, { onchange: (e) => store.set({ lapseLayerMode: e.target.value }) }))),
row(2,
button('Reset', () => engine.LoadNewLapseLyr('R')),
button('Apply', () => engine.LoadNewLapseLyr('N', { bottom: refs.lapseBot.value, top: refs.lapseTop.value, mode: refs.lapseMode.value }))),
H('div', { class: 'sjs-sub' }, 'Wind shear'),
fieldRow(3,
field('Bottom', input('shearBot', { type: 'number', step: 'any', placeholder: '0' })),
field('Top', input('shearTop', { type: 'number', step: 'any', placeholder: '6' })),
field('Mode', select('shearMode', { Hght: 'km AGL', Pres: 'mb' }, { onchange: (e) => store.set({ shearLayerMode: e.target.value }) }))),
row(2,
button('Reset', () => engine.LoadNewShearLyr('R')),
button('Apply', () => engine.LoadNewShearLyr('N', { bottom: refs.shearBot.value, top: refs.shearTop.value, mode: refs.shearMode.value }))),
);
const savedNote = H('p', { class: 'sjs-saved' });
const settingsSection = section('settings', 'Settings',
savedNote,
row(1, button('Reset all settings', () => {
if (typeof confirm === 'function' && !confirm('Reset every SharpJS setting to its default?')) return;
handle.resetSettings();
status('Settings reset');
})),
);
host.append(statusEl, fileSection, archiveSection, parcelSection, motionSection, displaySection, presetSection, cursorSection, surfaceSection, layerSection, settingsSection);
renderArchive();
// ── actions ──────────────────────────────────────────────────────────────────────────────
/**
* Apply the selected parcel to the chart and persist non-custom parcel choices.
* @param {string} id Parcel identifier (SB, ML, MU, or CU).
* @returns {void} Updates chart parcel state.
*/
function selectParcel(id) {
engine.LoadNewParcel(id, id === 'CU' ? num(refs.parcelPressure) : undefined);
if (id !== 'CU') engine.applyState({ defaultParcel: id }, { redraw: false });
}
/**
* Apply the selected storm motion, including custom direction and speed when requested.
* @param {string} id Storm-motion identifier.
* @returns {void} Updates chart storm-motion state.
*/
function selectMotion(id) {
const custom = id === 'CM' ? { direction: num(refs.motionDir), speed: num(refs.motionSpd) } : null;
engine.LoadNewWind(id, true, custom);
if (id !== 'CM') engine.applyState({ defaultMotion: id }, { redraw: false });
}
/**
* Toggle cursor readout and disable modification mode when readout is enabled.
* @param {boolean} on Whether cursor readout should be active.
* @returns {void} Updates chart interaction state.
*/
function setReadout(on) {
engine.setReadOutOnState(on);
if (on) engine.setModifyMode(false);
else engine.CursorReadout(false);
engine.applyState({ readOut: on }, { redraw: false });
}
// ── render ───────────────────────────────────────────────────────────────────────────────
/**
* Update a toggle control's displayed label and pressed state.
* @param {string} key Toggle reference key.
* @param {string} label Text displayed in the toggle.
* @param {boolean} pressed Whether the toggle is pressed.
* @returns {void} Updates the referenced control when it exists.
*/
function setToggle(key, label, pressed) {
const r = refs[key];
if (!r) return;
r.value.textContent = label;
r.button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
}
const setValue = (key, value) => {
const el = refs[key];
if (el && doc.activeElement !== el && el.value !== String(value)) el.value = String(value);
};
/**
* Synchronize controls with the current chart state and persisted UI settings.
* @param {object} state Chart-engine state.
* @param {object} settings User settings.
* @returns {void} Updates controls and section visibility.
*/
function render(state = engine.getState(), settings = store.get()) {
for (const [id] of PARCELS) refs[`parcel${id}`].setAttribute('aria-pressed', state.parcel === id ? 'true' : 'false');
for (const [id] of MOTIONS) refs[`motion${id}`].setAttribute('aria-pressed', state.motion === id ? 'true' : 'false');
setToggle('verticalBars', state.verticalBarsWindSpeed ? 'Full' : 'No wind speed', !state.verticalBarsWindSpeed);
setToggle('hodoNumbers', state.hodographNumbers ? 'On' : 'Off', state.hodographNumbers);
setToggle('colorTable', COLOR_TABLES[state.hodographColorTable] || 'Default', state.hodographColorTable !== 0);
setToggle('hodoScale', SCALE_MODES[state.hodographScaleMode] || 'None', state.hodographScaleMode !== 0);
setToggle('trimWind', state.hodographTrimWindProfile ? 'Trimmed' : 'Full resolution', !state.hodographTrimWindProfile);
setToggle('stormMotions', state.showStormMotions ? 'Shown' : 'Hidden', state.showStormMotions);
setToggle('skew', SKEWS[state.skewTSkew] || 'Default', state.skewTSkew !== 0.75);
setToggle('readout', state.readOut ? 'On' : 'Off', state.readOut);
setToggle('front', state.frontActive ? 'On' : 'Off', state.frontActive);
setToggle('modify', state.modifyActive ? 'On' : 'Off', state.modifyActive);
setValue('hodoMode', state.hodographMode);
setValue('units', state.units);
setValue('exportFormat', state.exportFormat);
setValue('efScale', state.efScale);
setValue('sfcUnit', settings.surfaceTempUnit);
setValue('lapseMode', settings.lapseLayerMode);
setValue('shearMode', settings.shearLayerMode);
for (const type of Object.keys(PRESET_VALUES)) {
const sel = refs[`preset${type}`];
const pinned = state.presetOverrides[type];
const autoValue = state.autoPresets[type];
sel.options[0].textContent = `Auto (${PRESET_LABELS[type][autoValue] || autoValue})`;
setValue(`preset${type}`, pinned || 'auto');
if (type === 'SidePanel') {
const tornado = [...sel.options].find((o) => o.value === 'Tornado');
if (tornado) tornado.disabled = !state.tornadoClimoAvailable;
}
}
efField.hidden = state.presets.SidePanel !== 'Tornado';
if (refs.vwpExit) refs.vwpExit.disabled = !state.vwpActive;
const vwp = state.vwpActive;
parcelSection.hidden = vwp;
surfaceSection.hidden = vwp;
layerSection.hidden = vwp;
cursorSection.hidden = vwp;
savedNote.textContent = store.storage === 'memory'
? 'Settings are kept for this page only.'
: `Settings are saved in this browser (${store.storage === 'cookie' ? 'cookie' : store.storage === 'localStorage' ? 'local storage' : store.storage}).`;
}
render();
removeLoadListener = handle.on('load', (sounding) => {
const meta = sounding.meta;
if (meta?.source !== 'bufkit') return;
if (refs.bufkitFh) refs.bufkitFh.value = String(meta.forecastHour);
if (meta.run && refs.bufkitDate && refs.bufkitRun) {
const run = new Date(meta.run);
refs.bufkitDate.value = utcDate(run);
refs.bufkitRun.value = String(run.getUTCHours());
}
});
return {
render,
status,
setSources(next) {
currentSources = next || null;
renderArchive();
render();
},
destroy() {
removeLoadListener?.();
clearTimeout(statusTimer);
bufkitMap?.destroy();
host.innerHTML = '';
},
};
}