lab-js/realtime/realtime-radial.jsjavascript
/**
 * @module realtime-radial
 * @description
 * A compact distribution of descriptor change. Absolute deltas are ranked,
 * then reduced into 16 RMS bands. The spokes are magnitudes, not stable latent
 * coordinates or facial regions.
 */

/**
 * Sorts absolute descriptor deltas and reduces them to equal-sized RMS bands.
 * Ranking makes the plot legible while deliberately discarding coordinate
 * identity: it compares the distribution of change, not where change lives.
 *
 * @param {Float32Array|number[]} values Descriptor deltas.
 * @param {number} [bandCount=16] Number of ranked bands.
 * @returns {number[]} Ascending RMS magnitudes.
 */
export function rankedDeltaBands(values, bandCount = 16) {
  const ranked = Array.from(values || [], (value) => Number.isFinite(value) ? Math.abs(value) : 0)
    .sort((a, b) => a - b);
  if (!ranked.length || bandCount < 1) return [];
  return Array.from({ length: Math.min(bandCount, ranked.length) }, (_, index) => {
    const start = Math.floor((index * ranked.length) / bandCount);
    const end = Math.max(start + 1, Math.floor(((index + 1) * ranked.length) / bandCount));
    const band = ranked.slice(start, end);
    return Math.sqrt(band.reduce((sum, value) => sum + value * value, 0) / band.length);
  });
}

/**
 * Converts descriptor deltas to radial points.
 *
 * @param {Float32Array|number[]} values Signed descriptor deltas.
 * @param {object} [options] Geometry and scale.
 * @param {number} [options.centerX=0] Plot centre x.
 * @param {number} [options.centerY=0] Plot centre y.
 * @param {number} [options.radius=1] Maximum radius.
 * @param {number} [options.maxValue=0.15] Delta mapped to the outer ring.
 * @returns {Array<{x: number, y: number, value: number, index: number}>} Points in descriptor order.
 */
export function radialPoints(values, options = {}) {
  const {
    centerX = 0,
    centerY = 0,
    radius = 1,
    maxValue = 0.15,
  } = options;
  const count = values?.length || 0;
  if (!count) return [];

  return Array.from(values, (raw, index) => {
    const value = Number.isFinite(raw) ? Math.abs(raw) : 0;
    const magnitude = Math.min(1, value / maxValue) * radius;
    const angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
    return {
      x: centerX + Math.cos(angle) * magnitude,
      y: centerY + Math.sin(angle) * magnitude,
      value,
      index,
    };
  });
}

/**
 * Creates the live/snapshot radial descriptor plot.
 *
 * @param {object} options Controller options.
 * @param {HTMLCanvasElement} options.canvasEl Plot canvas.
 * @param {number} [options.maxDelta=0.15] Delta mapped to the outer ring.
 * @returns {object} Plot controller.
 */
export function createRadialGraph({ canvasEl, maxDelta = 0.15 }) {
  const ctx = canvasEl.getContext('2d');
  let live = null;
  let measured = null;

  function css(name, fallback) {
    const value = getComputedStyle(canvasEl).getPropertyValue(name).trim();
    return value || fallback;
  }

  function path(points) {
    if (!points.length) return;
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let index = 1; index < points.length; index += 1) {
      ctx.lineTo(points[index].x, points[index].y);
    }
    ctx.closePath();
  }

  /** Repaints the plot from the current live and measured deltas. */
  function draw() {
    const rect = canvasEl.getBoundingClientRect();
    const width = Math.max(1, Math.floor(rect.width));
    const height = Math.max(1, Math.floor(rect.height));
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    if (canvasEl.width !== Math.floor(width * dpr) || canvasEl.height !== Math.floor(height * dpr)) {
      canvasEl.width = Math.floor(width * dpr);
      canvasEl.height = Math.floor(height * dpr);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, width, height);

    const centerX = width / 2;
    const centerY = height / 2;
    const radius = Math.max(4, Math.min(width, height) / 2 - 10);
    const grid = css('--rt-grid', 'rgba(255,231,168,0.12)');

    ctx.strokeStyle = grid;
    ctx.lineWidth = 1;
    for (const fraction of [0.25, 0.5, 0.75, 1]) {
      ctx.beginPath();
      ctx.arc(centerX, centerY, radius * fraction, 0, Math.PI * 2);
      ctx.stroke();
    }
    for (let index = 0; index < 16; index += 1) {
      const angle = -Math.PI / 2 + (index / 16) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(centerX, centerY);
      ctx.lineTo(centerX + Math.cos(angle) * radius, centerY + Math.sin(angle) * radius);
      ctx.stroke();
    }

    if (measured?.length) {
      const points = radialPoints(measured, { centerX, centerY, radius, maxValue: maxDelta });
      path(points);
      ctx.fillStyle = css('--rt-radial-measured-fill', 'rgba(242,106,27,0.25)');
      ctx.strokeStyle = css('--rt-radial-measured', '#f26a1b');
      ctx.lineWidth = 1.5;
      ctx.fill();
      ctx.stroke();
    }

    if (live?.length) {
      const points = radialPoints(live, { centerX, centerY, radius, maxValue: maxDelta });
      path(points);
      ctx.strokeStyle = css('--rt-radial-live', '#ffe7a8');
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }

  const observer = new ResizeObserver(draw);
  observer.observe(canvasEl);

  return {
    draw,
    setLive(values) {
      live = values ? rankedDeltaBands(values) : null;
      draw();
    },
    setMeasured(values) {
      measured = values ? rankedDeltaBands(values) : null;
      draw();
    },
    reset() {
      live = null;
      measured = null;
      draw();
    },
    destroy() {
      observer.disconnect();
    },
  };
}