// The project stack on projects.html. Drag the lime marker along the // techniques and the stack re-sorts: projects that use that technique rise // to the top and wire into it. Built with Dragology (Horowitz, Blinn and // Heer, "Declarative Dragging," UIST 2026): each marker position is a state, // and the library animates the whole drawing between states mid-drag. // An ASCII field behind it drifts and brightens under the marker. import { DraggableRenderer, translate } from "dragology"; import { useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./hof-line.jsx"; const W = 1240; const H = 640; const LIME = "#d7ff3d"; const DIM = "#c9ccff"; const MONO = '"Plex Mono", ui-monospace, monospace'; const COND = '"Plex Cond", "Arial Narrow", sans-serif'; // Index 0 is "everything". The pairings match the map this replaces. const TECH = [ { id: "all", label: "all" }, { id: "rate", label: "Rating models", tag: "rating" }, { id: "sim", label: "Simulated worlds", tag: "simulation" }, { id: "prob", label: "Probability engines", tag: "probability" }, { id: "mech", label: "Mechanism design", tag: "mechanism" }, { id: "match", label: "Matching", tag: "matching" }, { id: "scen", label: "Scenario play", tag: "scenario" }, { id: "meas", label: "Measurement", tag: "measurement" }, ]; const PROJECTS = [ { id: "cap", name: "Cap Buffet", href: "projects/draft-lab.html", uses: ["mech"] }, { id: "ortn", name: "Oregon HS Tennis Power Rankings", href: "projects/oregon-tennis.html", uses: ["rate", "sim"] }, { id: "occ", name: "Occupant Indices", href: "projects/occupant.html", uses: ["meas"] }, { id: "mallo", name: "Mallo", href: "projects/mallo.html", uses: ["rate"] }, { id: "ptc", name: "Play To Clinch", href: "projects/play-to-clinch.html", uses: ["rate", "sim"] }, { id: "vb", name: "Viperball", href: "projects/viperball.html", uses: ["prob", "sim"] }, { id: "si", name: "SuperInnings", href: "projects/super-innings.html", uses: ["prob", "sim"] }, { id: "ygf", name: "You Go First", href: "projects/ygf.html", uses: ["match", "mech"] }, { id: "pen", name: "Pensulo Cards", href: "https://dribbble.com/shots/3794375-Pensulo-Card-Deck", uses: ["scen"] }, ]; // Technique sockets, laid out left to right in 14px mono (8.4px a character). const CH = 8.4; const SOCK_Y = 44; const sockets = (() => { const texts = TECH.map((t) => `[ ${t.label} ]`); const widths = texts.map((s) => s.length * CH); const gap = (W - widths.reduce((a, b) => a + b, 0)) / (TECH.length - 1); let x = 0; return texts.map((text, i) => { const s = { text, x, w: widths[i] }; x += widths[i] + gap; return s; }); })(); const ROOT_Y = 150; const ROW0 = 200; const ROW_H = 48; const TAG_X = 800; function order(lens) { if (lens === 0) return PROJECTS; const t = TECH[lens].id; return [...PROJECTS.filter((p) => p.uses.includes(t)), ...PROJECTS.filter((p) => !p.uses.includes(t))]; } function tagX(p, techId) { let x = TAG_X; for (const u of p.uses) { if (u === techId) return x; x += (TECH.find((t) => t.id === u).tag.length + 3) * CH; } return x; } const STATES = TECH.map((_, i) => ({ lens: i })); const stack = ({ state, d, setState }) => { const lens = state.lens; const active = TECH[lens].id; const rows = order(lens); const rowY = (p) => ROW0 + rows.indexOf(p) * ROW_H; const lastY = ROW0 + (rows.length - 1) * ROW_H; const s = sockets[lens]; return ( {/* Click targets for each technique, under the marker */} {sockets.map((k, i) => ( setState({ lens: i })} style={{ cursor: "pointer" }}> ))} {/* The marker: a lime block behind the chosen technique. Drag it. */} d.between(STATES).withDropTransition("cubic-out")} style={{ cursor: "grab" }} > {/* Labels sit on top but let the pointer through */} {sockets.map((k, i) => ( {k.text} ))} {/* Wires from the chosen socket down to each project that uses it */} {PROJECTS.flatMap((p) => p.uses.map((u) => { const ti = TECH.findIndex((t) => t.id === u); const k = sockets[ti]; const x1 = k.x + k.w / 2; const y1 = SOCK_Y + 24; const x2 = tagX(p, u) + 2; const y2 = rowY(p) - 18; const on = u === active; return ( ); }), )} {/* The stack, drawn like a dependency tree */} ronbronson.dev/projects {PROJECTS.map((p) => { const on = lens === 0 || p.uses.includes(active); return ( (window.location.href = p.href)} style={{ cursor: "pointer" }} > {p.name} {p.uses.map((u) => ( {TECH.find((t) => t.id === u).tag} ))} ); })} ); }; // ASCII field: characters drift on a slow wave and brighten under the marker. function Field({ host }) { const ref = useRef(null); useEffect(() => { const cv = ref.current; const ctx = cv.getContext("2d"); const still = matchMedia("(prefers-reduced-motion: reduce)").matches; const RAMP = " .ยท:-=+*"; const CW = 14, LH = 20; let raf = 0, w = 0, h = 0, dpr = 1; const size = () => { dpr = Math.min(devicePixelRatio || 1, 2); w = cv.clientWidth; h = cv.clientHeight; cv.width = w * dpr; cv.height = h * dpr; }; size(); const ro = new ResizeObserver(size); ro.observe(cv); const draw = (t) => { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); ctx.font = `12px ${MONO}`; const lensEl = host.querySelector("#lens"); const cr = cv.getBoundingClientRect(); let lx = -1e4; if (lensEl) { const r = lensEl.getBoundingClientRect(); lx = r.left + r.width / 2 - cr.left; } const s = t / 1000; for (let y = LH; y < h; y += LH) { for (let x = 0; x < w; x += CW) { const v = Math.sin(x * 0.011 + s * 0.7) * Math.cos(y * 0.017 - s * 0.5) + Math.sin((x + y) * 0.006 + s * 0.3); const near = Math.max(0, 1 - Math.abs(x - lx) / 140) * Math.max(0, 1 - y / h); const lvl = Math.max(0, Math.min(RAMP.length - 1, Math.floor((v + 2) / 4 * 5 + near * 4))); if (lvl === 0) continue; ctx.fillStyle = near > 0.15 ? `rgba(215,255,61,${0.12 + near * 0.35})` : `rgba(201,204,255,${0.06 + lvl * 0.02})`; ctx.fillText(RAMP[lvl], x, y); } } if (!still) raf = requestAnimationFrame(draw); }; raf = requestAnimationFrame(draw); let poke = null; if (still) { poke = () => requestAnimationFrame(draw); host.addEventListener("pointerup", poke); host.addEventListener("click", poke); } return () => { cancelAnimationFrame(raf); ro.disconnect(); if (poke) { host.removeEventListener("pointerup", poke); host.removeEventListener("click", poke); } }; }, [host]); return