headreel 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The contribution calendar as a trail profile: one column per week, each
3
+ * week's total the trail's elevation at that point of the year.
4
+ *
5
+ * Elevation is built so the busiest week is always the highest point, and the
6
+ * line never shows a peak the data does not have: weekly totals sit at their
7
+ * column centres and are joined with monotone cubic interpolation
8
+ * (Fritsch-Carlson), which never overshoots. The profile peaks at exactly the
9
+ * summit total at the busiest week (ties go to the latest week), and it stays
10
+ * on the ground line across empty weeks and the flat margins.
11
+ *
12
+ * Heights are in contributions per week. The profile is sampled once (every 4
13
+ * world units) and simplified (Ramer-Douglas-Peucker, epsilon 1); the strata
14
+ * spans, the x ranges where the trail is above each level, are computed once
15
+ * here. Nothing is derived per frame.
16
+ */
17
+ /** One week column; one empty column flanks the calendar on each side. */
18
+ export const CELL = 48;
19
+ /** The sheet is 2.2:1, the viewport's aspect, so the wide shot fills the frame. */
20
+ export const WORLD_H = 1200;
21
+ /** World y of elevation 0, and of the summit (the busiest week). */
22
+ export const GROUND_Y = 1080;
23
+ export const SUMMIT_Y = 300;
24
+ /** Profile sampling pitch and simplification, world units. */
25
+ const SAMPLE = 4;
26
+ const SPAN_SAMPLE = 2;
27
+ const RDP_EPSILON = 1;
28
+ export const MAX_LEVELS = 8;
29
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
30
+ /**
31
+ * The contour interval: the smallest of 1, 2, 5, 10, 20, 50, ... that gives at
32
+ * most `MAX_LEVELS` levels up to the summit, never below 1 contribution.
33
+ */
34
+ export function contourInterval(summit) {
35
+ const series = [];
36
+ for (let pow = 1; pow <= 1e6; pow *= 10)
37
+ series.push(pow, pow * 2, pow * 5);
38
+ return series.find((step) => Math.floor(summit / step) <= MAX_LEVELS) ?? series.at(-1);
39
+ }
40
+ /**
41
+ * Fritsch-Carlson monotone cubic interpolation over uniformly spaced nodes:
42
+ * the curve never overshoots, so it stays 0 across a run of empty weeks and
43
+ * peaks at exactly the node value at a peak node.
44
+ */
45
+ export function monotoneCubic(nodes, pitch, x0) {
46
+ const n = nodes.length;
47
+ const delta = [];
48
+ for (let i = 0; i < n - 1; i++)
49
+ delta.push((nodes[i + 1] - nodes[i]) / pitch);
50
+ const m = Array.from({ length: n });
51
+ m[0] = delta[0] ?? 0;
52
+ m[n - 1] = delta[n - 2] ?? 0;
53
+ for (let i = 1; i < n - 1; i++) {
54
+ if (delta[i - 1] * delta[i] <= 0) {
55
+ m[i] = 0;
56
+ continue;
57
+ }
58
+ // Weighted harmonic mean of the two segment slopes (equal pitches here),
59
+ // which never exceeds either in magnitude, so the curve stays monotone.
60
+ m[i] = (2 * delta[i - 1] * delta[i]) / (delta[i - 1] + delta[i]);
61
+ }
62
+ return (x) => {
63
+ if (n === 1)
64
+ return nodes[0];
65
+ const u = clamp((x - x0) / pitch, 0, n - 1);
66
+ const i = Math.min(Math.floor(u), n - 2);
67
+ const t = u - i;
68
+ const t2 = t * t;
69
+ const t3 = t2 * t;
70
+ return ((2 * t3 - 3 * t2 + 1) * nodes[i] +
71
+ (t3 - 2 * t2 + t) * pitch * m[i] +
72
+ (-2 * t3 + 3 * t2) * nodes[i + 1] +
73
+ (t3 - t2) * pitch * m[i + 1]);
74
+ };
75
+ }
76
+ /** Ramer-Douglas-Peucker, iterative. */
77
+ function simplify(points, epsilon) {
78
+ if (points.length < 3)
79
+ return points;
80
+ const keep = new Uint8Array(points.length);
81
+ keep[0] = 1;
82
+ keep[points.length - 1] = 1;
83
+ const stack = [[0, points.length - 1]];
84
+ while (stack.length > 0) {
85
+ const [lo, hi] = stack.pop();
86
+ if (hi - lo < 2)
87
+ continue;
88
+ const [ax, ay] = points[lo];
89
+ const [bx, by] = points[hi];
90
+ const dx = bx - ax;
91
+ const dy = by - ay;
92
+ const len = Math.hypot(dx, dy) || 1;
93
+ let far = -1;
94
+ let dist = 0;
95
+ for (let i = lo + 1; i < hi; i++) {
96
+ const [px, py] = points[i];
97
+ const d = Math.abs((px - ax) * dy - (py - ay) * dx) / len;
98
+ if (d > dist) {
99
+ dist = d;
100
+ far = i;
101
+ }
102
+ }
103
+ if (dist > epsilon && far > 0) {
104
+ keep[far] = 1;
105
+ stack.push([lo, far], [far, hi]);
106
+ }
107
+ }
108
+ return points.filter((_, i) => keep[i]);
109
+ }
110
+ /** The x ranges where the trail is at or above `level` contributions. */
111
+ function spansAbove(trail, level, x0, x1) {
112
+ const spans = [];
113
+ const f = (x) => trail(x) - level;
114
+ let start = null;
115
+ let prevX = x0;
116
+ let prevF = f(x0);
117
+ if (prevF >= 0)
118
+ start = x0;
119
+ for (let x = x0 + SPAN_SAMPLE; x <= x1 + 1e-9; x += SPAN_SAMPLE) {
120
+ const curF = f(x);
121
+ if (prevF < 0 && curF >= 0 && start === null) {
122
+ start = prevX + (SPAN_SAMPLE * -prevF) / (curF - prevF);
123
+ }
124
+ else if (prevF >= 0 && curF < 0 && start !== null) {
125
+ spans.push([start, prevX + (SPAN_SAMPLE * prevF) / (prevF - curF)]);
126
+ start = null;
127
+ }
128
+ prevX = x;
129
+ prevF = curF;
130
+ }
131
+ if (start !== null)
132
+ spans.push([start, x1]);
133
+ return spans;
134
+ }
135
+ export function buildProfile(contributions) {
136
+ const cols = contributions.weeks.length;
137
+ const world = { w: (cols + 2) * CELL, h: WORLD_H };
138
+ const mapArea = { x0: CELL, y0: 48, x1: world.w - CELL, y1: world.h - 48 };
139
+ const weekX = (i) => mapArea.x0 + (i + 0.5) * CELL;
140
+ // Weekly totals; days outside the window are skipped.
141
+ const weeks = contributions.weeks.map((week) => {
142
+ let total = 0;
143
+ for (const day of week) {
144
+ if (day.date < contributions.from || day.date > contributions.to)
145
+ continue;
146
+ total += day.count;
147
+ }
148
+ return { from: week[0].date, to: week.at(-1).date, total };
149
+ });
150
+ // The busiest week; ties go to the latest.
151
+ let summitWeek = -1;
152
+ let summitTotal = 0;
153
+ weeks.forEach(({ total }, i) => {
154
+ if (total >= summitTotal && total > 0) {
155
+ summitTotal = total;
156
+ summitWeek = i;
157
+ }
158
+ });
159
+ // Zero nodes in the margins keep the sheet's edges flat ground.
160
+ const trail = monotoneCubic([0, ...weeks.map((w) => w.total), 0], CELL, mapArea.x0 - CELL / 2);
161
+ const scale = (GROUND_Y - SUMMIT_Y) / (summitTotal || 1);
162
+ const profileY = (x) => GROUND_Y - trail(x) * scale;
163
+ const line = [];
164
+ for (let x = mapArea.x0; x <= mapArea.x1 + 1e-9; x += SAMPLE) {
165
+ line.push([Math.min(x, mapArea.x1), profileY(Math.min(x, mapArea.x1))]);
166
+ }
167
+ const summit = summitWeek >= 0
168
+ ? {
169
+ week: summitWeek,
170
+ total: summitTotal,
171
+ from: weeks[summitWeek].from,
172
+ to: weeks[summitWeek].to,
173
+ x: weekX(summitWeek),
174
+ y: profileY(weekX(summitWeek)),
175
+ }
176
+ : null;
177
+ const levels = [];
178
+ let interval = 0;
179
+ if (summit) {
180
+ interval = contourInterval(summit.total);
181
+ for (let k = 1; k * interval <= summit.total + 1e-9; k++) {
182
+ const height = k * interval;
183
+ levels.push({
184
+ height,
185
+ y: GROUND_Y - height * scale,
186
+ kind: k % 5 === 0 ? 'index' : 'intermediate',
187
+ spans: spansAbove(trail, height, mapArea.x0, mapArea.x1),
188
+ });
189
+ }
190
+ }
191
+ // Spot heights: the next 3 busiest weeks that are local maxima of the
192
+ // profile, at least 4 weeks from the summit and from each other.
193
+ const spots = [];
194
+ if (summit) {
195
+ const order = weeks
196
+ .map(({ total }, i) => ({ total, i }))
197
+ .filter(({ total, i }) => total > 0 && i !== summit.week)
198
+ .sort((a, b) => b.total - a.total || a.i - b.i);
199
+ const isPeak = (i) => {
200
+ const left = i > 0 ? weeks[i - 1].total : 0;
201
+ const right = i < weeks.length - 1 ? weeks[i + 1].total : 0;
202
+ const v = weeks[i].total;
203
+ return v >= left && v >= right && (v > left || v > right);
204
+ };
205
+ const taken = [summit.week];
206
+ for (const { total, i } of order) {
207
+ if (spots.length >= 3)
208
+ break;
209
+ if (!isPeak(i))
210
+ continue;
211
+ if (taken.some((w) => Math.abs(w - i) < 4))
212
+ continue;
213
+ taken.push(i);
214
+ spots.push({ week: i, total, x: weekX(i), y: profileY(weekX(i)) });
215
+ }
216
+ }
217
+ return {
218
+ cols,
219
+ world,
220
+ mapArea,
221
+ weeks,
222
+ interval,
223
+ levels,
224
+ line: simplify(line, RDP_EPSILON),
225
+ summit,
226
+ spots,
227
+ profileY,
228
+ weekX,
229
+ weekAt: (x) => clamp(Math.floor((x - mapArea.x0) / CELL), 0, cols - 1),
230
+ };
231
+ }