audiorouter-dashboard 0.2.0

HTTP/SSE dashboard API and static-file host for audiorouter
import { BaseEdge, EdgeLabelRenderer, useEdges, useNodes, type EdgeProps } from "@xyflow/react";
import { useCallback, useMemo, useRef } from "react";
import type { RouteEdgeData } from "./flow-types";
import { useRouteUpdate } from "./route-update-context";

const STEP = 24;
const LABEL_MIN_SEP = 20;

/**
 * 1-D overlap resolution with centroid preservation.
 *
 * Items are pushed apart (forward + backward pass) until all neighbours
 * satisfy `minSep`, then the whole group is re-centred around its
 * original mean so it does not drift.
 */
function resolveOffsets(desired: number[], minSep: number): number[] {
  if (desired.length <= 1) return [...desired];
  const items = desired.map((y, i) => ({ y, i }));
  items.sort((a, b) => a.y - b.y);
  for (let k = 1; k < items.length; k++) {
    if (items[k].y - items[k - 1].y < minSep) items[k].y = items[k - 1].y + minSep;
  }
  for (let k = items.length - 2; k >= 0; k--) {
    if (items[k + 1].y - items[k].y < minSep) items[k].y = items[k + 1].y - minSep;
  }
  const origMean = desired.reduce((s, y) => s + y, 0) / desired.length;
  const newMean = items.reduce((s, p) => s + p.y, 0) / items.length;
  const shift = origMean - newMean;
  items.forEach((p) => {
    p.y += shift;
  });
  const result = [...desired];
  items.forEach((p) => {
    result[p.i] = p.y;
  });
  return result;
}

