import type { ActivityRepoRef } from "#lib/components/profile/types.ts"; 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 ActivityChartLayout { width: number; height: number; columns: ActivityChartColumn[]; points: Record; paths: Record; } const DESIGN_WIDTH = 988; const MIN_WIDTH = 720; const HEIGHT = 256; const PLOT_TOP = 20; const PLOT_BOTTOM = 196; const POINT_GAP = 30; const POINT_INSET = 26; const COLUMN_WEIGHTS = [146, 146, 146, 146, 146, 258]; const NARROW_PAST_GROWTH = 1.5; 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 narrow = availableWidth < MIN_WIDTH; const scale = Math.max(MIN_WIDTH, availableWidth) / DESIGN_WIDTH; const weights = COLUMN_WEIGHTS.map((weight, index) => narrow && index < COLUMN_WEIGHTS.length - 1 ? weight * NARROW_PAST_GROWTH : weight ); const width = weights.reduce((sum, weight) => sum + weight * scale, 0); const columns: ActivityChartColumn[] = []; let left = 0; for (const [index, weight] of weights.entries()) { const columnWidth = index === 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 paths = Object.fromEntries( ACTIVITY_METRICS.map((metric) => { const metricPoints = points[metric]; const first = metricPoints[0]; const last = metricPoints.at(-1)!; const curves = metricPoints.slice(1).map((next, index) => { const point = metricPoints[index]; const control = (next.x - point.x) / 2; return `C ${point.x + control} ${point.y}, ${next.x - control} ${next.y}, ${next.x} ${next.y}`; }); return [ metric, [`M 0 ${first.y} L ${first.x} ${first.y}`, ...curves, `L ${width} ${last.y}`].join( " " ) ]; }) ) as Record; return { width, height: HEIGHT, columns, points, paths }; }; export interface RepoGroup { repo: ActivityRepoRef; items: T[]; } export const repoPath = (repo: ActivityRepoRef) => `/${repo.ownerHandle}/${repo.name}`; export const groupByRepo = (items: T[]): RepoGroup[] => { const groups = new Map>(); for (const item of items) { const key = repoPath(item.repo); const group = groups.get(key) ?? { repo: item.repo, items: [] }; group.items.push(item); groups.set(key, group); } return [...groups.values()].sort((a, b) => b.items.length - a.items.length); };