Source: viewer/index.js

/**
 * SharpJS(target, props): one call that draws an interactive sounding and handles the rest
 * (controls, saved settings, file drop, exports). Call it again with new props to update.
 *
 *   import { SharpJS } from 'sharpjs';
 *   SharpJS('#chart', { sounding: text });            // mount
 *   SharpJS('#chart', { sounding: nextText });        // update the same chart
 *
 * It also returns a handle for imperative use (load, screenshot, getSettings, destroy, …).
 */
import { createEngine } from './engine.js';
import { renderChart } from './template.js';
import { injectStyles } from './styles.js';
import { createControls } from './controls.js';
import { createSettingsStore } from '../settings/store.js';
import { toSounding, isSounding } from '../io/input.js';
import { withLocation } from '../io/location.js';
import { buildVwpSounding } from '../io/vwp.js';
import { isBufkitText, listBufkitForecastHours, parseBufkit } from '../io/bufkit.js';
import { analyze } from '../analysis.js';
import { VERSION } from '../version.js';
import { createTimeSlider, expandBufkitTimeSeries } from './time-slider.js';

/** Settings the chart engine owns (the rest belong to the controls panel). */
const ENGINE_SETTINGS = [
  'units', 'skewTSkew', 'hodographNumbers', 'hodographColorTable', 'hodographScaleMode',
  'hodographTrimWindProfile', 'hodographMode', 'readOut', 'verticalBarsWindSpeed', 'showStormMotions',
  'presetOverrides', 'efScale', 'defaultParcel', 'defaultMotion', 'exportFormat',
];

const COMPARISON_COLORS = ['rgb(0, 225, 255)', 'rgb(255, 0, 0)', 'rgb(246, 255, 0)', 'rgb(85, 255, 0)', 'rgb(170, 0, 255)', 'rgba(255, 255, 255, 1)'];

const instances = new WeakMap();

/**
 * Resolve a selector or validate an existing element as the chart mount target.
 * @param {Element|string} target CSS selector or DOM element.
 * @returns {Element} The element that will contain the chart.
 */
function resolveTarget(target) {
  if (typeof target === 'string') {
    if (typeof document === 'undefined') throw new Error('SharpJS: no document');
    const el = document.querySelector(target);
    if (!el) throw new Error(`SharpJS: no element matches ${target}`);
    return el;
  }
  if (!target || typeof target.appendChild !== 'function') throw new Error('SharpJS: target must be an element or selector');
  return target;
}

const pick = (obj, keys) => Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, obj[k]]));
const safeName = (s) => String(s || 'sounding').trim().replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'sounding';

/** Text in any supported format, or binary (NEXRAD VWP)? */
async function readFile(file) {
  const buffer = await file.arrayBuffer();
  const bytes = new Uint8Array(buffer);
  let binary = false;
  for (let i = 0; i < Math.min(bytes.length, 2048); i++) {
    const b = bytes[i];
    if (b === 0 || (b < 9 && b !== 4)) { binary = true; break; }
  }
  if (!binary || /\.txt$|\.buf$|\.csv$|\.json$/i.test(file.name)) {
    const text = new TextDecoder().decode(bytes);
    if (text.includes('%RAW%') || text.includes('STID') || text.includes('"level"') || !/\p{L}/u.test(text)) {
      return { name: file.name, text };
    }
  }
  return { name: file.name, buffer };
}

/**
 * @param {Element|string} target
 * @param {object} [props]  see README → "SharpJS(target, props)"
 * @returns {object} handle
 */
export function SharpJS(target, props = {}) {
  const el = resolveTarget(target);
  const existing = instances.get(el);
  if (existing) {
    existing.update(props);
    return existing;
  }
  const handle = mount(el, props);
  instances.set(el, handle);
  return handle;
}

/** The handle of the chart mounted on `target`, if any. */
SharpJS.get = (target) => instances.get(resolveTarget(target)) || null;
SharpJS.version = VERSION;

/**
 * Create the chart DOM, engine, settings store, controls, and public handle for one target.
 * @param {Element} el Validated container element.
 * @param {object} initialProps Initial chart, data, and settings options.
 * @returns {object} Mounted chart handle.
 */
