Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162import 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<ActivityMetric, ActivityChartPoint[]>; paths: Record<ActivityMetric, string>;}
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<ActivityMetric, number> => { 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<ActivityMetric, number>(); 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<ActivityMetric, number[]>, 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<ActivityMetric, number[]>; const points = Object.fromEntries( ACTIVITY_METRICS.map((metric) => [metric, [] as ActivityChartPoint[]]) ) as Record<ActivityMetric, ActivityChartPoint[]>;
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<ActivityMetric, string>;
return { width, height: HEIGHT, columns, points, paths };};
export interface RepoGroup<T> { repo: ActivityRepoRef; items: T[];}
export const repoPath = (repo: ActivityRepoRef) => `/${repo.ownerHandle}/${repo.name}`;
export const groupByRepo = <T extends { repo: ActivityRepoRef }>(items: T[]): RepoGroup<T>[] => { const groups = new Map<string, RepoGroup<T>>(); 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);};