lab-js/realtime/realtime-settings.jsjavascript
/**
 * @module realtime-settings
 * @description
 * The bottom bar over the video stage, plus the persistence behind it.
 *
 * The match threshold is deliberately NOT adjustable here. 0.60 is the one
 * number on this page with a citable provenance — dlib's default, measured on
 * LFW — and a moved threshold means nothing at all. The information lives in
 * the measured noise floor and in how far the trace climbs above it, not in
 * where an operator decided to drag a line.
 *
 * The brightness control is display only. face-api reads raw frames from the
 * video element and CSS filters apply at composite, so turning this dial cannot
 * change a descriptor. That is stated in the control's tooltip so nobody later
 * reads an exposure change as a lighting-robustness result.
 */

const SETTINGS_KEY = 'gstmxx.realtime.settings';
const BASELINE_KEY = 'gstmxx.realtime.baseline';
const BASELINE_FORMAT = 3;
const CONDITION_KEYS = [
  'rollDegrees',
  'yawOffset',
  'boxWidthRatio',
  'centerXRatio',
  'centerYRatio',
  'luma',
];

const DEFAULT_SETTINGS = {
  brightness: 0.85,
  equalizer: true,
};

/**
 * Reads persisted view settings.
 *
 * @returns {{brightness: number, equalizer: boolean}} Stored or default settings.
 */
export function loadSettings() {
  try {
    const raw = window.localStorage.getItem(SETTINGS_KEY);
    if (!raw) return { ...DEFAULT_SETTINGS };
    const parsed = JSON.parse(raw);
    return {
      brightness: Number.isFinite(parsed.brightness) ? parsed.brightness : DEFAULT_SETTINGS.brightness,
      equalizer: typeof parsed.equalizer === 'boolean' ? parsed.equalizer : DEFAULT_SETTINGS.equalizer,
    };
  } catch {
    return { ...DEFAULT_SETTINGS };
  }
}

/**
 * Persists view settings.
 *
 * @param {object} settings Settings to store.
 * @returns {void}
 */
export function saveSettings(settings) {
  try {
    window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
  } catch {
    /* Storage disabled or full; settings simply do not persist. */
  }
}

/**
 * Reads a persisted baseline.
 *
 * The baseline is a biometric template. It never leaves the device, and
 * "Reset baseline" removes it outright rather than flipping a flag.
 *
 * @returns {{descriptor: number[], noiseFloor: number, capturedAt: number, sourceKind: 'camera', conditions: object, guide: object}|null} Stored baseline.
 */
