Files
feedBack/static/v3/pedal-cables.js
T
2026-06-16 18:47:13 +02:00

335 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* fee[dB]ack v0.3.0 — Pedalboard patch cables (decorative).
*
* Draws purely cosmetic 1/4" patch cables between the pedals on each board of
* the v3 Plugins page (#v3-plugins). Cables carry NO routing/audio meaning —
* they're eye candy. A light Verlet "rope" makes each cable sag under gravity
* and swing slightly, settling on load and reacting to drags / resize / scroll.
*
* Constraints honoured:
* - The SVG overlay is `pointer-events:none` so it never blocks pedal clicks.
* - One shared requestAnimationFrame loop; it runs ONLY while #v3-plugins is the
* active screen and stops when the screen is hidden (hooked off the same
* `screen:changed` event plugins-page uses).
* - `prefers-reduced-motion: reduce` → static (sagged but not swinging) cables,
* no rAF. A live drag still gets a one-shot redraw via refresh().
* - Cheap: a handful of segments per cable, anchors recomputed from
* getBoundingClientRect() each active frame so cables track moved pedals.
*/
(function () {
'use strict';
var SEGMENTS = 12; // points per seeded cable (used by the pure helpers/tests)
var MAX_CABLES = 200; // hard cap so a pathological board can't explode
var CABLE_SAG = 0.06; // dip of the cable curve as a fraction of its span (small = rigid)
var SCREEN_ID = 'v3-plugins';
// Distance from the socket (plug tip) to the boot end where the cable
// actually attaches — matches makePlug()'s boot outer edge. The rope is
// pinned here (NOT at the socket), so the cable connects to the END of the
// jack. Pedal spacing (GAP_X in plugins-page.js) must exceed 2×this so two
// facing plugs leave room for visible cable between them.
var PLUG_BOOT = 44;
// Cable anchors sit at this fraction of the pedal's height (the side jacks on
// the pedal photos measure ~57.5% down), so they stay aligned at any size.
var JACK_FRAC = 0.575;
var sm = window.slopsmith;
// ---- pure geometry helpers (exported for tests) -----------------------
// Jacks sit on the pedal's SIDE faces like a real stompbox: output on the
// RIGHT edge, input on the LEFT edge, both at the pedal's vertical centre —
// so a cable runs side-to-side into the next pedal. Board-relative so the
// overlay viewBox lines up regardless of page scroll. The small default
// inset tucks the plug tip just inside the edge socket (.v3-pedal-jack in
// v3.css straddles the side edge). A single `inset` keeps the symmetric
// behaviour the unit test relies on.
function computeJacks(pedalRect, boardRect, inset) {
var i = inset == null ? 1 : inset;
var midY = (pedalRect.top - boardRect.top) + JACK_FRAC * (pedalRect.bottom - pedalRect.top);
return {
out: { x: pedalRect.right - boardRect.left - i, y: midY }, // right side
in: { x: pedalRect.left - boardRect.left + i, y: midY }, // left side
};
}
function dist(a, b) { var dx = b.x - a.x, dy = b.y - a.y; return Math.sqrt(dx * dx + dy * dy); }
// Initial straight-line seeding of a cable's points between a and b.
function seedPoints(a, b, segments) {
var n = Math.max(2, segments | 0);
var pts = [];
for (var i = 0; i < n; i++) {
var t = i / (n - 1);
var x = a.x + (b.x - a.x) * t;
var y = a.y + (b.y - a.y) * t;
pts.push({ x: x, y: y, px: x, py: y });
}
return pts;
}
// Build an SVG path string through a list of {x,y} points (smooth-ish).
function pointsToPath(pts) {
if (!pts || pts.length < 2) return '';
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length; i++) {
d += ' L ' + pts[i].x.toFixed(1) + ' ' + pts[i].y.toFixed(1);
}
return d;
}
// Static sagged cable (reduced-motion / no-physics): a quadratic with its
// control point pushed below the midpoint by `sag` (scaled to span).
function staticCablePath(a, b, sag) {
var mx = (a.x + b.x) / 2;
var my = (a.y + b.y) / 2;
var span = dist(a, b);
var drop = (sag == null ? CABLE_SAG : sag) * span + 4;
return 'M ' + a.x.toFixed(1) + ' ' + a.y.toFixed(1) +
' Q ' + mx.toFixed(1) + ' ' + (my + drop).toFixed(1) +
' ' + b.x.toFixed(1) + ' ' + b.y.toFixed(1);
}
var SVG_NS = 'http://www.w3.org/2000/svg';
function reducedMotion() {
try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }
catch (e) { return false; }
}
// ---- 1/4" plug graphics (vector, drawn at each cable end) --------------
function _rect(x, y, w, h, r, fill) {
var e = document.createElementNS(SVG_NS, 'rect');
e.setAttribute('x', x); e.setAttribute('y', y);
e.setAttribute('width', w); e.setAttribute('height', h);
if (r) { e.setAttribute('rx', r); e.setAttribute('ry', r); }
e.setAttribute('fill', fill);
return e;
}
// Chrome (cross-axis sheen) + boot gradients, one set per board svg so the
// url(#id) references stay unique across overlays.
function makeDefs(idx) {
var defs = document.createElementNS(SVG_NS, 'defs');
function grad(id, stops) {
var g = document.createElementNS(SVG_NS, 'linearGradient');
g.setAttribute('id', id);
g.setAttribute('x1', '0'); g.setAttribute('y1', '0');
g.setAttribute('x2', '0'); g.setAttribute('y2', '1'); // across the barrel
stops.forEach(function (s) {
var st = document.createElementNS(SVG_NS, 'stop');
st.setAttribute('offset', s[0]); st.setAttribute('stop-color', s[1]);
g.appendChild(st);
});
defs.appendChild(g);
}
grad('v3plug-chrome-' + idx, [
['0', '#f8fafc'], ['0.22', '#cbd5e1'], ['0.5', '#5b6b7b'],
['0.78', '#cbd5e1'], ['1', '#3a4756'],
]);
grad('v3plug-boot-' + idx, [['0', '#3b4252'], ['0.5', '#1c2230'], ['1', '#0a0d13']]);
return defs;
}
// A plug pointing along +x with its metal tip at the origin (the socket).
// boot (cable strain-relief) → barrel → insulator ring → tip sleeve → cap.
function makePlug(idx) {
var g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class', 'v3-cable-plug');
var chrome = 'url(#v3plug-chrome-' + idx + ')';
// Chunkier plug sized to match the pedal photo's 1/4" jack barrels.
g.appendChild(_rect(26, -11, 18, 22, 6, 'url(#v3plug-boot-' + idx + ')')); // boot
g.appendChild(_rect(6, -9, 22, 18, 3, chrome)); // barrel
g.appendChild(_rect(16, -9, 2, 18, 0, 'rgba(0,0,0,.35)')); // barrel rib
g.appendChild(_rect(3, -10, 4, 20, 1.5, '#0a0d13')); // insulator ring
g.appendChild(_rect(-7, -6, 11, 12, 5, chrome)); // tip sleeve
var cap = document.createElementNS(SVG_NS, 'circle');
cap.setAttribute('cx', -6); cap.setAttribute('cy', 0); cap.setAttribute('r', 5.5);
cap.setAttribute('fill', chrome);
g.appendChild(cap);
return g;
}
// Orient a plug group so its tip sits at `tip` and points toward `toward`.
function placePlug(g, tip, toward) {
if (!g) return;
var ang = Math.atan2(toward.y - tip.y, toward.x - tip.x) * 180 / Math.PI;
g.setAttribute('transform',
'translate(' + tip.x.toFixed(1) + ',' + tip.y.toFixed(1) + ') rotate(' + ang.toFixed(1) + ')');
}
// ---- state ------------------------------------------------------------
var boards = []; // [{ el, svg, cables:[{a,b,pts,el,restLen}] }]
var rafId = 0;
var active = false; // is #v3-plugins the current screen?
var dragForce = false; // a drag is in progress → run even under RM
function cancelLoop() { if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } }
// Recompute the two pinned endpoints of every cable from current layout.
function repinAnchors() {
for (var bi = 0; bi < boards.length; bi++) {
var b = boards[bi];
var brect = b.el.getBoundingClientRect();
// Size overlay to the board's full scrollable area.
var w = b.el.scrollWidth, h = b.el.scrollHeight;
b.svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
b.svg.setAttribute('width', w);
b.svg.setAttribute('height', h);
for (var ci = 0; ci < b.cables.length; ci++) {
var c = b.cables[ci];
var ra = c.fromEl.getBoundingClientRect();
var rb = c.toEl.getBoundingClientRect();
c.a = computeJacks(ra, brect).out; // output socket (right side)
c.b = computeJacks(rb, brect).in; // input socket (left side)
// Rope attaches at each plug's BOOT end, not the socket: output
// plug extends +x, input plug extends -x.
c.ra = { x: c.a.x + PLUG_BOOT, y: c.a.y };
c.rb = { x: c.b.x - PLUG_BOOT, y: c.b.y };
}
}
}
// The plugs are rigid: the output jack (right side) always points straight
// out to the right, the input jack (left side) straight out to the left —
// they do NOT swing with the cable. Only the rope between them dips.
function placeRigidPlugs(c) {
placePlug(c.plugA, c.a, { x: c.a.x + 1, y: c.a.y }); // output → +x
placePlug(c.plugB, c.b, { x: c.b.x - 1, y: c.b.y }); // input → -x
}
// Draw every cable as a curve computed DIRECTLY from its current endpoints —
// no Verlet, no inertia, no momentum. The shape is a pure function of where
// the pedals are right now, so it tracks a dragged pedal exactly (1:1) with
// zero float / slow-motion drift. A tiny gravity-biased dip gives it a
// natural hang without any springiness.
function drawAll() {
for (var bi = 0; bi < boards.length; bi++) {
var cs = boards[bi].cables;
for (var ci = 0; ci < cs.length; ci++) {
var c = cs[ci];
c.el.setAttribute('d', staticCablePath(c.ra, c.rb));
placeRigidPlugs(c);
}
}
}
// Single render pass: refresh anchors from the live layout, then draw.
function render() {
if (!boards.length) return;
repinAnchors();
drawAll();
}
// During a drag we run a short rAF loop purely so the cable re-reads the
// pedal's getBoundingClientRect every frame and stays glued to it; outside a
// drag there's nothing to animate, so we render on demand only (no idle
// loop, no physics to settle → nothing to look like the moon).
function tick() {
rafId = 0;
if (!dragForce) { render(); return; } // final frame, then stop
render();
rafId = requestAnimationFrame(tick);
}
function start() { if (boards.length) render(); }
function stop() { cancelLoop(); }
function refresh() { if (boards.length) render(); }
// Called by plugins-page around a pedal drag.
function setDragging(on) {
dragForce = !!on;
if (dragForce) { if (!rafId) rafId = requestAnimationFrame(tick); }
else render();
}
// (Re)build overlays + cable lists for the current board DOM. Called by
// plugins-page after every render() (which rebuilds the board markup).
function attach(rootEl) {
cancelLoop();
boards = [];
if (!rootEl) return;
var boardEls = rootEl.querySelectorAll('.v3-pedalboard');
var total = 0;
for (var bi = 0; bi < boardEls.length; bi++) {
var el = boardEls[bi];
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'v3-cable-layer');
svg.setAttribute('aria-hidden', 'true');
svg.appendChild(makeDefs(bi));
el.insertBefore(svg, el.firstChild);
var pedals = el.querySelectorAll('.v3-pedal');
var cables = [];
for (var pi = 0; pi + 1 < pedals.length && total < MAX_CABLES; pi++) {
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('class', 'v3-cable');
svg.appendChild(path);
// Plug graphics go on TOP of the path so the cable reads as
// entering the boot.
var plugA = makePlug(bi), plugB = makePlug(bi);
svg.appendChild(plugA); svg.appendChild(plugB);
cables.push({
fromEl: pedals[pi], toEl: pedals[pi + 1],
a: { x: 0, y: 0 }, b: { x: 0, y: 0 },
ra: { x: 0, y: 0 }, rb: { x: 0, y: 0 },
el: path, plugA: plugA, plugB: plugB,
});
total++;
}
boards.push({ el: el, svg: svg, cables: cables });
}
render();
}
function destroy() { cancelLoop(); boards = []; }
// ---- wiring -----------------------------------------------------------
// Pause/resume with the screen. screen:changed fires with the NEW screen id
// on every navigation, so this covers both enter and leave.
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', function (e) {
var id = e && e.detail && e.detail.id;
if (id === SCREEN_ID) { active = true; start(); }
else { active = false; stop(); }
});
}
// Recompute on viewport changes (throttled via rAF-coalescing in refresh()).
var pending = false;
function onLayout() {
if (!active && !dragForce) return;
if (pending) return;
pending = true;
requestAnimationFrame(function () { pending = false; refresh(); });
}
window.addEventListener('resize', onLayout, { passive: true });
// Scroll can change board rects relative to viewport; getBoundingClientRect
// already accounts for it, but a moved pedal mid-scroll wants a redraw.
window.addEventListener('scroll', onLayout, { passive: true, capture: true });
window.v3PedalCables = {
attach: attach,
refresh: refresh,
setDragging: setDragging,
start: start,
stop: stop,
destroy: destroy,
// Mark active without waiting for a screen:changed (plugins-page calls
// this when it renders while already on the plugins screen at boot).
markActive: function (on) { active = !!on; if (active) start(); else stop(); },
// Pure helpers exposed for unit tests.
_test: {
computeJacks: computeJacks,
seedPoints: seedPoints,
pointsToPath: pointsToPath,
staticCablePath: staticCablePath,
dist: dist,
SEGMENTS: SEGMENTS,
MAX_CABLES: MAX_CABLES, JACK_FRAC: JACK_FRAC,
},
};
})();