function mount(el, initialProps) {
  let props = { ...initialProps };
  let destroyed = false;
  const listeners = new Map();
  const on = (type, fn) => {
    if (!listeners.has(type)) listeners.set(type, new Set());
    listeners.get(type).add(fn);
    return () => listeners.get(type)?.delete(fn);
  };
  const fire = (type, detail) => {
    const propName = `on${type[0].toUpperCase()}${type.slice(1)}`;
    if (typeof props[propName] === 'function') {
      try { props[propName](detail); } catch (err) { console.error(err); }
    }
    for (const fn of listeners.get(type) || []) {
      try { fn(detail); } catch (err) { console.error(err); }
    }
  };

  if (props.injectStyles !== false) injectStyles(el.ownerDocument);

  // ── DOM ────────────────────────────────────────────────────────────────────────────────
  const root = el.ownerDocument.createElement('div');
  root.className = 'sharpjs';
  if (props.className) root.classList.add(...String(props.className).split(/\s+/).filter(Boolean));
  const controlsHost = el.ownerDocument.createElement('aside');
  controlsHost.className = 'sjs-controls';
  controlsHost.setAttribute('aria-label', 'Sounding controls');
  const stage = el.ownerDocument.createElement('div');
  stage.className = 'sjs-stage';
  const timeSlider = el.ownerDocument.createElement('div');
  timeSlider.className = 'sjs-timeslider';
  root.append(controlsHost, stage, timeSlider);
  el.appendChild(root);
  const viz = renderChart(stage);
  const q = (id) => viz.querySelector(`[data-sjs="${id}"]`);
  const empty = el.ownerDocument.createElement('div');
  empty.className = 'sjs-empty';
  viz.appendChild(empty);
  const renderEmpty = () => {
    empty.innerHTML = '';
    const strong = el.ownerDocument.createElement('strong');
    strong.textContent = props.emptyTitle || 'No sounding loaded';
    const msg = el.ownerDocument.createElement('span');
    msg.textContent = props.emptyMessage || (props.fileDrop === false
      ? 'Pass a sounding to SharpJS() to draw it.'
      : 'Drop a sounding file here (SHARPpy, CM1, BUFKIT, Chaser Archive JSON or NEXRAD VWP), or load one from the controls.');
    empty.append(strong, msg);
  };
  renderEmpty();

  // ── data (declared first: callbacks below read it) ───────────────────────────────────
  let main = null; // parsed main sounding (never the VWP variant)
  let comparisons = [];
  let soundingKey;
  let vwpOn = false;
  let controls = null;
  const hasSounding = () => !!(main || vwpOn);
  const timeSliderController = createTimeSlider(timeSlider, stage, controlsHost, el.ownerDocument, (_index, item, reset) => {
    displayTimeSeriesItem(item, reset);
  });

  function setTimeSeries(items, selected, reset = true) {
    timeSliderController.setItems(items, selected, reset);
  }

  function setTimeSeriesLoading(loading) {
    timeSliderController.setLoading(loading);
  }

  function clearTimeSeries() {
    timeSliderController.clear();
  }

  // ── settings ───────────────────────────────────────────────────────────────────────────
  const store = createSettingsStore({
    persist: props.persist ?? 'auto',
    key: props.storageKey || 'sharpjs:settings',
    defaults: props.defaultSettings,
    overrides: props.settings,
  });

  // ── engine ─────────────────────────────────────────────────────────────────────────────
  let stateTimer = null;
  let lastStateJson = '';
  let syncingFromEngine = false;
  const engineOptions = {
    brandLabel: props.brandLabel,
    savedBy: props.savedBy,
    filePrefix: props.filePrefix,
    font: props.font,
    emit: (type, detail) => {
      if (type === 'error') {
        reportError(new Error(detail));
        return;
      }
      scheduleStateCheck();
    },
  };
  const engine = createEngine(viz, engineOptions);
  engine.attach();

  /**
   * Queue one state synchronization after engine updates settle.
   * @returns {void} Synchronizes persisted settings, controls, and change listeners asynchronously.
   */
  function scheduleStateCheck() {
    if (stateTimer !== null || destroyed) return;
    stateTimer = Promise.resolve().then(() => {
      stateTimer = null;
      if (destroyed) return;
      const state = engine.getState();
      const json = JSON.stringify(state);
      if (json === lastStateJson) return;
      lastStateJson = json;
      syncingFromEngine = true;
      try {
        store.set(pick(state, ENGINE_SETTINGS));
      } finally {
        syncingFromEngine = false;
      }
      controls?.render(state, store.get());
      fire('change', state);
    });
  }

  store.subscribe((settings) => {
    if (!syncingFromEngine) {
      const current = engine.getState();
      const changed = ENGINE_SETTINGS.filter((k) => JSON.stringify(settings[k]) !== JSON.stringify(current[k]));
      if (changed.length) engine.applyState(pick(settings, changed), { redraw: hasSounding() });
    }
    controls?.render(engine.getState(), settings);
    fire('settingsChange', settings);
  });

  engine.applyState(pick(store.get(), ENGINE_SETTINGS), { redraw: false });

  // ── data ───────────────────────────────────────────────────────────────────────────────
  /**
   * Normalize and report an error through the callback, event listeners, console, and controls.
   * @param {*} err Error or error-like value to report.
   * @returns {void} Notifies configured error surfaces.
   */
  function reportError(err) {
    const error = err instanceof Error ? err : new Error(String(err));
    if (!listeners.get('error')?.size && typeof props.onError !== 'function') console.error(error);
    fire('error', error);
    controls?.status(error.message, true);
  }

  /**
   * Set whether the empty-chart prompt is visible.
   * @param {boolean} show Whether to show the prompt.
   * @returns {void} Updates the prompt display style.
   */
  function showEmpty(show) {
    empty.style.display = show ? '' : 'none';
  }

  /**
   * Parse comparison inputs and attach labels, visibility, and chart colors.
   * @param {Array<object|string>} list Comparison sounding inputs and optional metadata.
   * @returns {object[]} Valid comparison records ready for the chart engine.
   */
  function normalizeComparisons(list) {
    if (!Array.isArray(list)) return [];
    return list
      .map((item, i) => {
        const input = item && (item.sounding ?? item);
        let parsed;
        try {
          parsed = toSounding(input);
        } catch (err) {
          reportError(err);
          return null;
        }
        const label = item.label || String(parsed.STitle || parsed.Title || `Sounding ${i + 1}`).trim().slice(0, 48);
        return {
          sounding: parsed,
          metadata: {
            comparisonColor: item.color || COMPARISON_COLORS[i % COMPARISON_COLORS.length],
            isVisible: item.visible !== false,
            title: label,
            date: '',
            model: '',
            run: '',
            fh: '',
          },
        };
      })
      .filter(Boolean);
  }

  /**
   * Toggle the chart layout when the active sounding represents dropsonde data.
   * @param {object|null} snd Parsed sounding, if one is active.
   * @returns {void} Updates the chart's dropsonde-mode class.
   */
  function setDropsondeLayout(snd) {
    const isDrop = !!(snd && (snd.isDropsonde === true || /dropsonde|·\s*drop\b/i.test(`${snd.STitle || ''} ${snd.Title || ''}`)));
    viz.classList.toggle('dropsonde-mode', isDrop);
  }

  /**
   * Draw the main sounding and comparison overlays, leaving VWP mode.
   * @param {object} options Drawing options.
   * @param {boolean} options.reset Whether to reset chart zoom and layout state.
   * @returns {void} Updates the engine and empty-state display.
   */
  function drawMain({ reset }) {
    if (!main) return;
    setDropsondeLayout(main);
    engine.setVwpActive(false);
    vwpOn = false;
    engine.Load(main, true, { reset, comparisons });
    showEmpty(false);
  }

  function displayTimeSeriesItem(item, reset) {
    if (item.kind === 'vwp') {
      applyVwpSounding(item.sounding);
      return;
    }
    main = item.sounding;
    setDropsondeLayout(main);
    vwpOn = false;
    engine.setVwpActive(false);
    engine.Load(main, true, { reset, comparisons });
    showEmpty(false);
    fire('load', main);
    scheduleStateCheck();
  }

  /**
   * Load the main sounding.
   * @param {string|object} input
   * @param {{ title?: string, key?: string, reset?: boolean, location?: {lat:number, lon:number}, parseOptions?: object }} [opts]
   */
  function load(input, opts = {}) {
    try {
      let parsed = toSounding(input, opts.parseOptions);
      let timeline = [];
      let selectedIndex = 0;
      if (typeof input === 'string' && isBufkitText(input)) {
        const hours = listBufkitForecastHours(input);
        const soundings = hours.map((forecastHour) => {
          const sounding = forecastHour === parsed.meta?.forecastHour
            ? parsed
            : parseBufkit(input, { ...opts.parseOptions, forecastHour });
          return sounding;
        });
        timeline = expandBufkitTimeSeries(soundings);
        selectedIndex = Math.max(0, timeline.findIndex((item) => item.sounding?.meta?.forecastHour === parsed.meta?.forecastHour));
        parsed = timeline[selectedIndex]?.sounding || parsed;
      } else if (parsed.meta?.source === 'bufkit' && parsed.meta.valid) {
        timeline = expandBufkitTimeSeries([parsed]);
      }
      for (const item of timeline) {
        if (opts.location) withLocation(item.sounding, opts.location);
        if (opts.title || props.title) {
          item.sounding.Title = opts.title || props.title;
          item.sounding.STitle = item.sounding.Title;
        }
      }
      if (!timeline.length) {
        if (opts.location) withLocation(parsed, opts.location);
        if (opts.title || props.title) {
          parsed.Title = opts.title || props.title;
          parsed.STitle = parsed.Title;
        }
      }
      const key = opts.key;
      const reset = opts.reset ?? !(key !== undefined && key === soundingKey);
      soundingKey = key;
      main = parsed;
      if (timeline.length) setTimeSeries(timeline, selectedIndex, reset);
      else {
        clearTimeSeries();
        drawMain({ reset });
        fire('load', parsed);
      }
      scheduleStateCheck();
      return parsed;
    } catch (err) {
      reportError(err);
      return null;
    }
  }

  /**
   * Remove the active sounding, comparisons, VWP state, and chart drawings.
   * @returns {void} Resets the chart to its empty state.
   */
  function clear() {
    clearTimeSeries();
    main = null;
    comparisons = [];
    vwpOn = false;
    engine.TurnOff();
    viz.classList.remove('dropsonde-mode');
    for (const c of viz.querySelectorAll('canvas')) {
      const ctx = c.getContext('2d');
      ctx?.clearRect(0, 0, c.width, c.height);
    }
    const modal = q('modifySkewTModal');
    if (modal) modal.style.display = 'none';
    showEmpty(true);
    scheduleStateCheck();
  }

  /**
   * Replace the active sounding comparison overlays.
   * @param {Array<object|string>} list Comparison sounding inputs.
   * @returns {void} Updates the chart engine when a main sounding is displayed.
   */
  function setComparisons(list) {
    comparisons = normalizeComparisons(list);
    if (main && !vwpOn) engine.setComparisons(comparisons);
    scheduleStateCheck();
  }

  /** Overlay more soundings (text / parsed / arrays) on the current one. */
  function compare(inputs) {
    const items = (Array.isArray(inputs) ? inputs : [inputs]).map((s) => (s && s.sounding ? s : { sounding: s }));
    const offset = comparisons.length;
    const added = normalizeComparisons(items).map((c, i) => {
      if (!items[i].color) c.metadata.comparisonColor = COMPARISON_COLORS[(offset + i) % COMPARISON_COLORS.length];
      return c;
    });
    comparisons = comparisons.concat(added);
    if (main && !vwpOn) engine.setComparisons(comparisons);
    scheduleStateCheck();
  }

  /** Average several soundings into one (drawn with the members as ghosts). */
  function average(texts) {
    try {
      const list = (Array.isArray(texts) ? texts : [texts]).filter((t) => typeof t === 'string');
      if (list.length < 2) throw new Error('SharpJS: averaging needs at least two sounding texts');
      clearTimeSeries();
      setDropsondeLayout(null);
      engine.AverageAll(list.map((t, i) => [`${i}`, t]));
      main = engine.getSnding();
      vwpOn = false;
      showEmpty(false);
      scheduleStateCheck();
    } catch (err) {
      reportError(err);
    }
  }

  /**
   * Show a NEXRAD VWP. `input` is a VWP sounding (buildVwpSounding / sources.fetchNexradVwp) or raw
   * NVW bytes. Pass null to go back to the main sounding.
   */
  function applyVwpSounding(stored) {
    viz.classList.remove('dropsonde-mode');
    vwpOn = true;
    engine.applyNexradVwpStoredSounding(stored);
    showEmpty(false);
    scheduleStateCheck();
  }

  function setVwp(input, opts = {}) {
    try {
      if (!input) {
        clearTimeSeries();
        if (vwpOn) {
          vwpOn = false;
          if (main) drawMain({ reset: false });
          else clear();
        }
        scheduleStateCheck();
        return null;
      }
      const stored = isSounding(input) ? input : buildVwpSounding(input, { background: main || undefined, ...opts });
      if (!stored) throw new Error('SharpJS: VWP product has no wind levels');
      if (Number.isFinite(stored.volumeUnix)) {
        setTimeSeries([{ kind: 'vwp', time: stored.volumeUnix * 1000, sounding: stored }], 0);
      } else {
        clearTimeSeries();
        applyVwpSounding(stored);
      }
      return stored;
    } catch (err) {
      reportError(err);
      return null;
    }
  }

  /**
   * Read selected files and load, compare, average, or display their sounding data.
   * @param {FileList|File[]} files Browser files to read.
   * @param {string} mode Loading behavior: auto, compare, or average.
   * @returns {Promise<void>} Completes after files are read and the selected operation is applied.
   */
  async function loadFiles(files, mode = 'auto') {
    const list = [...(files || [])];
    if (list.length === 0) return;
    try {
      const read = await Promise.all(list.map(readFile));
      const binary = read.find((r) => r.buffer);
      if (binary && read.length === 1) {
        setVwp(binary.buffer, { radar: (/([A-Z]{3})[_-]?NVW|NVW[_-]?([A-Z]{3})/i.exec(binary.name) || []).slice(1).find(Boolean) });
        return;
      }
      const texts = read.filter((r) => r.text);
      if (texts.length === 0) throw new Error('SharpJS: no readable sounding files');
      if (mode === 'average') return average(texts.map((t) => t.text));
      if (mode === 'compare' && main) return compare(texts.map((t) => ({ sounding: toSounding(t.text, { fileName: t.name }), label: t.name })));
      load(texts[0].text, { parseOptions: { fileName: texts[0].name } });
      if (texts.length > 1) {
        compare(texts.slice(1).map((t) => ({ sounding: toSounding(t.text, { fileName: t.name }), label: t.name })));
      }
    } catch (err) {
      reportError(err);
    }
  }

  // ── exports ────────────────────────────────────────────────────────────────────────────
  /**
   * Ask the chart engine to serialize the active sounding.
   * @param {string} format Export format identifier.
   * @returns {object|null} Export text and filename, or null when no sounding is available.
   */
  function exportText(format) {
    return engine.GetSoundingTextContent(format);
  }

  /**
   * Trigger a browser download for a Blob and revoke its temporary object URL.
   * @param {Blob} blob File content.
   * @param {string} filename Suggested download filename.
   * @returns {void} Starts a browser download.
   */
  function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = el.ownerDocument.createElement('a');
    a.href = url;
    a.download = filename;
    a.style.display = 'none';
    el.ownerDocument.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  /**
   * Export the active sounding as text and trigger its download.
   * @param {string} format Export format identifier.
   * @returns {object|null} Export payload, or null when no sounding can be exported.
   */
  function downloadText(format) {
    const data = exportText(format);
    if (!data) return null;
    downloadBlob(new Blob([data.text], { type: 'text/plain;charset=utf-8' }), data.filename);
    return data;
  }

  /** PNG data URL of the whole chart (2000×1200 × scale). */
  function screenshot({ scale = 1 } = {}) {
    if (!hasSounding()) return Promise.resolve(null);
    return engine.Screenshot(false, scale);
  }

  /**
   * Capture the chart and download it as a PNG image.
   * @param {object} options Image options.
   * @param {number} options.scale Image scale multiplier (in unitless).
   * @returns {Promise<string|null>} PNG data URL, or null when no sounding is available.
   */
  async function downloadImage({ scale = 2 } = {}) {
    const url = await screenshot({ scale });
    if (!url) return null;
    const blob = await (await fetch(url)).blob();
    const title = engine.getSnding()?.Title;
    downloadBlob(blob, `${props.filePrefix || 'SharpJS_Sounding'}_${safeName(title)}.png`);
    return url;
  }

  /**
   * Capture the chart and copy its PNG image to the system clipboard.
   * @param {object} options Image options.
   * @param {number} options.scale Image scale multiplier (in unitless).
   * @returns {Promise<boolean>} Whether an image was copied.
   */
  async function copyImage({ scale = 2 } = {}) {
    const url = await screenshot({ scale });
    if (!url) return false;
    if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) throw new Error('Copying images is not supported in this browser');
    const blob = await (await fetch(url)).blob();
    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
    return true;
  }

  // ── pointer, wheel, help, drop ─────────────────────────────────────────────────────────
  const cleanups = [];
  const listen = (target, type, fn, opts) => {
    target.addEventListener(type, fn, opts);
    cleanups.push(() => target.removeEventListener(type, fn, opts));
  };
  const skewT = q('skewT');
  const hodo = q('hodograph');
  listen(skewT, 'click', (e) => engine.Flip(e));
  listen(skewT, 'mousemove', (e) => hasSounding() && engine.SkewTMouseMoved(e));
  listen(skewT, 'mousedown', (e) => engine.SkewTMouseDown(e));
  listen(skewT, 'wheel', (e) => { e.preventDefault(); if (hasSounding()) engine.SkewTWheel(e); }, { passive: false });
  listen(hodo, 'click', (e) => engine.Front(e));
  listen(hodo, 'mousemove', (e) => hasSounding() && engine.HodographMouseMoved(e));
  listen(hodo, 'mousedown', (e) => engine.HodographMouseDown(e));
  listen(hodo, 'wheel', (e) => { e.preventDefault(); if (hasSounding()) engine.HodographWheel(e); }, { passive: false });

  const help = q('helpOverlay');
  listen(viz, 'click', (e) => {
    const action = e.target.closest?.('[data-sjs-action]')?.dataset.sjsAction;
    if (action === 'help-open') help.hidden = false;
    else if (action === 'help-close' || e.target === help) help.hidden = true;
  });
  listen(el.ownerDocument, 'keydown', (e) => {
    if (e.key === 'Escape' && !help.hidden) help.hidden = true;
  });

  if (props.fileDrop !== false) {
    let depth = 0;
    listen(viz, 'dragenter', (e) => {
      if (![...(e.dataTransfer?.types || [])].includes('Files')) return;
      e.preventDefault();
      depth += 1;
      viz.classList.add('is-dragover');
      showEmpty(true);
    });
    listen(viz, 'dragover', (e) => {
      if ([...(e.dataTransfer?.types || [])].includes('Files')) e.preventDefault();
    });
    listen(viz, 'dragleave', () => {
      depth = Math.max(0, depth - 1);
      if (depth === 0) {
        viz.classList.remove('is-dragover');
        showEmpty(!hasSounding());
      }
    });
    listen(viz, 'drop', (e) => {
      if (!e.dataTransfer?.files?.length) return;
      e.preventDefault();
      depth = 0;
      viz.classList.remove('is-dragover');
      showEmpty(!hasSounding());
      loadFiles(e.dataTransfer.files, e.shiftKey ? 'compare' : 'auto');
    });
  }

  // Fonts can finish loading after the first draw; repaint once when they do.
  if (el.ownerDocument.fonts?.ready) {
    el.ownerDocument.fonts.ready.then(() => {
      if (!destroyed && hasSounding()) engine.redraw();
    });
  }

  // ── handle ─────────────────────────────────────────────────────────────────────────────
  const handle = {
    version: VERSION,
    element: root,
    /** The underlying chart engine (lower-level drawing API; see viewer/engine.js). */
    engine,
    /** The settings store (get / set / reset / subscribe). */
    settings: store,
    update,
    load,
    clear,
    compare,
    setComparisons,
    clearComparisons: () => setComparisons([]),
    average,
    setVwp,
    loadFiles,
    /** Current sounding (parsed), or null. */
    getSounding: () => (hasSounding() ? engine.getSnding() : null),
    getState: () => engine.getState(),
    getSettings: () => store.get(),
    setSettings: (partial) => store.set(partial),
    resetSettings: () => store.reset(),
    /** Headless analysis of the displayed sounding with the chart's parcel and storm motion. */
    analysis(options = {}) {
      const snd = engine.getSnding();
      if (!snd || !snd.Sounding) return null;
      const state = engine.getState();
      const motion = state.motion === 'CM' ? state.customMotion : state.motion;
      return analyze(snd, { parcel: state.parcel, parcelPressure: state.customParcelPressure, motion, ...options });
    },
    exportText,
    downloadText,
    screenshot,
    downloadImage,
    copyImage,
    on,
    redraw: () => engine.redraw(),
    destroy,
  };

  controls = props.controls === false
    ? null
    : createControls(controlsHost, {
      handle,
      engine,
      store,
      sources: props.sources,
      options: props.controls,
      setTimeSeries,
      setTimeSeriesLoading,
    });
  if (!controls) controlsHost.remove();

  /**
   * Merge new chart properties and update affected data, settings, and drawings.
   * @param {object} next Properties to merge into the current chart configuration.
   * @returns {object} The chart handle, including when the chart has already been destroyed.
   */
  function update(next = {}) {
    if (destroyed) return handle;
    props = { ...props, ...next };
    if ('brandLabel' in next) engineOptions.brandLabel = next.brandLabel;
    if ('savedBy' in next) engineOptions.savedBy = next.savedBy;
    if ('filePrefix' in next) engineOptions.filePrefix = next.filePrefix;
    if ('emptyMessage' in next || 'emptyTitle' in next) renderEmpty();
    if (next.settings) store.set(next.settings);
    if ('sources' in next) controls?.setSources(next.sources);

    let redrawHodo = false;
    if ('mapImage' in next) {
      engine.SetMapImage(next.mapImage || null);
      redrawHodo = true;
    }
    if ('comparisons' in next) comparisons = normalizeComparisons(next.comparisons);

    if ('sounding' in next) {
      if (next.sounding == null) clear();
      else load(next.sounding, { key: next.soundingKey, title: next.title, location: next.location });
    } else if ('comparisons' in next) {
      setComparisons(next.comparisons);
    } else if ('title' in next && main) {
      main.Title = next.title || main.Title;
      main.STitle = main.Title;
      drawMain({ reset: false });
    } else if (redrawHodo && hasSounding()) {
      engine.redraw();
    }

    if ('vwp' in next) setVwp(next.vwp);
    scheduleStateCheck();
    return handle;
  }

  /**
   * Remove event handlers, controls, engine resources, chart DOM, and instance registration.
   * @returns {void} Destroys the mounted chart.
   */
  function destroy() {
    if (destroyed) return;
    destroyed = true;
    for (const fn of cleanups) fn();
    controls?.destroy();
    timeSliderController.destroy();
    engine.detach();
    root.remove();
    instances.delete(el);
    listeners.clear();
  }

  // initial data
  showEmpty(true);
  if (props.vwp && !props.sounding) setVwp(props.vwp);
  if (props.sounding != null) {
    if (props.comparisons) comparisons = normalizeComparisons(props.comparisons);
    load(props.sounding, { key: props.soundingKey, title: props.title, location: props.location });
    if (props.vwp) setVwp(props.vwp);
  }
  if (props.mapImage) {
    engine.SetMapImage(props.mapImage);
    if (hasSounding()) engine.redraw();
  }
  scheduleStateCheck();
  store.ready.then(() => scheduleStateCheck());
  return handle;
}