// 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 ;
}
function StackMap({ host }) {
const [lens, setLens] = useState(0);
const [scale, setScale] = useState(1);
const wrap = useRef(null);
const state = useMemo(() => ({ lens }), [lens]);
useEffect(() => {
const ro = new ResizeObserver(([e]) => setScale(Math.min(1, e.contentRect.width / W)));
ro.observe(wrap.current);
return () => ro.disconnect();
}, []);
const onKey = (e) => {
if (e.key === "ArrowRight") setLens((l) => (l + 1) % TECH.length);
else if (e.key === "ArrowLeft") setLens((l) => (l + TECH.length - 1) % TECH.length);
else if (e.key === "Home") setLens(0);
else return;
e.preventDefault();
};
const t = TECH[lens];
const live =
lens === 0
? "Showing every project."
: `${t.label}: ${order(lens).filter((p) => p.uses.includes(t.id)).map((p) => p.name).join(", ")}.`;
return (
setLens(s.lens)} width={W} height={H} />
{live}
);
}
// Phones: the techniques sit in a small two-column panel with the marker,
// and the projects are an ordinary HTML list below it, so the page still
// scrolls when you touch the list. Only the small panel captures the finger.
const P_COLS = 2;
const P_CELL_H = 40;
const P_H = Math.ceil(TECH.length / P_COLS) * P_CELL_H + 8;
const P_LABEL = (t) => (t.id === "all" ? "All projects" : t.label);
function makePanel(width) {
const cw = width / P_COLS;
const at = (i) => [(i % P_COLS) * cw, Math.floor(i / P_COLS) * P_CELL_H];
return ({ state, d, setState }) => {
const [mx, my] = at(state.lens);
return (
{TECH.map((t, i) => {
const [x, y] = at(i);
return (
setState({ lens: i })} style={{ cursor: "pointer" }}>
);
})}
d.between(STATES).withDropTransition("cubic-out")}>
{TECH.map((t, i) => {
const [x, y] = at(i);
return (
{P_LABEL(t)}
);
})}
);
};
}
function PhoneMap({ list }) {
const [lens, setLens] = useState(0);
const [shown, setShown] = useState(0);
const [width, setWidth] = useState(0);
const wrap = useRef(null);
const items = useMemo(() => [...list.children], [list]);
useEffect(() => {
const ro = new ResizeObserver(([e]) => setWidth(Math.floor(e.contentRect.width)));
ro.observe(wrap.current);
return () => ro.disconnect();
}, []);
// Re-sort the page's own project list: projects that use the technique
// first, the rest after and dimmed. "All projects" restores the original order.
useEffect(() => {
const t = TECH[shown].id;
const uses = (li) => {
const href = li.querySelector("a")?.getAttribute("href");
const p = PROJECTS.find((x) => x.href === href);
return !!p && p.uses.includes(t);
};
const sorted = shown === 0 ? items : [...items.filter(uses), ...items.filter((li) => !uses(li))];
sorted.forEach((li) => { list.appendChild(li); li.classList.toggle("is-dim", shown !== 0 && !uses(li)); });
}, [shown, items, list]);
const panel = useMemo(() => (width ? makePanel(width) : null), [width]);
const state = useMemo(() => ({ lens }), [lens]);
const t = TECH[shown];
return (
Drag the marker, or tap a technique, to sort the projects.
{panel && (
setShown(s.lens)}
onDropState={(s) => { setLens(s.lens); setShown(s.lens); }}
width={width}
height={P_H}
/>
)}
{shown === 0 ? "Showing every project." : `${t.label}: ${PROJECTS.filter((p) => p.uses.includes(t.id)).map((p) => p.name).join(", ")}.`}
);
}
const host = document.getElementById("stack-map");
const plist = document.querySelector(".plist");
if (host && matchMedia("(min-width: 861px)").matches) {
host.classList.add("sm--on");
createRoot(host.querySelector(".sm__mount")).render();
} else if (plist) {
// Phones: one list. The technique panel sits above the page's project list
// and sorts it; the desktop map (and its own list) isn't shown.
const mount = document.createElement("div");
mount.className = "smp__mount";
plist.before(mount);
document.documentElement.classList.add("has-smp");
createRoot(mount).render();
}