96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
/**
|
|
* Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that
|
|
* decides whether a slice closes correctly is testable without a DOM.
|
|
*/
|
|
|
|
export interface DonutSlice {
|
|
/** Semantic identity: the React key, the colour key and the legend's identity. */
|
|
key: string;
|
|
label: string;
|
|
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
|
|
secondary?: string;
|
|
value: number;
|
|
color: string;
|
|
}
|
|
|
|
export interface DonutArc {
|
|
slice: DonutSlice;
|
|
/** Of the whole, 0 to 1. */
|
|
share: number;
|
|
d: string;
|
|
}
|
|
|
|
export interface DonutLayout {
|
|
size: number;
|
|
total: number;
|
|
arcs: DonutArc[];
|
|
}
|
|
|
|
const START_ANGLE = -Math.PI / 2;
|
|
|
|
function point(center: number, radius: number, angle: number): string {
|
|
return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`;
|
|
}
|
|
|
|
/**
|
|
* A whole-circle slice cannot be drawn as one arc — start and end coincide, and
|
|
* the renderer draws nothing at all — so the ring is two half arcs.
|
|
*/
|
|
function fullRing(center: number, outer: number, inner: number): string {
|
|
const top = `${center} ${center - outer}`;
|
|
const bottom = `${center} ${center + outer}`;
|
|
const innerTop = `${center} ${center - inner}`;
|
|
const innerBottom = `${center} ${center + inner}`;
|
|
return [
|
|
`M ${top}`,
|
|
`A ${outer} ${outer} 0 0 1 ${bottom}`,
|
|
`A ${outer} ${outer} 0 0 1 ${top}`,
|
|
`M ${innerTop}`,
|
|
`A ${inner} ${inner} 0 0 0 ${innerBottom}`,
|
|
`A ${inner} ${inner} 0 0 0 ${innerTop}`,
|
|
"Z",
|
|
].join(" ");
|
|
}
|
|
|
|
/**
|
|
* Slices in the order given — the caller has already ranked them — starting at
|
|
* twelve o'clock and running clockwise. Zero-valued slices are dropped: they
|
|
* have no arc to draw, and a legend entry reading 0 is noise.
|
|
*/
|
|
export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout {
|
|
const drawn = slices.filter((slice) => slice.value > 0);
|
|
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
|
if (total <= 0) return { size, total: 0, arcs: [] };
|
|
|
|
const center = size / 2;
|
|
const outer = center;
|
|
const inner = Math.max(0, center - thickness);
|
|
|
|
if (drawn.length === 1) {
|
|
return {
|
|
size,
|
|
total,
|
|
arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }],
|
|
};
|
|
}
|
|
|
|
let angle = START_ANGLE;
|
|
const arcs = drawn.map((slice) => {
|
|
const share = slice.value / total;
|
|
const sweep = share * Math.PI * 2;
|
|
const end = angle + sweep;
|
|
const large = sweep > Math.PI ? 1 : 0;
|
|
const d = [
|
|
`M ${point(center, outer, angle)}`,
|
|
`A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`,
|
|
`L ${point(center, inner, end)}`,
|
|
`A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`,
|
|
"Z",
|
|
].join(" ");
|
|
angle = end;
|
|
return { slice, share, d };
|
|
});
|
|
|
|
return { size, total, arcs };
|
|
}
|