/**
* The Sankey's geometry, computed once and drawn twice.
*
* Pure: no React, no Prisma, no DOM. The page renders these rectangles inline
* and the share route renders the same ones into a standalone SVG that a
* headless Chromium turns into a PNG. A layout computed in the component would
* mean the image and the screen could disagree, which for a thing whose whole
* job is to be screenshotted and posted is the one bug that matters.
*
* Not a general Sankey. A job search has one spine — applied, screen,
* interview, final, offer — and everything that leaves it leaves sideways and
* never comes back. That is a much smaller problem than the layered graph a
* real Sankey library solves, and it has an exact answer rather than an
* iterated one: each rung is a column, its survivors continue, and its
* departures stack below them.
*/
/**
* What this module needs to draw one rung — and nothing else.
*
* It used to take stage keys and look their words and colours up through the
* caller's catalogue. It cannot any more: a rung may be "Round 3" and an exit
* may be a tag the person named themselves, neither of which any catalogue
* here could know. So each rung and each ending arrives carrying its own label
* and its own colour, and this file knows no palette and no vocabulary at all,
* which is what its header always claimed.
*/
export type FunnelRungInput = {
key: string;
label: string;
/** A CSS colour, already resolved by the caller. */
tone: string;
reached: number;
/** How many were actually in this rung, not merely past its depth. */
visited: number;
advanced: number;
ended: { key: string; label: string; tone: string; count: number }[];
open: number;
};
export type SankeyNode = {
id: string;
label: string;
value: number;
x: number;
y: number;
width: number;
height: number;
/** A CSS colour. The caller resolves it, so this file knows no palette. */
tone: string;
/** Departures are drawn quieter than the spine. */
kind: "spine" | "exit";
};
export type SankeyLink = {
id: string;
/** An SVG path, already a ribbon rather than a line. */
path: string;
value: number;
tone: string;
kind: "spine" | "exit";
};
export type SankeyLayout = {
width: number;
height: number;
nodes: SankeyNode[];
links: SankeyLink[];
/** True when there is nothing to draw, so callers can say so instead. */
empty: boolean;
};
export type SankeyOptions = {
width?: number;
height?: number;
/** What the still-live band at each rung is called, and its colour. */
openLabel: string;
openTone: string;
};
const NODE_WIDTH = 14;
const PADDING = { top: 28, right: 148, bottom: 28, left: 8 };
/** Vertical gap between a rung's survivors and the exits stacked under them. */
const GAP = 10;
/**
* Horizontal room kept clear for the column the exits land in.
*
* Without it the last rung's block and the exit block sit at almost the same x
* and overlap — the offer column ended up hidden behind "Offer accepted", which
* is exactly the square somebody wants to see.
*/
const EXIT_LANE = 120;
/**
* The least vertical room a departure gets in the landing column.
*
* Its label is 11.5px whatever the chart's scale, so two exits worth one
* application each, on a chart of forty, would otherwise be drawn 3px apart and
* their labels would sit on top of each other. This is the floor that stops it.
*/
const MIN_EXIT_PITCH = 17;
/**
* A ribbon from one x to another, as a filled path.
*
* Two cubics and a close, rather than a thick line: a stroke cannot change
* width along its length, and the whole point of a Sankey is that a flow is as
* wide as it is big.
*/
function ribbon(x0: number, y0: number, h0: number, x1: number, y1: number, h1: number) {
const cx = x0 + (x1 - x0) / 2;
return [
`M${x0},${y0}`,
`C${cx},${y0} ${cx},${y1} ${x1},${y1}`,
`L${x1},${y1 + h1}`,
`C${cx},${y1 + h1} ${cx},${y0 + h0} ${x0},${y0 + h0}`,
"Z",
].join(" ");
}
export function sankeyLayout(rungs: FunnelRungInput[], options: SankeyOptions): SankeyLayout {
const width = options.width ?? 900;
const height = options.height ?? 420;
const total = rungs[0]?.reached ?? 0;
if (total === 0) {
return { width, height, nodes: [], links: [], empty: true };
}
// Only the rungs somebody was actually IN.
//
// `reached` is a depth: an application that went from an interview straight
// to an offer counts as having got past the final-round rung, which is what
// makes the arithmetic close. Drawing a column for it would put "Final round
// 1" on a chart belonging to somebody who never had one — and this chart's
// whole job is to be posted and read by strangers. So a rung nobody entered
// is skipped, and the ribbon joins the columns either side of it: the
// survivors leaving one rung always equal the depth reached at the next kept
// one, whether or not anything in between was drawn.
const live = rungs.filter(
(rung) => rung.reached > 0 && (rung.visited > 0 || rung.open > 0 || rung.ended.length > 0),
);
const inner = {
width: width - PADDING.left - PADDING.right,
height: height - PADDING.top - PADDING.bottom,
};
// Every rung's exits are stacked under it, so the tallest column is the first
// one — which is the whole population. Scale off that and nothing overflows.
const perUnit = inner.height / total;
const columnGap =
live.length > 1 ? (inner.width - NODE_WIDTH - EXIT_LANE) / (live.length - 1) : inner.width;
const nodes: SankeyNode[] = [];
const links: SankeyLink[] = [];
/**
* Every departure, collected before any of them is placed.
*
* The landing column is shared by all five rungs, and each rung works out
* where its own exits *want* to sit without knowing about the others. That is
* fine while the drawing is tall — the wants happen not to overlap, because a
* rung's exits occupy exactly the band between its survivors and its total —
* but the labels beside them are a fixed 11.5px whatever the scale, so on a
* short chart with a real search in it they land on top of each other and the
* whole right-hand side becomes unreadable. So the placing happens once, over
* all of them, in the pass below.
*/
const departures: {
id: string;
label: string;
tone: string;
value: number;
/** Where it leaves the spine. Fixed — this is what makes it readable. */
fromX: number;
fromY: number;
height: number;
/** Where it would land with nothing else in the way. */
wantsY: number;
}[] = [];
live.forEach((rung, index) => {
const x = PADDING.left + index * columnGap;
const top = PADDING.top;
const spineHeight = Math.max(1, rung.reached * perUnit);
nodes.push({
id: `rung-${rung.key}`,
label: rung.label,
value: rung.reached,
x,
y: top,
width: NODE_WIDTH,
height: spineHeight,
tone: rung.tone,
kind: "spine",
});
const next = live[index + 1];
// Survivors first, from the top: the eye follows the spine across.
let cursor = top;
if (next && rung.advanced > 0) {
const h = Math.max(1, rung.advanced * perUnit);
const nextX = PADDING.left + (index + 1) * columnGap;
links.push({
id: `flow-${rung.key}`,
path: ribbon(x + NODE_WIDTH, cursor, h, nextX, top, Math.max(1, next.reached * perUnit)),
value: rung.advanced,
tone: next.tone,
kind: "spine",
});
cursor += h;
}
// Then everything that left here, stacked under the survivors and pushed
// out to the right margin where the labels live.
const exits = [
...rung.ended,
// "Still going" is an exit from the diagram, not from the search — drawn
// last so an in-flight application never sits above a rejection.
...(rung.open > 0
? [{ key: "OPEN", label: options.openLabel, tone: options.openTone, count: rung.open }]
: []),
].filter((exit) => exit.count > 0);
let exitY = cursor + (exits.length > 0 ? GAP : 0);
for (const exit of exits) {
const h = Math.max(1, exit.count * perUnit);
departures.push({
id: `${rung.key}-${exit.key}`,
label: exit.label,
tone: exit.tone,
value: exit.count,
fromX: x + NODE_WIDTH,
fromY: cursor,
height: h,
wantsY: exitY,
});
cursor += h;
exitY += h + GAP;
}
});
// Place the landing column top to bottom, giving every label the room it
// needs. A block keeps its true height — a 1 must not look like a 3 — so
// what gives is the space beneath it, and the drawing grows rather than
// overlapping. Deeper rungs land higher, which falls out of sorting: a
// rung's exits sit in the band between its survivors and its total, and the
// deeper the rung the smaller both are.
const endX = PADDING.left + inner.width;
let floorY = PADDING.top;
for (const exit of departures.sort((a, b) => a.wantsY - b.wantsY)) {
const y = Math.max(exit.wantsY, floorY);
floorY = y + Math.max(exit.height, MIN_EXIT_PITCH);
links.push({
id: `exit-${exit.id}`,
path: ribbon(exit.fromX, exit.fromY, exit.height, endX, y, exit.height),
value: exit.value,
tone: exit.tone,
kind: "exit",
});
nodes.push({
id: `end-${exit.id}`,
label: exit.label,
value: exit.value,
x: endX,
y,
width: NODE_WIDTH,
height: exit.height,
tone: exit.tone,
kind: "exit",
});
}
// The exits fan downward, so the drawing is usually taller than the spine.
// Grow the box to fit rather than clipping the bottom label off.
const lowest = Math.max(
height - PADDING.bottom,
...nodes.map((node) => node.y + node.height),
PADDING.top,
);
return {
width,
height: Math.ceil(lowest + PADDING.bottom),
nodes,
links,
empty: false,
};
}
// ---------------------------------------------------------------------------
// Drawing it
// ---------------------------------------------------------------------------
/**
* The markup, built as a string rather than as JSX.
*
* The page and the downloadable file have to be the same picture, and the only
* way to guarantee that is one emitter. It cannot be a React component that the
* file route renders with `renderToStaticMarkup`, because Next refuses
* `react-dom/server` inside the app router — so the string is the primitive and
* the component injects it. Nothing here comes from a person: every number is
* counted from the database and every label is looked up in a fixed catalogue.
* `esc` is belt-and-braces for the day somebody passes a `labelFor` that isn't.
*/
const esc = (value: string) =>
value.replace(/&/g, "&").replace(//g, ">");
export type SankeyInk = {
/** Label colour. A theme-aware page passes `currentColor`. */
ink: string;
faint: string;
/** Opacities differ on a white sheet, which has no theme to sit against. */
standalone: boolean;
/**
* How solid the ribbons are, spine and exit.
*
* The one thing that legitimately differs between the page and the file, and
* only because the grounds differ: 34% of a colour reads as a pastel on white
* and as mud on the app's near-black. The geometry, the words and the numbers
* are identical — an opacity is not a different picture.
*/
flow?: { spine: number; exit: number };
};
export function sankeyBody(
layout: SankeyLayout,
{ ink, faint, standalone, flow }: SankeyInk,
): string {
const solid = standalone ? 1 : 0.85;
const quiet = standalone ? 1 : 0.6;
const ribbons = flow ?? { spine: 0.34, exit: 0.2 };
const parts: string[] = [];
// Ribbons first, so a flow never covers the column it arrives at.
for (const link of layout.links) {
parts.push(
``,
);
}
for (const node of layout.nodes) {
parts.push(
``,
);
if (node.kind === "spine") {
// Above the column: a spine block is 14px wide and a label will not fit
// beside it.
parts.push(
`` +
`${esc(node.label)}${node.value}` +
``,
);
} else {
// Beside it, in the right margin the layout reserved.
parts.push(
`` +
`${esc(node.label)}${node.value}` +
``,
);
}
}
return parts.join("");
}
/**
* The same drawing as a standalone file: its own width, its own colours, and a
* font stack, because a downloaded SVG carries no stylesheet with it.
*/
export function sankeyDocument(layout: SankeyLayout, title: string): string {
const body = sankeyBody(layout, { ink: "#0f172a", faint: "#64748b", standalone: true });
return (
``
);
}