// 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 ── */}
{/* ── 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;