// components/RankShift.jsx — Self-ranked competence, re-sequencing across years. // Styled as a liner-notes "rotation" rather than a data chart: four snapshot // labels, five chart positions, the expertise areas threaded as tape ribbons // that climb and drop between them. Data is Merijn's own self-ranking, // transcribed verbatim — no invented numbers. // // Sequence: Year 1 → Year 2 → B3.1 → B3.2 (now) // Position 1 = top of the rotation. const RS_STAGES = [ { id: 'y1', main: 'Year 1', tag: '01', sub: 'yr 1' }, { id: 'y2', main: 'Year 2', tag: '02', sub: 'yr 2' }, { id: 'b31', main: 'B3.1', tag: '03', sub: 'yr 3 · s1' }, { id: 'now', main: 'B3.2', tag: '04', sub: 'now' }, ]; // ranks[] read in the same order as RS_STAGES. const RS_AREAS = [ { id: 'tr', abbr: 'T&R', name: 'Technology & Realization', color: '#F2876B', tint: '#FBD6C9', ink: '#C24E33', ranks: [1, 1, 1, 2] }, { id: 'mc', abbr: 'MDC', name: 'Math, Data & Computing', color: '#7FB5E8', tint: '#CFE5F7', ink: '#2F6FAE', ranks: [3, 2, 2, 1] }, { id: 'us', abbr: 'U&S', name: 'User & Society', color: '#B9A3E3', tint: '#E2D8F4', ink: '#6E54A8', ranks: [4, 5, 3, 3] }, { id: 'be', abbr: 'B&E', name: 'Business & Entrepreneurship', color: '#F2D98A', tint: '#F8ECC4', ink: '#A37E22', ranks: [5, 4, 4, 4] }, { id: 'ca', abbr: 'C&A', name: 'Creativity & Aesthetics', color: '#93CE8F', tint: '#CDE9CB', ink: '#3B7A3E', ranks: [2, 3, 5, 5] }, ]; // ── geometry (SVG user units; scales with viewBox) ── const RS_W = 1060, RS_H = 470; const RS_PAD = { l: 152, r: 250, t: 96, b: 40 }; const rsX = (i) => RS_PAD.l + i * ((RS_W - RS_PAD.r - RS_PAD.l) / (RS_STAGES.length - 1)); const rsY = (rank) => RS_PAD.t + (rank - 1) * ((RS_H - RS_PAD.b - RS_PAD.t) / (RS_AREAS.length - 1)); // Smooth S-curve path between rank nodes (horizontal control handles). function rsPath(ranks) { const pts = ranks.map((r, i) => [rsX(i), rsY(r)]); let d = `M ${pts[0][0]} ${pts[0][1]}`; for (let i = 1; i < pts.length; i++) { const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i]; const mx = (x0 + x1) / 2; d += ` C ${mx} ${y0} ${mx} ${y1} ${x1} ${y1}`; } return d; } function RankShift() { const [hover, setHover] = React.useState(null); const [pinned, setPinned] = React.useState(null); const [stage, setStage] = React.useState(RS_STAGES.length - 1); const refs = React.useRef({}); const focus = hover || pinned; const dim = (id) => focus && focus !== id; // Draw-on animation: thread the ribbons in on mount, staggered. React.useEffect(() => { const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; RS_AREAS.forEach((a, i) => { const pair = refs.current[a.id]; if (!pair) return; [pair.tint, pair.ink].forEach((el) => { if (!el) return; const len = el.getTotalLength(); el.style.transition = 'none'; el.style.strokeDasharray = len; el.style.strokeDashoffset = reduce ? 0 : len; if (reduce) return; el.getBoundingClientRect(); el.style.transition = `stroke-dashoffset 1000ms cubic-bezier(0.25,0,0,1) ${140 + i * 95}ms`; el.style.strokeDashoffset = 0; }); }); }, []); return (
{/* catalog strip — print/liner-notes header, not a chart legend */}
Rotation · what leads my practice
5 positions · 4 snapshots
{/* ── the rotation ── */}
{/* position lanes: big watermark numeral + dotted registration rule */} {RS_AREAS.map((_, r) => { const y = rsY(r + 1); const top = r === 0; return ( {r + 1} {top && ( LEAD )} ); })} {/* focused-column registration marks */} {/* snapshot label cards */} {RS_STAGES.map((s, i) => { const now = i === RS_STAGES.length - 1; const on = i === stage; const cx = rsX(i); const fill = now ? 'var(--c-smoke)' : (on ? '#F6F8FA' : '#FFFFFF'); const mainFill = now ? '#FFFFFF' : 'var(--c-smoke)'; const subFill = now ? '#C7CDD3' : 'var(--c-muted)'; const tagFill = now ? 'var(--p-coral)' : (on ? 'var(--ink-coral)' : '#B7BFC6'); return ( setStage(i)}> {s.tag} {s.main} {s.sub} ); })} {/* ribbons — pastel tape with an ink centreline */} {RS_AREAS.map((a) => { const faded = dim(a.id); const d = rsPath(a.ranks); return ( setHover(a.id)} onMouseLeave={() => setHover(null)} onClick={() => setPinned(pinned === a.id ? null : a.id)}> { (refs.current[a.id] = refs.current[a.id] || {}).tint = el; }} d={d} fill="none" stroke={a.color} strokeWidth={focus === a.id ? 11 : 8.5} strokeLinecap="round" style={{ transition: 'stroke-width 200ms' }} /> { (refs.current[a.id] = refs.current[a.id] || {}).ink = el; }} d={d} fill="none" stroke={a.ink} strokeWidth="2" strokeLinecap="round" opacity="0.85" /> ); })} {/* nodes + right-edge tracklist labels */} {RS_AREAS.map((a) => { const faded = dim(a.id); const lastRank = a.ranks[a.ranks.length - 1]; return ( {a.ranks.map((r, i) => { const big = i === stage; return ( setHover(a.id)} onMouseLeave={() => setHover(null)} onClick={() => setPinned(pinned === a.id ? null : a.id)}> {a.abbr} ); })} {/* right edge — current standing, like the final tracklist */} {'#' + lastRank} {a.name} ); })}
{/* ── focused-snapshot tracklist + stepper ── */}
); } /* ───────────────────────────────────────────────────────────────────────── */ function RankReadout({ stage, setStage, focus, pinned, setPinned, setHover }) { const ordered = [...RS_AREAS].sort((a, b) => a.ranks[stage] - b.ranks[stage]); const prev = stage > 0 ? stage - 1 : null; const delta = (a) => { if (prev == null) return { txt: 'baseline', dir: 0 }; const d = a.ranks[prev] - a.ranks[stage]; if (d === 0) return { txt: 'held', dir: 0 }; return { txt: (d > 0 ? '▲ up ' : '▼ down ') + Math.abs(d), dir: Math.sign(d) }; }; const s = RS_STAGES[stage]; return (
{/* stepper */}
setStage(Math.max(0, stage - 1))} />
{s.tag} {s.main}
Snapshot {stage + 1} of {RS_STAGES.length} · {s.sub}
setStage(Math.min(RS_STAGES.length - 1, stage + 1))} />
{RS_STAGES.map((st, i) => (
{/* the snapshot's tracklist, #1–#5 */}
{ordered.map((a, idx) => { const dl = delta(a); const dColor = dl.dir > 0 ? 'var(--ink-sage)' : dl.dir < 0 ? 'var(--ink-coral)' : 'var(--c-muted)'; const off = focus && focus !== a.id; return (
setHover(a.id)} onMouseLeave={() => setHover(null)} onClick={() => setPinned(pinned === a.id ? null : a.id)} style={{ padding: '16px 16px 18px', cursor: 'pointer', borderLeft: idx === 0 ? 'none' : '1px solid var(--c-border)', background: focus === a.id ? '#F4F7F9' : 'transparent', opacity: off ? 0.38 : 1, transition: 'opacity 200ms, background 200ms' }}>
{idx + 1} {a.abbr}
{a.name}
{dl.txt}
); })}
); } function StepBtn({ label, onClick, disabled }) { return ( ); } window.RankShift = RankShift;