export function RouteEdge({
  id,
  source,
  target,
  sourceX,
  sourceY,
  targetX,
  targetY,
  data,
  selected,
}: EdgeProps) {
  const d = data as RouteEdgeData;
  const disabled = d?.disabled ?? false;
  const mute = d?.mute ?? false;
  const dim = disabled || mute;

  const parallelCount = Math.max(1, d?.parallelCount ?? 1);
  const parallelIndex = d?.parallelIndex ?? 0;
  const parallelOffset = (parallelIndex - (parallelCount - 1) / 2) * STEP;

  // ── Path geometry ──────────────────────────────────────────────────
  //
  // Single cubic bezier from handle to handle.
  // Three-segment paths cause G2 discontinuities (visible kinks) at the
  // junction points even when G1-continuous.  A single bezier distributes
  // the vertical movement of parallelOffset evenly over the full span so
  // the curve stays smooth everywhere.
  //
  // Control-point horizontal reach = 50 % of span (standard S-curve).
  // Control-point Y is shifted by parallelOffset so parallel routes on the
  // same pair arc away from each other in the middle.
  // Both handles are at the exact node handle coordinates. ✓
  const span = targetX - sourceX;
  const cpX = Math.abs(span) * 0.5;
  const edgePath = [
    `M ${sourceX} ${sourceY}`,
    `C ${sourceX + cpX} ${sourceY + parallelOffset}`,
    `  ${targetX - cpX} ${targetY + parallelOffset}`,
    `  ${targetX} ${targetY}`,
  ].join(" ");

  // Exact bezier midpoint at t = 0.5:
  //   x = ⅛·P0 + ⅜·CP1 + ⅜·CP2 + ⅛·P3
  //     = (sourceX + targetX) / 2  (symmetric control points)
  //   y = (sourceY + targetY) / 2 + 0.75 · parallelOffset
  const gainLabelX = (sourceX + targetX) / 2;
  const gainLabelY = (sourceY + targetY) / 2 + parallelOffset * 0.75;

  // Channel label X: small fixed inset from each handle (capped so labels
  // stay on the correct side for very short edges).
  const labelInset = Math.min(44, Math.abs(span) * 0.2);
  const srcLabelX = sourceX + labelInset;
  const tgtLabelX = targetX - labelInset;

  // ── Label repulsion ────────────────────────────────────────────────
  //
  // Sort siblings by the PEER node's Y position so label order always
  // matches the visual path order.  useNodes() re-fires on every drag so
  // labels re-order in real-time.
  const allEdges = useEdges();
  const allNodes = useNodes();

  const nodeYMap = useMemo(() => new Map(allNodes.map((n) => [n.id, n.position.y])), [allNodes]);

  // Source-side: sort by target node Y
  const sameSource = useMemo(
    () =>
      allEdges
        .filter((e) => e.source === source)
        .sort((a, b) => (nodeYMap.get(a.target) ?? 0) - (nodeYMap.get(b.target) ?? 0)),
    [allEdges, source, nodeYMap],
  );
  const srcRank = sameSource.findIndex((e) => e.id === id);
  const srcLabelOffset = useMemo(() => {
    const n = sameSource.length;
    const desired = sameSource.map((_, k) => (k - (n - 1) / 2) * STEP);
    return resolveOffsets(desired, LABEL_MIN_SEP)[srcRank] ?? 0;
  }, [sameSource, srcRank]);

  // Destination-side: sort by source node Y
  const sameDest = useMemo(
    () =>
      allEdges
        .filter((e) => e.target === target)
        .sort((a, b) => (nodeYMap.get(a.source) ?? 0) - (nodeYMap.get(b.source) ?? 0)),
    [allEdges, target, nodeYMap],
  );
  const dstRank = sameDest.findIndex((e) => e.id === id);
  const dstLabelOffset = useMemo(() => {
    const n = sameDest.length;
    const desired = sameDest.map((_, k) => (k - (n - 1) / 2) * STEP);
    return resolveOffsets(desired, LABEL_MIN_SEP)[dstRank] ?? 0;
  }, [sameDest, dstRank]);

  // ── Arrowhead at target end ────────────────────────────────────────
  //
  // SVG marker attached to the BaseEdge path so it inherits the same
  // z-index layer as the path (fixes overlap ordering with dimmed edges).
  // orient="auto" rotates the marker to match the path tangent at t=1.
  // userSpaceOnUse keeps pixel dimensions stable across zoom levels.
  const arrowLen = selected ? 13 : 9;
  const arrowHalf = selected ? 6.5 : 4.5;
  const markerId = `arrow-${id}`;

  // ── Colors ─────────────────────────────────────────────────────────
  // Dim variants: color-mix blends the original hue with the background
  // to produce an opaque faded color that doesn't bleed underlying objects.
  const dimRoute = "color-mix(in oklch, var(--color-ar-route) 40%, var(--color-background))";
  const dimIn = "color-mix(in oklch, var(--color-ar-in)    40%, var(--color-card))";
  const dimOut = "color-mix(in oklch, var(--color-ar-out)   40%, var(--color-card))";
  const dimGain = "color-mix(in oklch, var(--color-ar-gain)  40%, var(--color-card))";

  const selectedColor = dim ? "var(--color-ring-dim)" : "var(--color-ring)";
  const strokeColor = selected ? selectedColor : dim ? dimRoute : "var(--color-ar-route)";
  const strokeWidth = selected ? 4.5 : dim ? 1.5 : 2;
  const dashArray = dim ? "5 3" : undefined;

  const gainLabel = disabled
    ? "OFF"
    : mute
      ? "✕"
      : d.gain_db === 0
        ? "0dB"
        : `${d.gain_db > 0 ? "+" : ""}${d.gain_db.toFixed(1)}dB`;

  const srcLabel = d ? channelFmt(d.from_channels) : "";
  const dstLabel = d ? channelFmt(d.to_channels) : "";

  return (
    <>
      <defs>
        <marker
          id={markerId}
          markerWidth={arrowLen}
          markerHeight={arrowHalf * 2}
          refX={arrowLen}
          refY={arrowHalf}
          orient="auto"
          markerUnits="userSpaceOnUse"
        >
          <polygon
            points={`0 0, ${arrowLen} ${arrowHalf}, 0 ${arrowHalf * 2}`}
            fill={strokeColor}
          />
        </marker>
      </defs>
      <BaseEdge
        id={id}
        path={edgePath}
        interactionWidth={30}
        markerEnd={`url(#${markerId})`}
        style={{
          stroke: strokeColor,
          strokeWidth,
          strokeDasharray: dashArray,
        }}
      />
      <EdgeLabelRenderer>
        {/* Source channel label */}
        {srcLabel && (
          <div
            className="nodrag nopan absolute rounded px-1 font-mono text-[10px]"
            style={{
              transform: `translate(-50%, -50%) translate(${srcLabelX}px, ${sourceY + srcLabelOffset}px)`,
              pointerEvents: "none",
              color: selected ? selectedColor : dim ? dimIn : "var(--color-ar-in)",
              background: "var(--color-card)",
              fontWeight: 500,
            }}
          >
            {srcLabel}
          </div>
        )}

        {/* Gain control — minimal label at bezier midpoint.
            Click = toggle mute, vertical drag = adjust gain. */}
        <GainLabel
          x={gainLabelX}
          y={gainLabelY}
          label={gainLabel}
          gainDb={d.gain_db}
          mute={mute}
          disabled={disabled}
          selected={selected ?? false}
          selectedColor={selectedColor}
          dimGain={dimGain}
          routeColor={strokeColor}
          edgeId={id}
        />

        {/* Destination channel label */}
        {dstLabel && (
          <div
            className="nodrag nopan absolute rounded px-1 font-mono text-[10px]"
            style={{
              transform: `translate(-50%, -50%) translate(${tgtLabelX}px, ${targetY + dstLabelOffset}px)`,
              pointerEvents: "none",
              color: selected ? selectedColor : dim ? dimOut : "var(--color-ar-out)",
              background: "var(--color-card)",
              fontWeight: 500,
            }}
          >
            {dstLabel}
          </div>
        )}
      </EdgeLabelRenderer>
    </>
  );
}

