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 };
};