/* Animated delivery spine. One dot per programme on a true year axis, an
accumulation band that only ever grows, and a 30-second playhead that names
the flagship it is crossing. Static and complete under reduced motion. */
const SP = window.SX_DATA;
const SP_FROM = 1999, SP_TO = 2026;
const LAYERS_OF = {
"ERP & billing": ["core", "data", "apps"],
"Data platforms": ["data", "apps"],
"Cloud & DevOps": ["apps"],
"Industrial IoT & edge": ["edge", "data", "apps"],
"Energy-tech": ["edge", "data", "core"],
"Agri-tech": ["edge", "data", "apps"],
"Applied ML": ["ml", "data"],
"Computer vision": ["ml", "edge", "apps"],
"Document intelligence": ["agents", "data", "apps"],
"AI agents": ["agents", "data", "apps"],
};
const SP_TIER_R = { flagship: 11, deployment: 6.5, delivery: 4.2 };
const SP_TIER_W = { flagship: 3, deployment: 2, delivery: 1 };
const NODE_CAP = 4;
const spYear = (p) => +String(p.year || "").slice(0, 4) || null;
function buildSpine(rows, W) {
const left = 132, right = W - 28;
const years = [];
for (let y = SP_FROM; y <= SP_TO; y++) years.push(y);
const step = (right - left) / years.length;
const x = (y) => left + (y - SP_FROM) * step + step / 2;
const nodeFloor = 196;
const nodes = [], chips = [];
years.forEach((y) => {
const inYear = rows.filter((p) => spYear(p) === y).sort((a, b) => SP_TIER_R[b.tier] - SP_TIER_R[a.tier]);
let cursor = nodeFloor;
inYear.slice(0, NODE_CAP).forEach((p) => {
const r = SP_TIER_R[p.tier];
cursor -= r + 3;
nodes.push({ p, year: y, x: x(y), y: cursor, r });
cursor -= r + 4;
});
if (inYear.length > NODE_CAP) chips.push({ year: y, x: x(y), y: cursor - 6, count: inYear.length - NODE_CAP, all: inYear });
});
const axisY = 226, bandTop = 262, rowH = 16, gap = 2;
const rowY = {};
SP.layers.forEach((l, i) => { rowY[l.id] = bandTop + (SP.layers.length - 1 - i) * (rowH + gap); });
const cells = [], ghosts = [];
const reached = {};
years.forEach((y) => {
const active = new Set();
rows.forEach((p) => {
const py = spYear(p); if (!py) return;
if (py <= y && y <= py + 2) (LAYERS_OF[p.practice] || []).forEach((t) => active.add(t));
if (py <= y) (LAYERS_OF[p.practice] || []).forEach((t) => { if (reached[t] === undefined) reached[t] = y; });
});
SP.layers.forEach((l) => {
const cx = x(y) - step / 2 + 1, w = step - 2;
if (active.has(l.id)) cells.push({ key: y + l.id, x: cx, y: rowY[l.id], w, h: rowH, year: y });
else if (reached[l.id] !== undefined && reached[l.id] <= y) ghosts.push({ key: y + l.id, x: cx, y: rowY[l.id], w, h: rowH, year: y });
});
});
const wTop = 34, wBottom = 74, wMax = 5;
let wPath = "";
years.forEach((y, i) => {
const v = rows.filter((p) => spYear(p) === y).reduce((s, p) => s + SP_TIER_W[p.tier], 0);
const yy = Math.round((wBottom - (Math.min(v, wMax) / wMax) * (wBottom - wTop)) * 10) / 10;
wPath += (i === 0 ? `M${Math.round(x(y) - step / 2)},${yy}` : `L${Math.round(x(y) - step / 2)},${yy}`) + `L${Math.round(x(y) + step / 2)},${yy}`;
});
const wArea = `M${left},${wBottom}` + wPath.replace(/^M/, "L") + `L${Math.round(right)},${wBottom}z`;
return { years, step, x, left, right, nodes, chips, cells, ghosts, rowY, axisY, bandTop, wPath, wArea, wBottom, wTop,
bandBottom: bandTop + SP.layers.length * (rowH + gap) - gap };
}
/** Phone layout: the same record as year rows, newest first, with era headers
and a per-year band strip. The playhead highlights the year it is on. */
function VerticalSpine({ rows, pyInt }) {
const years = [];
for (let y = SP_TO; y >= SP_FROM; y--) years.push(y);
return (
{years.map((y) => {
const era = SP.eras.find((e) => { const [a, b] = e.label.split("–").map(Number); return y >= a && y <= b; });
const [, eraEnd] = era ? era.label.split("–").map(Number) : [];
const inYear = rows.filter((p) => spYear(p) === y);
const active = new Set(), reach = new Set();
rows.forEach((p) => {
const py2 = spYear(p);
if (py2 <= y && y <= py2 + 2) (LAYERS_OF[p.practice] || []).forEach((t) => active.add(t));
if (py2 <= y) (LAYERS_OF[p.practice] || []).forEach((t) => reach.add(t));
});
return (
{y === SP_TO || y === eraEnd ? (
) : null}
{SP.layers.slice().reverse().map((l) => (
))}
{y}
{inYear.map((p) => (
{p.short}
{p.title}
))}
);
})}
);
}
function AnimatedSpine({ mobile }) {
const rows = React.useMemo(() => SP.projects.filter(spYear), []);
const wrapRef = React.useRef(null);
const [W, setW] = React.useState(mobile ? 700 : 1180);
const [playing, setPlaying] = React.useState(false);
const [year, setYear] = React.useState(SP_TO);
const [hover, setHover] = React.useState(null);
const [chip, setChip] = React.useState(null);
const raf = React.useRef(0), t0 = React.useRef(0);
React.useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(Math.max(680, el.clientWidth)));
ro.observe(el); setW(Math.max(680, el.clientWidth));
return () => ro.disconnect();
}, []);
React.useEffect(() => {
if (!playing) { cancelAnimationFrame(raf.current); return; }
t0.current = 0;
const tick = (ts) => {
if (!t0.current) t0.current = ts;
const f = ((ts - t0.current) / 30000) % 1;
setYear(SP_FROM + f * (SP_TO - SP_FROM));
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, [playing]);
const g = buildSpine(rows, W);
const H = g.bandBottom + 56;
const py = year, pyInt = Math.round(year);
const era = SP.eras.find((e) => { const [a, b] = e.label.split("–").map(Number); return pyInt >= a && pyInt <= b; }) || SP.eras[SP.eras.length - 1];
const landed = rows.filter((p) => spYear(p) === pyInt);
const activeLayers = new Set(), reachedLayers = new Set();
rows.forEach((p) => {
const y = spYear(p);
if (y <= pyInt && pyInt <= y + 2) (LAYERS_OF[p.practice] || []).forEach((t) => activeLayers.add(t));
if (y <= pyInt) (LAYERS_OF[p.practice] || []).forEach((t) => reachedLayers.add(t));
});
const flagLabels = playing
? g.nodes.filter((n) => n.p.tier === "flagship" && py >= n.year && py < n.year + 2.2)
: hover ? [] : g.nodes.filter((n) => n.p.slug === "lesco");
const setFromX = (clientX, el) => {
const box = el.ownerSVGElement.getBoundingClientRect();
const sx = (clientX - box.left) * (W / box.width);
const f = (sx - g.left) / (g.right - g.left);
setPlaying(false);
setYear(Math.round(SP_FROM + Math.min(1, Math.max(0, f)) * (SP_TO - SP_FROM)));
};
return (
setPlaying(!playing)} aria-label={playing ? "Pause the timeline" : "Play the timeline"}
style={{ display: "inline-flex", alignItems: "center", gap: 9, padding: "6px 14px 6px 6px", border: "1px solid var(--border-default)", borderRadius: 999, background: "var(--surface-card)", cursor: "pointer", fontFamily: "var(--font-body)", fontSize: 13, fontWeight: 500, color: "var(--text-strong)" }}>
{playing ?
: }
{playing ? "Pause" : "Play"}
{mobile ? "or scroll the years below" : "or drag across the plot"}
{rows.length} programmes · {SP_FROM}–{SP_TO}
{mobile ? (
) : (
{SP.eras.map((e, i) => {
const [a, b] = e.label.split("–").map(Number);
const x0 = g.x(a) - g.step / 2, x1 = g.x(Math.min(b, SP_TO)) + g.step / 2;
const lit = pyInt >= a && pyInt <= b;
const maxChars = Math.floor((x1 - x0 - 12) / 6.2);
const short = e.short || e.title.toLowerCase();
const label = short.length > maxChars ? short.split(/[ ,]/)[0].slice(0, Math.max(3, maxChars)) : short;
return (
{label}
);
})}
{g.years.map((y) => (
{y % 5 === 0 && {y} }
))}
Weight
{g.nodes.map((n) => {
const fut = playing && n.year > py;
const hov = hover && hover.p.slug === n.p.slug;
const r = hov ? n.r * 1.3 : n.r;
return (
setHover(n)} onPointerLeave={() => setHover(null)}>
);
})}
{g.chips.map((c) => (
setChip(c.year)} onPointerLeave={() => setChip(null)} style={{ cursor: "pointer" }}>
+{c.count}
))}
{flagLabels.map((n, i) => {
const flip = n.x > g.right - 200;
const ly = n.y - n.r - 14 - i * 15;
return (
{n.p.short.toLowerCase()}
);
})}
{g.ghosts.map((c) => )}
{g.cells.filter((c) => !(playing && c.year > py)).map((c) => (
))}
{SP.layers.map((l) => (
{l.label}
))}
{pyInt}
{ e.currentTarget.setPointerCapture(e.pointerId); setFromX(e.clientX, e.currentTarget); }}
onPointerMove={(e) => { if (e.buttons) setFromX(e.clientX, e.currentTarget); }}
onKeyDown={(e) => { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); setPlaying(false); setYear(Math.min(SP_TO, Math.max(SP_FROM, pyInt + (e.key === "ArrowRight" ? 1 : -1)))); } }}
style={{ cursor: "ew-resize" }} />
{hover && !mobile && (
{hover.p.practice.toLowerCase()} · {hover.p.tier}
{hover.p.client}
{hover.p.title}
{hover.p.year}{hover.p.country ? " · " + hover.p.country.toLowerCase() : ""}
)}
{chip && (
{chip} · {g.chips.find((c) => c.year === chip).all.length} programmes
{g.chips.find((c) => c.year === chip).all.map((p) => (
{p.short} — {p.title.toLowerCase()}
))}
)}
)}
at the playhead
{pyInt}
{era.title} · {era.label}
landed that year
{landed.length ? landed.map((p) => (
{p.short} — {p.title.toLowerCase()}
)) : nothing new shipped this year — the programmes from the years either side were still running }
capability layers
{SP.layers.slice().reverse().map((l) => {
const on = activeLayers.has(l.id), reach = reachedLayers.has(l.id);
return (
{l.label}
);
})}
Dot size is the weight of the programme. The band underneath fills a layer in the years work touching it landed, and keeps a ghost outline of every layer ever reached — that outline never falls. Delivery years for the enterprise record are being confirmed against contracts.
);
}
Object.assign(window, { AnimatedSpine, VerticalSpine, LAYERS_OF });