export function loadBaseline() {
  try {
    const raw = window.localStorage.getItem(BASELINE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (parsed.format !== BASELINE_FORMAT) return null;
    if (!Array.isArray(parsed.descriptor) || parsed.descriptor.length !== 128) return null;
    if (parsed.descriptor.some((value) => !Number.isFinite(value))) return null;
    if (!Number.isFinite(parsed.noiseFloor)) return null;
    if (!Number.isFinite(parsed.capturedAt)) return null;
    if (parsed.sourceKind !== 'camera') return null;
    if (!parsed.conditions || CONDITION_KEYS.some((key) => !Number.isFinite(parsed.conditions[key]))) {
      return null;
    }
    if (
      !parsed.guide ||
      !Number.isFinite(parsed.guide.aspectRatio) ||
      !parsed.guide.box ||
      ['x', 'y', 'width', 'height'].some((key) => !Number.isFinite(parsed.guide.box[key])) ||
      !Array.isArray(parsed.guide.landmarks) ||
      parsed.guide.landmarks.length !== 68 ||
      parsed.guide.landmarks.some((point) => !Number.isFinite(point?.x) || !Number.isFinite(point?.y))
    ) {
      return null;
    }
    return parsed;
  } catch {
    return null;
  }
}

/**
 * Persists a baseline.
 *
 * @param {object} baseline Baseline record to store.
 * @returns {void}
 */
export function saveBaseline(baseline) {
  try {
    window.localStorage.setItem(
      BASELINE_KEY,
      JSON.stringify({ ...baseline, format: BASELINE_FORMAT }),
    );
  } catch {
    /* Storage disabled; the baseline holds for this session only. */
  }
}

/**
 * Removes the persisted baseline.
 *
 * @returns {void}
 */
export function clearBaseline() {
  try {
    window.localStorage.removeItem(BASELINE_KEY);
  } catch {
    /* Nothing to clear. */
  }
}

/**
 * Wraps a destructive button in a two-step confirmation.
 *
 * Deleting a baseline mid-session costs the whole session, and "Delete all"
 * costs every capture on the device. Neither should be one stray click.
 *
 * @param {HTMLButtonElement} button Button to guard.
 * @param {string} idleLabel Label in the resting state.
 * @param {string} confirmLabel Label while awaiting confirmation.
 * @param {function(): void} onConfirm Action to run on the second click.
 * @param {number} [timeoutMs] Time before the button reverts.
 * @returns {object} Guard handle exposing `reset()`.
 */
export function guardDestructive(button, idleLabel, confirmLabel, onConfirm, timeoutMs = 4000) {
  let armed = false;
  let timer = null;

  /**
   * Returns the button to its resting label.
   *
   * @returns {void}
   */
  function reset() {
    armed = false;
    button.textContent = idleLabel;
    button.classList.remove('is-confirming');
    if (timer) window.clearTimeout(timer);
    timer = null;
  }

  button.addEventListener('click', () => {
    if (!armed) {
      armed = true;
      button.textContent = confirmLabel;
      button.classList.add('is-confirming');
      timer = window.setTimeout(reset, timeoutMs);
      return;
    }
    reset();
    onConfirm();
  });

  return { reset };
}

/**
 * Creates the settings bar controller.
 *
 * @param {object} options Wiring options.
 * @param {HTMLElement} options.rootEl Element carrying the --rt-* custom properties.
 * @param {HTMLInputElement} options.brightnessEl Brightness range input.
 * @param {HTMLElement} options.brightnessValueEl Brightness readout.
 * @param {HTMLButtonElement} options.equalizerEl Equalizer switch.
 * @param {HTMLElement} options.layoutEl Grid root toggled by the equalizer switch.
 * @param {function(): void} options.onLayoutChange Called after a layout-affecting change.
 * @returns {object} Settings controller.
 */
export function createSettings(options) {
  const {
    rootEl,
    brightnessEl,
    brightnessValueEl,
    equalizerEl,
    layoutEl,
    onLayoutChange,
  } = options;

  const settings = loadSettings();

  /**
   * Applies the brightness value to the stage and the readout.
   *
   * @returns {void}
   */
  function applyBrightness() {
    rootEl.style.setProperty('--rt-brightness', String(settings.brightness));
    brightnessEl.value = String(settings.brightness);
    brightnessValueEl.textContent = settings.brightness.toFixed(2);
  }

  /**
   * Applies equalizer visibility to the grid.
   *
   * Hiding the right column resizes the left one without firing a window
   * resize event, which is why the graph observes its own container rather
   * than listening on window alone.
   *
   * @returns {void}
   */
  function applyEqualizer() {
    layoutEl.classList.toggle('hide-equalizer', !settings.equalizer);
    equalizerEl.setAttribute('aria-checked', String(settings.equalizer));
  }

  brightnessEl.addEventListener('input', () => {
    settings.brightness = Number(brightnessEl.value);
    applyBrightness();
    saveSettings(settings);
  });

  equalizerEl.addEventListener('click', () => {
    settings.equalizer = !settings.equalizer;
    applyEqualizer();
    saveSettings(settings);
    if (typeof onLayoutChange === 'function') onLayoutChange();
  });

  equalizerEl.addEventListener('keydown', (event) => {
    if (event.key !== ' ' && event.key !== 'Enter') return;
    event.preventDefault();
    equalizerEl.click();
  });

  applyBrightness();
  applyEqualizer();

  return {
    /**
     * Returns the live settings object.
     *
     * @returns {{brightness: number, equalizer: boolean}} Current settings.
     */
    values() {
      return settings;
    },
  };
}