Source: viewer/time-slider.js

/**
 * Create the fixed, chart-width timeline and keep it clear of the controls panel.
 * @param {HTMLElement} element Timeline container.
 * @param {HTMLElement} stage Chart display area.
 * @param {HTMLElement} controls Controls panel.
 * @param {Document} doc Owning document.
 * @param {(index: number, item: object, reset: boolean) => void} onSelect Displays a timeline item.
 * @returns {{ setItems: (items: object[], selected?: number, reset?: boolean) => void, setLoading: (loading: boolean) => void, clear: () => void, destroy: () => void }}
 */
export function createTimeSlider(element, stage, controls, doc, onSelect) {
  const view = doc.defaultView;
  const playButton = doc.createElement('button');
  playButton.type = 'button';
  playButton.className = 'sjs-button sjs-time-play';
  playButton.setAttribute('aria-label', 'Play timeline');
  playButton.textContent = '▶';

  const range = doc.createElement('input');
  range.type = 'range';
  range.className = 'sjs-time-range';
  range.min = '0';
  range.max = '0';
  range.value = '0';
  range.setAttribute('aria-label', 'Sounding time');

  const label = doc.createElement('output');
  label.className = 'sjs-time-label';
  label.setAttribute('aria-live', 'polite');

  element.replaceChildren(playButton, range, label);
  element.hidden = true;

  let items = [];
  let selected = 0;
  let playback = null;
  let resizeObserver = null;
  let hasItems = false;
  let loading = false;

  function pause() {
    if (playback !== null) {
      view.clearInterval(playback);
      playback = null;
    }
    playButton.textContent = '▶';
    playButton.setAttribute('aria-label', 'Play timeline');
    playButton.setAttribute('aria-pressed', 'false');
  }

  function formatTime(milliseconds) {
    const time = new Date(milliseconds);
    const pad = (value) => String(value).padStart(2, '0');
    return `${time.getUTCFullYear()}-${pad(time.getUTCMonth() + 1)}-${pad(time.getUTCDate())} ${pad(time.getUTCHours())}:${pad(time.getUTCMinutes())}Z`;
  }

  function position() {
    if (!hasItems) return;
    const rect = stage.getBoundingClientRect();
    if (rect.width <= 0) return;
    const height = Math.min(view.innerHeight * 0.05, 44);
    const controlsRect = controls.isConnected ? controls.getBoundingClientRect() : null;
    const controlsBelowStage = controlsRect
      && controlsRect.top >= rect.bottom - 1
      && controlsRect.top < view.innerHeight
      && controlsRect.bottom > 0;
    if (controlsBelowStage && controlsRect.top < height) {
      element.hidden = true;
      return;
    }
    element.hidden = false;
    const top = controlsBelowStage ? Math.max(0, controlsRect.top - height) : view.innerHeight - height;
    element.style.left = `${rect.left}px`;
    element.style.top = `${top}px`;
    element.style.width = `${rect.width}px`;
    element.style.height = `${height}px`;
  }

  function select(index, reset = false) {
    if (!items.length) return;
    const next = Math.max(0, Math.min(items.length - 1, index));
    if (items[next].disabled) {
      range.value = String(selected);
      return;
    }
    selected = next;
    range.value = String(selected);
    label.value = formatTime(items[selected].time);
    label.textContent = label.value;
    onSelect(selected, items[selected], reset);
  }

  function setItems(nextItems, initialIndex = nextItems.length - 1, reset = true) {
    pause();
    const previousTime = items[selected]?.time;
    items = nextItems.filter((item) => Number.isFinite(item.time) || item.disabled);
    const available = items.filter((item) => !item.disabled).length;
    hasItems = available > 0;
    element.hidden = available === 0;
    playButton.hidden = items.length > 0 && items.every((item) => item.kind === 'vwp');
    range.max = String(Math.max(0, items.length - 1));
    range.disabled = available < 2;
    playButton.disabled = loading || available < 2 || playButton.hidden;
    if (available === 0) {
      label.value = '';
      label.textContent = '';
      position();
      return;
    }
    const retainedIndex = !reset && Number.isFinite(previousTime)
      ? items.findIndex((item) => !item.disabled && item.time === previousTime)
      : -1;
    const target = retainedIndex >= 0
      ? retainedIndex
      : items[initialIndex] && !items[initialIndex].disabled
      ? initialIndex
      : items.findIndex((item) => !item.disabled);
    select(target, reset);
    position();
  }

  function setLoading(value) {
    loading = value === true;
    if (loading) pause();
    playButton.disabled = loading || items.filter((item) => !item.disabled).length < 2 || playButton.hidden;
  }

  function advance() {
    let next = selected + 1;
    while (next < items.length && items[next].disabled) next++;
    if (next >= items.length) {
      pause();
      return;
    }
    select(next);
    if (selected >= items.length - 1) pause();
  }

  range.addEventListener('input', () => {
    pause();
    select(Number(range.value));
  });
  playButton.addEventListener('click', () => {
    if (playback !== null || items.length < 2 || loading || playButton.hidden) {
      pause();
      return;
    }
    const lastAvailable = items.findLastIndex((item) => !item.disabled);
    if (selected >= lastAvailable) select(items.findIndex((item) => !item.disabled));
    playButton.textContent = 'Ⅱ';
    playButton.setAttribute('aria-label', 'Pause timeline');
    playButton.setAttribute('aria-pressed', 'true');
    playback = view.setInterval(advance, 1000);
  });

  view.addEventListener('resize', position);
  view.addEventListener('scroll', position, { passive: true });
  if (view.ResizeObserver) {
    resizeObserver = new view.ResizeObserver(position);
    resizeObserver.observe(stage);
    if (controls.isConnected) resizeObserver.observe(controls);
  }

  return {
    setItems,
    setLoading,
    clear() {
      pause();
      items = [];
      hasItems = false;
      loading = false;
      element.hidden = true;
      range.max = '0';
      range.value = '0';
      range.disabled = true;
      playButton.disabled = true;
      playButton.hidden = false;
      label.value = '';
      label.textContent = '';
    },
    destroy() {
      pause();
      resizeObserver?.disconnect();
      view.removeEventListener('resize', position);
      view.removeEventListener('scroll', position);
      element.replaceChildren();
    },
  };
}

/**
 * Expand available BUFKIT forecasts into hourly positions, marking gaps unavailable.
 * @param {object[]} soundings Parsed BUFKIT forecast soundings.
 * @returns {object[]} Hourly slider positions in valid-time order.
 */
export function expandBufkitTimeSeries(soundings) {
  const available = new Map(soundings.map((sounding) => [sounding.meta.forecastHour, sounding]));
  const hours = [...available.keys()].sort((a, b) => a - b);
  if (!hours.length || !hours.every(Number.isInteger)) {
    return soundings.map((sounding) => ({
      kind: 'bufkit',
      time: Date.parse(sounding.meta.valid),
      sounding,
    }));
  }
  const firstHour = hours[0];
  const firstTime = Date.parse(available.get(firstHour).meta.valid);
  const runTime = firstTime - firstHour * 60 * 60 * 1000;
  return Array.from({ length: hours.at(-1) - firstHour + 1 }, (_, index) => {
    const forecastHour = firstHour + index;
    const sounding = available.get(forecastHour);
    return {
      kind: 'bufkit',
      time: runTime + forecastHour * 60 * 60 * 1000,
      sounding,
      disabled: !sounding,
    };
  });
}