record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation
export const CANVAS_COMPONENTS = Object.freeze({
  record: true,
  syncRings: true,
  strobeLamp: true,
  spindle: true,
  stylus: true,
  needlePoint: true,
  tonearmGuide: true,
  startStop: true,
  needle: true,
  rpm: true,
  volume: true,
  crossfader: true,
  seek: true,
  labels: true
});

export const CANVAS_THEME = Object.freeze({
  background: "transparent",
  line: "#050505",
  mutedLine: "rgba(5,5,5,0.28)",
  controlFill: "rgba(255,255,255,0.08)",
  controlActive: "#050505",
  controlText: "#050505",
  controlActiveText: "#f00020",
  accent: "#00bfd3",
  accentSecondary: "#ef035c",
  accentTertiary: "#f3b511",
  recordFallback: "transparent",
  turntableRing: "rgba(5,5,5,0.18)",
  turntableRingStrong: "rgba(5,5,5,0.34)",
  label: "transparent",
  syncDot: "rgba(0,0,0,0.23)",
  syncLit: "#00bfd3",
  lamp: "#00bfd3",
  tonearm: "#050505",
  tonearmGuide: "rgba(5,5,5,0.35)",
  stylus: "#00bfd3",
  stylusGlow: "rgba(0,191,211,0.65)"
});

export const STROBE_ROWS = Object.freeze([
  { pitchPct: -3.3, count: 150, dotScale: 0.65 },
  { pitchPct: 0, count: 132, dotScale: 1.3 },
  { pitchPct: 3.3, count: 138, dotScale: 0.65 },
  { pitchPct: 6, count: 120, dotScale: 0.65 }
]);

export const STROBE_LAMP_DEG = 135;
export const STROBE_BEAM_FULL_DEG = 4;
export const STROBE_BEAM_HALF_DEG = 11;
export const STROBE_DEG_PER_SEC = 320;
export const STROBE_CONT_DEG_PER_SEC = 150;
export const PITCH_RANGE_PCT = 8;
export const FONT_FAMILY = '"Archivo Black", "Arial Black", Helvetica, Arial, sans-serif';

export function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

export function degToRad(value) {
  return value * Math.PI / 180;
}

export function radToDeg(value) {
  return value * 180 / Math.PI;
}

export function normalizeDegrees(value) {
  return ((value % 360) + 360) % 360;
}

export function angularDeltaDegrees(a, b) {
  return Math.abs((((a - b + 540) % 360) - 180));
}

export function minuteToDegrees(minute) {
  return minute * 6 - 90;
}

export function pointOnCircle(geometry, angleDeg, radius) {
  const angle = degToRad(angleDeg);
  return {
    x: geometry.cx + Math.cos(angle) * radius,
    y: geometry.cy + Math.sin(angle) * radius
  };
}

export function buildCanvasGeometry(width, height) {
  const scale = Math.max(0.55, Math.min(width / 800, height / 840));
  const localWidth = 800 * scale;
  const localHeight = 840 * scale;
  const left = (width - localWidth) / 2;
  const top = (height - localHeight) / 2;
  const recordSize = 576 * scale;
  const recordRadius = recordSize / 2;
  const cx = left + localWidth / 2;
  const cy = top + localHeight / 2;
  const buttonHeight = 30 * scale;
  const innerRadius = recordRadius + 3;
  const outerRadius = innerRadius + buttonHeight;
  return {
    width,
    height,
    scale,
    left,
    top,
    localWidth,
    localHeight,
    cx,
    cy,
    recordRadius,
    innerRadius,
    outerRadius,
    buttonHeight,
    controlBandInner: outerRadius + 6 * scale,
    controlBandOuter: outerRadius + 24 * scale
  };
}

export function localPointer(canvas, event) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
    rect
  };
}

export function pointerPolar(geometry, point) {
  const dx = point.x - geometry.cx;
  const dy = point.y - geometry.cy;
  return {
    radius: Math.hypot(dx, dy),
    angle: normalizeDegrees(radToDeg(Math.atan2(dy, dx)))
  };
}

export function unwrapAngleNear(angle, reference) {
  let value = angle;
  while (value - reference > 180) value -= 360;
  while (value - reference < -180) value += 360;
  return value;
}

export function arcValue(angle, startAngle, endAngle, reverse = false) {
  const start = startAngle;
  const end = unwrapAngleNear(endAngle, start);
  const current = unwrapAngleNear(angle, start);
  const low = Math.min(start, end);
  const high = Math.max(start, end);
  const projected = clamp(current, low, high);
  const ratio = Math.abs(end - start) > 0.001 ? (projected - start) / (end - start) : 0;
  return reverse ? 1 - clamp(ratio, 0, 1) : clamp(ratio, 0, 1);
}