~james/core

core/web/src/lib/components/profile/profileActivityChart.ts -rw-r--r-- 4.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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<ActivityMetric, ActivityChartPoint[]>;
	segments: Record<ActivityMetric, ActivityChartSegment[]>;
}

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 = 12;
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 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<ActivityMetric, ActivityChartSegment[]>;

	return { width, height: HEIGHT, columns, points, segments };
};