export const ACTIVITY_METRICS = ["pulls", "commits", "repos", "issues"] as const; export type ActivityMetric = (typeof ACTIVITY_METRICS)[number]; export interface ActivityChartColumn { left: number; width: number; divider: boolean; } export interface ActivityChartPoint { x: number; y: number; value: number; } export interface ActivityChartSegment { d: string; rising: boolean; } export interface ActivityChartLayout { width: number; height: number; columns: ActivityChartColumn[]; points: Record; segments: Record; } const MIN_WIDTH = 988; const HEIGHT = 256; const PLOT_TOP = 20; const PLOT_BOTTOM = 196; const POINT_GAP = 30; const POINT_INSET = 12; const COLUMN_WEIGHTS = [146, 146, 146, 146, 146, 258]; const normalizedY = (values: number[]): number[] => { const min = Math.min(...values); const max = Math.max(...values); if (min === max) return values.map(() => (PLOT_TOP + PLOT_BOTTOM) / 2); return values.map( (value) => PLOT_BOTTOM - ((value - min) / (max - min)) * (PLOT_BOTTOM - PLOT_TOP) ); }; const resolvePointCollisions = ( points: { metric: ActivityMetric; y: number }[] ): Map => { const sorted = points.toSorted((a, b) => a.y - b.y); const clusters = sorted.map((point) => ({ metrics: [point.metric], sum: point.y })); const topOf = (cluster: { metrics: ActivityMetric[]; sum: number }) => { const span = (cluster.metrics.length - 1) * POINT_GAP; const centered = cluster.sum / cluster.metrics.length; return Math.min(Math.max(centered, PLOT_TOP), PLOT_BOTTOM - span); }; let index = 1; while (index < clusters.length) { const previous = clusters[index - 1]; const current = clusters[index]; if (topOf(previous) + previous.metrics.length * POINT_GAP <= topOf(current)) { index += 1; continue; } clusters.splice(index - 1, 2, { metrics: [...previous.metrics, ...current.metrics], sum: previous.sum + current.sum - previous.metrics.length * current.metrics.length * POINT_GAP }); index = Math.max(index - 1, 1); } const resolved = new Map(); for (const cluster of clusters) { const top = topOf(cluster); for (const [offset, metric] of cluster.metrics.entries()) { resolved.set(metric, top + offset * POINT_GAP); } } return resolved; }; export const layoutActivityChart = ( values: Record, availableWidth: number ): ActivityChartLayout => { const width = Math.max(MIN_WIDTH, availableWidth); const scale = width / MIN_WIDTH; const columns: ActivityChartColumn[] = []; let left = 0; for (const [index, weight] of COLUMN_WEIGHTS.entries()) { const columnWidth = index === COLUMN_WEIGHTS.length - 1 ? width - left : weight * scale; columns.push({ left, width: columnWidth, divider: index > 0 }); left += columnWidth; } const rawY = Object.fromEntries( ACTIVITY_METRICS.map((metric) => [metric, normalizedY(values[metric])]) ) as Record; const points = Object.fromEntries( ACTIVITY_METRICS.map((metric) => [metric, [] as ActivityChartPoint[]]) ) as Record; for (const [monthIndex, column] of columns.entries()) { const resolved = resolvePointCollisions( ACTIVITY_METRICS.map((metric) => ({ metric, y: rawY[metric][monthIndex] })) ); for (const metric of ACTIVITY_METRICS) { points[metric].push({ x: column.left + POINT_INSET, y: resolved.get(metric)!, value: values[metric][monthIndex] }); } } const segments = Object.fromEntries( ACTIVITY_METRICS.map((metric) => { const metricPoints = points[metric]; const connectors = metricPoints.slice(0, -1).map((point, index) => { const next = metricPoints[index + 1]; const control = (next.x - point.x) / 2; return { d: `M ${point.x} ${point.y} C ${point.x + control} ${point.y}, ${next.x - control} ${next.y}, ${next.x} ${next.y}`, rising: next.value >= point.value }; }); const first = metricPoints[0]; const last = metricPoints.at(-1)!; return [ metric, [ { d: `M 0 ${first.y} L ${first.x} ${first.y}`, rising: connectors[0]?.rising ?? true }, ...connectors, { d: `M ${last.x} ${last.y} L ${width} ${last.y}`, rising: connectors.at(-1)?.rising ?? true } ] ]; }) ) as Record; return { width, height: HEIGHT, columns, points, segments }; };