function channelFmt(channels: number[]): string {
  return channels.join(",");
}

// ── GainLabel ──────────────────────────────────────────────────────
//
// A minimal label at the bezier midpoint — same visual style as the
// channel labels, but interactive.
//
//   click         → toggle mute
//   drag ↕        → adjust gain (-20 … +20 dB, 0.5 dB steps)
//   wheel         → fine-adjust gain (1 dB steps)
//
// Click vs drag is disambiguated by a 3px movement threshold.

const GAIN_MIN = -20;
const GAIN_MAX = 20;
const DRAG_THRESHOLD = 3;

interface GainLabelProps {
  x: number;
  y: number;
  label: string;
  gainDb: number;
  mute: boolean;
  disabled: boolean;
  selected: boolean;
  selectedColor: string;
  dimGain: string;
  routeColor: string;
  edgeId: string;
}

function GainLabel({
  x,
  y,
  label,
  gainDb,
  mute,
  disabled,
  selected,
  selectedColor,
  dimGain,
  routeColor,
  edgeId,
}: GainLabelProps) {
  const update = useRouteUpdate();
  const dragRef = useRef<{ startY: number; startGain: number; moved: boolean } | null>(null);

  const clampGain = (v: number) => Math.max(GAIN_MIN, Math.min(GAIN_MAX, v));

  const onPointerDown = useCallback(
    (e: React.PointerEvent) => {
      if (disabled) return;
      e.stopPropagation();
      (e.target as HTMLElement).setPointerCapture(e.pointerId);
      dragRef.current = { startY: e.clientY, startGain: gainDb, moved: false };
    },
    [disabled, gainDb],
  );

  const onPointerMove = useCallback(
    (e: React.PointerEvent) => {
      const s = dragRef.current;
      if (!s) return;
      // Up = increase, down = decrease
      const dy = s.startY - e.clientY;
      if (Math.abs(dy) > DRAG_THRESHOLD) s.moved = true;
      if (s.moved && update) {
        // 1px ≈ 0.25 dB, snapped to 0.5
        const raw = s.startGain + dy * 0.25;
        update(edgeId, { gain_db: Math.round(clampGain(raw) * 2) / 2 });
      }
    },
    [edgeId, update],
  );

  const onPointerUp = useCallback(
    (e: React.PointerEvent) => {
      const s = dragRef.current;
      dragRef.current = null;
      try {
        (e.target as HTMLElement).releasePointerCapture(e.pointerId);
      } catch {
        // already released
      }
      if (s && !s.moved && update && !disabled) {
        update(edgeId, { mute: !mute });
      }
    },
    [edgeId, mute, disabled, update],
  );

  const onWheel = useCallback(
    (e: React.WheelEvent) => {
      if (disabled || !update) return;
      e.stopPropagation();
      const step = e.shiftKey ? 0.5 : 1;
      update(edgeId, { gain_db: clampGain(gainDb + (e.deltaY < 0 ? step : -step)) });
    },
    [edgeId, gainDb, disabled, update],
  );

  const dim = disabled || mute;
  const textColor = disabled
    ? "var(--color-ar-disabled)"
    : mute
      ? "var(--color-ar-clip)"
      : selected
        ? selectedColor
        : dim
          ? dimGain
          : "var(--color-ar-gain)";

  // 0 dB + not mute + not disabled → show a dot in the route color
  const isDot = gainDb === 0 && !mute && !disabled;
  // Dot uses the same color as the edge path
  const dotColor = selected ? selectedColor : dim ? dimGain : routeColor;

  return (
    <div
      className="nodrag nopan absolute cursor-ns-resize"
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      onWheel={onWheel}
      style={{
        transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
        pointerEvents: disabled ? "none" : "auto",
        userSelect: "none",
      }}
    >
      {isDot ? (
        <div className="rounded-full" style={{ width: 8, height: 8, background: dotColor }} />
      ) : (
        <div
          className="rounded px-1.5 py-0.5 font-mono text-[11px]"
          style={{
            color: textColor,
            background: "var(--color-card)",
            fontWeight: 600,
          }}
        >
          {label}
        </div>
      )}
    </div>
  );
}