locmeter 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jojopirker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # locmeter
2
+
3
+ `locmeter` is a dependency-free Node CLI that scans your locally cloned GitHub contribution repos, aggregates added and deleted lines by day, week, or month, and renders a PNG chart.
4
+
5
+ Repository: `jojopirker/locmeter`
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 18+
10
+ - `gh` CLI authenticated
11
+ - network access for `gh api`
12
+ - local clones of the repos you want included
13
+ - `git`
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ locmeter
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -g locmeter
25
+ ```
26
+
27
+ Common options:
28
+
29
+ - default bucket: `week`
30
+ - default `--to`: today
31
+ - default `--from`: one year before `--to`
32
+ - default author identity: auto-detected from your current `gh` login
33
+ - `--from YYYY-MM-DD`
34
+ - `--to YYYY-MM-DD`
35
+ - `--days N`
36
+ - `--bucket day|week|month`
37
+ - `--root /path/to/repos`
38
+ - `--author-email you@example.com`
39
+ - `--author-name yourname`
40
+ - `--output chart.png`
41
+ - `--json-output data.json`
42
+
43
+ Example:
44
+
45
+ ```bash
46
+ locmeter \
47
+ --from 2025-01-01 \
48
+ --to 2025-12-31 \
49
+ --bucket week
50
+ ```
51
+
52
+ Real example generated from your usage:
53
+
54
+ ```bash
55
+ locmeter \
56
+ --root ~/Developer \
57
+ --output examples/jojo-weekly.png \
58
+ --json-output examples/jojo-weekly.json
59
+ ```
60
+
61
+ That example produced:
62
+
63
+ - `examples/jojo-weekly.png`
64
+ - `examples/jojo-weekly.json`
65
+ - date range: `2025-03-13` to `2026-03-13`
66
+ - bucket: `week`
67
+ - total lines changed: `1,059,347`
68
+ - peak week: `207,431`
69
+
70
+ ![Example locmeter output](./examples/jojo-weekly.png)
71
+
72
+ The CLI prints the generated PNG path and JSON path on success.
73
+
74
+ ## Notes
75
+
76
+ - `locmeter` is intended for global CLI usage.
77
+ - The npm package metadata is set to `MIT`; add the full MIT license text in a `LICENSE` file before publishing.
78
+ - The published package only ships the example PNG, not the example JSON.
@@ -0,0 +1,714 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execFile } = require("node:child_process");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { promisify } = require("node:util");
8
+ const zlib = require("node:zlib");
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const BG = [247, 248, 250];
13
+ const PLOT_BG = [255, 255, 255];
14
+ const GRID = [226, 232, 240];
15
+ const AXIS = [100, 116, 139];
16
+ const LINE = [15, 23, 42];
17
+ const FILL = [191, 219, 254];
18
+ const TEXT = [30, 41, 59];
19
+
20
+ const FONT = {
21
+ "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
22
+ "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
23
+ "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
24
+ "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
25
+ "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
26
+ "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
27
+ "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
28
+ "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
29
+ "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
30
+ "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
31
+ A: ["00100", "01010", "10001", "10001", "11111", "10001", "10001"],
32
+ B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
33
+ C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
34
+ D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
35
+ E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
36
+ F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
37
+ G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
38
+ H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
39
+ I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
40
+ J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100"],
41
+ K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
42
+ L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
43
+ M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
44
+ N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
45
+ O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
46
+ P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
47
+ Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
48
+ R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
49
+ S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
50
+ T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
51
+ U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
52
+ V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
53
+ W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
54
+ X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
55
+ Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
56
+ Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
57
+ a: ["00000", "00000", "01110", "00001", "01111", "10001", "01111"],
58
+ b: ["10000", "10000", "11110", "10001", "10001", "10001", "11110"],
59
+ c: ["00000", "00000", "01111", "10000", "10000", "10000", "01111"],
60
+ d: ["00001", "00001", "01111", "10001", "10001", "10001", "01111"],
61
+ e: ["00000", "00000", "01110", "10001", "11111", "10000", "01111"],
62
+ f: ["00110", "01001", "01000", "11100", "01000", "01000", "01000"],
63
+ g: ["00000", "00000", "01111", "10001", "10001", "01111", "00001"],
64
+ h: ["10000", "10000", "11110", "10001", "10001", "10001", "10001"],
65
+ i: ["00100", "00000", "01100", "00100", "00100", "00100", "01110"],
66
+ j: ["00010", "00000", "00110", "00010", "00010", "10010", "01100"],
67
+ k: ["10000", "10000", "10010", "10100", "11000", "10100", "10010"],
68
+ l: ["01100", "00100", "00100", "00100", "00100", "00100", "01110"],
69
+ m: ["00000", "00000", "11010", "10101", "10101", "10101", "10101"],
70
+ n: ["00000", "00000", "11110", "10001", "10001", "10001", "10001"],
71
+ o: ["00000", "00000", "01110", "10001", "10001", "10001", "01110"],
72
+ p: ["00000", "00000", "11110", "10001", "10001", "11110", "10000"],
73
+ q: ["00000", "00000", "01111", "10001", "10001", "01111", "00001"],
74
+ r: ["00000", "00000", "10111", "11000", "10000", "10000", "10000"],
75
+ s: ["00000", "00000", "01111", "10000", "01110", "00001", "11110"],
76
+ t: ["01000", "01000", "11100", "01000", "01000", "01001", "00110"],
77
+ u: ["00000", "00000", "10001", "10001", "10001", "10011", "01101"],
78
+ v: ["00000", "00000", "10001", "10001", "10001", "01010", "00100"],
79
+ w: ["00000", "00000", "10001", "10001", "10101", "10101", "01010"],
80
+ x: ["00000", "00000", "10001", "01010", "00100", "01010", "10001"],
81
+ y: ["00000", "00000", "10001", "10001", "10001", "01111", "00001"],
82
+ z: ["00000", "00000", "11111", "00010", "00100", "01000", "11111"],
83
+ ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"],
84
+ "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
85
+ ":": ["00000", "00100", "00100", "00000", "00100", "00100", "00000"],
86
+ " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"]
87
+ };
88
+
89
+ const MONTHS = {
90
+ 1: "JAN",
91
+ 2: "FEB",
92
+ 3: "MAR",
93
+ 4: "APR",
94
+ 5: "MAY",
95
+ 6: "JUN",
96
+ 7: "JUL",
97
+ 8: "AUG",
98
+ 9: "SEP",
99
+ 10: "OCT",
100
+ 11: "NOV",
101
+ 12: "DEC"
102
+ };
103
+
104
+ function parseArgs(argv) {
105
+ const args = {
106
+ bucket: "week",
107
+ root: process.cwd(),
108
+ authorEmail: [],
109
+ authorName: [],
110
+ output: "github-lines-changed.png",
111
+ jsonOutput: "github-lines-changed.json"
112
+ };
113
+
114
+ for (let i = 0; i < argv.length; i += 1) {
115
+ const arg = argv[i];
116
+ const next = () => {
117
+ i += 1;
118
+ if (i >= argv.length) {
119
+ throw new Error(`missing value for ${arg}`);
120
+ }
121
+ return argv[i];
122
+ };
123
+
124
+ if (arg === "--days") args.days = Number(next());
125
+ else if (arg === "--from") args.fromDate = next();
126
+ else if (arg === "--to") args.toDate = next();
127
+ else if (arg === "--bucket") args.bucket = next();
128
+ else if (arg === "--root") args.root = next();
129
+ else if (arg === "--author-email") args.authorEmail.push(next());
130
+ else if (arg === "--author-name") args.authorName.push(next());
131
+ else if (arg === "--output") args.output = next();
132
+ else if (arg === "--json-output") args.jsonOutput = next();
133
+ else if (arg === "--help" || arg === "-h") {
134
+ printHelp();
135
+ process.exit(0);
136
+ } else {
137
+ throw new Error(`unknown argument: ${arg}`);
138
+ }
139
+ }
140
+
141
+ if (!["day", "week", "month"].includes(args.bucket)) {
142
+ throw new Error("--bucket must be day, week, or month");
143
+ }
144
+ if (args.days !== undefined && (!Number.isInteger(args.days) || args.days <= 0)) {
145
+ throw new Error("--days must be a positive integer");
146
+ }
147
+
148
+ return args;
149
+ }
150
+
151
+ function printHelp() {
152
+ process.stdout.write(
153
+ [
154
+ "Usage: locmeter [options]",
155
+ "",
156
+ "Defaults:",
157
+ " --bucket week",
158
+ " --to today",
159
+ " --from one year before --to",
160
+ " --author-email/--author-name auto-detected from current gh login",
161
+ "",
162
+ "Options:",
163
+ " --days N",
164
+ " --from YYYY-MM-DD",
165
+ " --to YYYY-MM-DD",
166
+ " --bucket day|week|month",
167
+ " --root /path/to/repos",
168
+ " --author-email you@example.com",
169
+ " --author-name yourname",
170
+ " --output chart.png",
171
+ " --json-output data.json"
172
+ ].join("\n") + "\n"
173
+ );
174
+ }
175
+
176
+ function parseDate(value) {
177
+ const [y, m, d] = value.split("-").map(Number);
178
+ return new Date(Date.UTC(y, m - 1, d));
179
+ }
180
+
181
+ function dateIso(date) {
182
+ return date.toISOString().slice(0, 10);
183
+ }
184
+
185
+ function addDays(date, days) {
186
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
187
+ }
188
+
189
+ function addYears(date, years) {
190
+ return new Date(Date.UTC(date.getUTCFullYear() + years, date.getUTCMonth(), date.getUTCDate()));
191
+ }
192
+
193
+ function computeDates(args) {
194
+ const today = new Date();
195
+ const utcToday = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
196
+ const endDate = args.toDate ? parseDate(args.toDate) : utcToday;
197
+ let startDate;
198
+ if (args.fromDate) startDate = parseDate(args.fromDate);
199
+ else if (args.days !== undefined) startDate = addDays(endDate, -(args.days - 1));
200
+ else startDate = addYears(endDate, -1);
201
+ if (startDate > endDate) throw new Error("--from must be before or equal to --to");
202
+ return { startDate, endDate };
203
+ }
204
+
205
+ function bucketStart(date, bucket) {
206
+ if (bucket === "day") return new Date(date.getTime());
207
+ if (bucket === "week") {
208
+ const day = (date.getUTCDay() + 6) % 7;
209
+ return addDays(date, -day);
210
+ }
211
+ if (bucket === "month") {
212
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
213
+ }
214
+ throw new Error(`unsupported bucket ${bucket}`);
215
+ }
216
+
217
+ async function runJson(command, args) {
218
+ const { stdout } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 * 50 });
219
+ return JSON.parse(stdout);
220
+ }
221
+
222
+ async function runText(command, args, cwd) {
223
+ const { stdout } = await execFileAsync(command, args, {
224
+ cwd,
225
+ maxBuffer: 1024 * 1024 * 200
226
+ });
227
+ return stdout;
228
+ }
229
+
230
+ async function getLogin() {
231
+ const user = await runJson("gh", ["api", "user"]);
232
+ return user.login;
233
+ }
234
+
235
+ async function getRepositories(login) {
236
+ const query = `
237
+ query($login:String!) {
238
+ user(login:$login) {
239
+ repositoriesContributedTo(
240
+ first: 100
241
+ includeUserRepositories: true
242
+ contributionTypes: [COMMIT]
243
+ orderBy: {field: UPDATED_AT, direction: DESC}
244
+ ) {
245
+ nodes { nameWithOwner }
246
+ }
247
+ }
248
+ }
249
+ `;
250
+ const data = await runJson("gh", ["api", "graphql", "-f", `query=${query}`, "-F", `login=${login}`]);
251
+ return data.data.user.repositoriesContributedTo.nodes.map((node) => node.nameWithOwner);
252
+ }
253
+
254
+ function resolveRepoPaths(repoNames, root) {
255
+ const resolved = [];
256
+ const missing = [];
257
+ for (const nameWithOwner of repoNames) {
258
+ const repoName = nameWithOwner.split("/")[1];
259
+ const candidate = path.join(root, repoName);
260
+ if (fs.existsSync(path.join(candidate, ".git"))) {
261
+ resolved.push([nameWithOwner, candidate]);
262
+ } else {
263
+ missing.push(nameWithOwner);
264
+ }
265
+ }
266
+ return { resolved, missing };
267
+ }
268
+
269
+ async function autodetectAuthorIdentities(repoPaths, login) {
270
+ const pairs = new Map();
271
+ const loginLower = login.toLowerCase();
272
+ await Promise.all(
273
+ repoPaths.map(async ([, repoPath]) => {
274
+ const output = await runText(
275
+ "git",
276
+ [
277
+ "log",
278
+ "--all",
279
+ "--extended-regexp",
280
+ "--regexp-ignore-case",
281
+ `--author=${login}`,
282
+ "--format=%ae%x09%an"
283
+ ],
284
+ repoPath
285
+ );
286
+ for (const line of output.split("\n")) {
287
+ if (!line.trim()) continue;
288
+ const [email, name] = line.split("\t");
289
+ if (name.trim().toLowerCase() === loginLower || email.toLowerCase().includes(loginLower)) {
290
+ pairs.set(`${email}\t${name}`, { email: email.trim(), name: name.trim() });
291
+ }
292
+ }
293
+ })
294
+ );
295
+ const emails = [...new Set([...pairs.values()].map((item) => item.email))].sort();
296
+ const names = [...new Set([...pairs.values()].map((item) => item.name))].sort();
297
+ return { emails, names };
298
+ }
299
+
300
+ function escapeRegex(value) {
301
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
+ }
303
+
304
+ function authorPattern(authorEmails, authorNames) {
305
+ return [...authorEmails, ...authorNames].filter(Boolean).map(escapeRegex).join("|");
306
+ }
307
+
308
+ async function collectRepoCommits(repoPath, startDate, endDate, pattern) {
309
+ return runText(
310
+ "git",
311
+ [
312
+ "log",
313
+ "--all",
314
+ "--extended-regexp",
315
+ "--regexp-ignore-case",
316
+ `--author=${pattern}`,
317
+ "--since",
318
+ `${dateIso(startDate)} 00:00:00`,
319
+ "--until",
320
+ `${dateIso(endDate)} 23:59:59`,
321
+ "--format=COMMIT%x09%H%x09%cs%x09%ae%x09%an",
322
+ "--numstat"
323
+ ],
324
+ repoPath
325
+ );
326
+ }
327
+
328
+ async function aggregate(repoPaths, startDate, endDate, bucket, authorEmails, authorNames) {
329
+ const pattern = authorPattern(authorEmails, authorNames);
330
+ if (!pattern) throw new Error("no author pattern available");
331
+
332
+ const outputs = await Promise.all(
333
+ repoPaths.map(([, repoPath]) => collectRepoCommits(repoPath, startDate, endDate, pattern))
334
+ );
335
+
336
+ const bucketTotals = new Map();
337
+ const rawDaily = new Map();
338
+ const seen = new Set();
339
+
340
+ for (const output of outputs) {
341
+ let current = null;
342
+ let runningLines = 0;
343
+
344
+ for (const line of output.split("\n")) {
345
+ if (line.startsWith("COMMIT\t")) {
346
+ if (current && !seen.has(current.sha)) {
347
+ seen.add(current.sha);
348
+ rawDaily.set(current.date, (rawDaily.get(current.date) || 0) + runningLines);
349
+ bucketTotals.set(current.bucket, (bucketTotals.get(current.bucket) || 0) + runningLines);
350
+ }
351
+ const [, sha, dateStr] = line.split("\t", 5);
352
+ const commitDate = parseDate(dateStr);
353
+ current = {
354
+ sha,
355
+ date: dateStr,
356
+ bucket: dateIso(bucketStart(commitDate, bucket))
357
+ };
358
+ runningLines = 0;
359
+ continue;
360
+ }
361
+
362
+ if (!line.trim() || !current) continue;
363
+ const parts = line.split("\t");
364
+ if (parts.length !== 3) continue;
365
+ const [added, deleted] = parts;
366
+ if (added !== "-") runningLines += Number(added);
367
+ if (deleted !== "-") runningLines += Number(deleted);
368
+ }
369
+
370
+ if (current && !seen.has(current.sha)) {
371
+ seen.add(current.sha);
372
+ rawDaily.set(current.date, (rawDaily.get(current.date) || 0) + runningLines);
373
+ bucketTotals.set(current.bucket, (bucketTotals.get(current.bucket) || 0) + runningLines);
374
+ }
375
+ }
376
+
377
+ const ordered = new Map();
378
+ let cursor = bucketStart(startDate, bucket);
379
+ const last = bucketStart(endDate, bucket);
380
+ while (cursor <= last) {
381
+ const key = dateIso(cursor);
382
+ ordered.set(key, bucketTotals.get(key) || 0);
383
+ if (bucket === "day") cursor = addDays(cursor, 1);
384
+ else if (bucket === "week") cursor = addDays(cursor, 7);
385
+ else cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1));
386
+ }
387
+
388
+ return {
389
+ series: Object.fromEntries(ordered),
390
+ rawDaily: Object.fromEntries([...rawDaily.entries()].sort(([a], [b]) => a.localeCompare(b)))
391
+ };
392
+ }
393
+
394
+ function createCanvas(width, height, color) {
395
+ const pixels = new Uint8Array(width * height * 3);
396
+ for (let i = 0; i < width * height; i += 1) {
397
+ pixels[i * 3] = color[0];
398
+ pixels[i * 3 + 1] = color[1];
399
+ pixels[i * 3 + 2] = color[2];
400
+ }
401
+ return { width, height, pixels };
402
+ }
403
+
404
+ function setPx(canvas, x, y, color) {
405
+ if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return;
406
+ const idx = (y * canvas.width + x) * 3;
407
+ canvas.pixels[idx] = color[0];
408
+ canvas.pixels[idx + 1] = color[1];
409
+ canvas.pixels[idx + 2] = color[2];
410
+ }
411
+
412
+ function fillRect(canvas, x1, y1, x2, y2, color) {
413
+ const startX = Math.max(0, Math.min(canvas.width, x1));
414
+ const endX = Math.max(0, Math.min(canvas.width, x2));
415
+ const startY = Math.max(0, Math.min(canvas.height, y1));
416
+ const endY = Math.max(0, Math.min(canvas.height, y2));
417
+ for (let y = startY; y < endY; y += 1) {
418
+ for (let x = startX; x < endX; x += 1) setPx(canvas, x, y, color);
419
+ }
420
+ }
421
+
422
+ function drawLine(canvas, x0, y0, x1, y1, color, thickness = 1) {
423
+ let cx = x0;
424
+ let cy = y0;
425
+ const dx = Math.abs(x1 - x0);
426
+ const dy = Math.abs(y1 - y0);
427
+ const sx = x0 < x1 ? 1 : -1;
428
+ const sy = y0 < y1 ? 1 : -1;
429
+ let err = dx - dy;
430
+ while (true) {
431
+ for (let ox = -Math.floor(thickness / 2); ox <= Math.floor(thickness / 2); ox += 1) {
432
+ for (let oy = -Math.floor(thickness / 2); oy <= Math.floor(thickness / 2); oy += 1) {
433
+ setPx(canvas, cx + ox, cy + oy, color);
434
+ }
435
+ }
436
+ if (cx === x1 && cy === y1) break;
437
+ const e2 = err * 2;
438
+ if (e2 > -dy) {
439
+ err -= dy;
440
+ cx += sx;
441
+ }
442
+ if (e2 < dx) {
443
+ err += dx;
444
+ cy += sy;
445
+ }
446
+ }
447
+ }
448
+
449
+ function drawPolyFill(canvas, points, baseline, color) {
450
+ const polygon = [...points, [points[points.length - 1][0], baseline], [points[0][0], baseline]];
451
+ const minY = Math.max(0, Math.min(...polygon.map(([, y]) => y)));
452
+ const maxY = Math.min(canvas.height - 1, Math.max(...polygon.map(([, y]) => y)));
453
+ for (let y = minY; y <= maxY; y += 1) {
454
+ const intersections = [];
455
+ for (let i = 0; i < polygon.length; i += 1) {
456
+ const [x1, y1] = polygon[i];
457
+ const [x2, y2] = polygon[(i + 1) % polygon.length];
458
+ if (y1 === y2) continue;
459
+ if (Math.min(y1, y2) <= y && y < Math.max(y1, y2)) {
460
+ const ratio = (y - y1) / (y2 - y1);
461
+ intersections.push(Math.round(x1 + ratio * (x2 - x1)));
462
+ }
463
+ }
464
+ intersections.sort((a, b) => a - b);
465
+ for (let i = 0; i < intersections.length; i += 2) {
466
+ if (i + 1 >= intersections.length) break;
467
+ fillRect(canvas, intersections[i], y, intersections[i + 1] + 1, y + 1, color);
468
+ }
469
+ }
470
+ }
471
+
472
+ function drawChar(canvas, x, y, ch, color, scale = 2) {
473
+ const pattern = FONT[ch] || FONT[" "];
474
+ for (let row = 0; row < pattern.length; row += 1) {
475
+ for (let col = 0; col < pattern[row].length; col += 1) {
476
+ if (pattern[row][col] === "1") {
477
+ fillRect(canvas, x + col * scale, y + row * scale, x + (col + 1) * scale, y + (row + 1) * scale, color);
478
+ }
479
+ }
480
+ }
481
+ return (pattern[0].length + 1) * scale;
482
+ }
483
+
484
+ function drawText(canvas, x, y, text, color, scale = 2) {
485
+ let cursor = x;
486
+ for (const ch of text) cursor += drawChar(canvas, cursor, y, ch, color, scale);
487
+ }
488
+
489
+ function textWidth(text, scale = 2) {
490
+ let width = 0;
491
+ for (const ch of text) {
492
+ const pattern = FONT[ch] || FONT[" "];
493
+ width += (pattern[0].length + 1) * scale;
494
+ }
495
+ return width;
496
+ }
497
+
498
+ function crcTable() {
499
+ const table = new Uint32Array(256);
500
+ for (let n = 0; n < 256; n += 1) {
501
+ let c = n;
502
+ for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
503
+ table[n] = c >>> 0;
504
+ }
505
+ return table;
506
+ }
507
+
508
+ const CRC_TABLE = crcTable();
509
+
510
+ function crc32(buffer) {
511
+ let c = 0xffffffff;
512
+ for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
513
+ return (c ^ 0xffffffff) >>> 0;
514
+ }
515
+
516
+ function pngChunk(tag, data) {
517
+ const tagBuffer = Buffer.from(tag, "ascii");
518
+ const length = Buffer.alloc(4);
519
+ length.writeUInt32BE(data.length, 0);
520
+ const crc = Buffer.alloc(4);
521
+ crc.writeUInt32BE(crc32(Buffer.concat([tagBuffer, data])), 0);
522
+ return Buffer.concat([length, tagBuffer, data, crc]);
523
+ }
524
+
525
+ function savePng(canvas, filePath) {
526
+ const raw = Buffer.alloc((canvas.width * 3 + 1) * canvas.height);
527
+ for (let y = 0; y < canvas.height; y += 1) {
528
+ const rowStart = y * (canvas.width * 3 + 1);
529
+ raw[rowStart] = 0;
530
+ raw.set(canvas.pixels.subarray(y * canvas.width * 3, (y + 1) * canvas.width * 3), rowStart + 1);
531
+ }
532
+ const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
533
+ const ihdr = Buffer.alloc(13);
534
+ ihdr.writeUInt32BE(canvas.width, 0);
535
+ ihdr.writeUInt32BE(canvas.height, 4);
536
+ ihdr[8] = 8;
537
+ ihdr[9] = 2;
538
+ const png = Buffer.concat([
539
+ signature,
540
+ pngChunk("IHDR", ihdr),
541
+ pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
542
+ pngChunk("IEND", Buffer.alloc(0))
543
+ ]);
544
+ fs.writeFileSync(filePath, png);
545
+ }
546
+
547
+ function niceUpperBound(maxValue) {
548
+ if (maxValue <= 10) return 10;
549
+ const power = 10 ** Math.floor(Math.log10(maxValue));
550
+ for (const factor of [1, 2, 5, 10]) {
551
+ const bound = factor * power;
552
+ if (maxValue <= bound) return bound;
553
+ }
554
+ return 10 * power;
555
+ }
556
+
557
+ function formatCompact(value) {
558
+ if (value >= 1000) {
559
+ const text = (value / 1000).toFixed(1).replace(/\.0$/, "");
560
+ return `${text}K`;
561
+ }
562
+ return String(value);
563
+ }
564
+
565
+ function formatGrouped(value) {
566
+ return new Intl.NumberFormat("de-AT").format(value);
567
+ }
568
+
569
+ function xLabel(date, bucket) {
570
+ const month = MONTHS[date.getUTCMonth() + 1];
571
+ if (bucket === "day" || bucket === "month") return `${month} ${String(date.getUTCFullYear()).slice(2)}`;
572
+ return `${month} ${date.getUTCDate()}`;
573
+ }
574
+
575
+ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
576
+ const dates = Object.keys(series).sort();
577
+ const values = dates.map((date) => series[date]);
578
+ const width = 1800;
579
+ const height = 980;
580
+ const left = 130;
581
+ const right = 40;
582
+ const top = 160;
583
+ const bottom = 130;
584
+ const plotLeft = left;
585
+ const plotRight = width - right;
586
+ const plotTop = top;
587
+ const plotBottom = height - bottom;
588
+ const plotWidth = plotRight - plotLeft;
589
+ const plotHeight = plotBottom - plotTop;
590
+ const maxValue = values.length ? Math.max(...values) : 0;
591
+ const upper = niceUpperBound(Math.max(maxValue, 10));
592
+
593
+ const canvas = createCanvas(width, height, BG);
594
+ fillRect(canvas, plotLeft, plotTop, plotRight, plotBottom, PLOT_BG);
595
+
596
+ for (let idx = 0; idx < 6; idx += 1) {
597
+ const value = Math.round((upper * idx) / 5);
598
+ const y = plotBottom - Math.floor((plotHeight * idx) / 5);
599
+ drawLine(canvas, plotLeft, y, plotRight, y, GRID);
600
+ drawText(canvas, 18, y - 10, formatCompact(value), AXIS, 3);
601
+ }
602
+
603
+ drawLine(canvas, plotLeft, plotBottom, plotRight, plotBottom, AXIS, 2);
604
+ drawLine(canvas, plotLeft, plotTop, plotLeft, plotBottom, AXIS, 2);
605
+
606
+ const points = values.map((value, idx) => {
607
+ const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, values.length - 1));
608
+ const y = plotBottom - Math.floor((value / upper) * plotHeight);
609
+ return [x, y];
610
+ });
611
+ if (points.length) {
612
+ drawPolyFill(canvas, points, plotBottom, FILL);
613
+ for (let i = 0; i < points.length - 1; i += 1) {
614
+ drawLine(canvas, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1], LINE, 3);
615
+ }
616
+ }
617
+
618
+ const tickCount = Math.min(8, dates.length);
619
+ const step = Math.max(1, Math.floor(dates.length / Math.max(1, tickCount - 1)));
620
+ const tickIndexes = [];
621
+ for (let i = 0; i < dates.length; i += step) tickIndexes.push(i);
622
+ if (tickIndexes[tickIndexes.length - 1] !== dates.length - 1) tickIndexes.push(dates.length - 1);
623
+
624
+ let drawnUntil = -1;
625
+ for (const idx of tickIndexes) {
626
+ const x = plotLeft + Math.floor((idx * plotWidth) / Math.max(1, values.length - 1));
627
+ const label = xLabel(parseDate(dates[idx]), bucket);
628
+ const widthPx = textWidth(label, 2);
629
+ const labelX = Math.max(plotLeft, Math.min(x - Math.floor(widthPx / 2), plotRight - widthPx));
630
+ if (labelX <= drawnUntil + 12) continue;
631
+ drawLine(canvas, x, plotBottom, x, plotBottom + 8, AXIS);
632
+ drawText(canvas, labelX, plotBottom + 20, label, AXIS, 2);
633
+ drawnUntil = labelX + widthPx;
634
+ }
635
+
636
+ drawText(canvas, 24, 24, `${login} lines per ${bucket}`, TEXT, 5);
637
+ drawText(canvas, 24, 74, `${dateIso(startDate)} to ${dateIso(endDate)}`, AXIS, 3);
638
+ drawText(
639
+ canvas,
640
+ 24,
641
+ 104,
642
+ `Total: ${formatGrouped(values.reduce((sum, value) => sum + value, 0))} Peak: ${formatGrouped(maxValue)}`,
643
+ AXIS,
644
+ 3
645
+ );
646
+
647
+ savePng(canvas, outputPath);
648
+ }
649
+
650
+ async function main() {
651
+ const args = parseArgs(process.argv.slice(2));
652
+ const { startDate, endDate } = computeDates(args);
653
+ const root = path.resolve(args.root);
654
+ const output = path.resolve(args.output);
655
+ const jsonOutput = path.resolve(args.jsonOutput);
656
+
657
+ const login = await getLogin();
658
+ const repoNames = await getRepositories(login);
659
+ const { resolved: repoPaths, missing } = resolveRepoPaths(repoNames, root);
660
+
661
+ let authorEmails = [...new Set(args.authorEmail)];
662
+ let authorNames = [...new Set(args.authorName)];
663
+
664
+ if (!authorEmails.length && !authorNames.length) {
665
+ const detected = await autodetectAuthorIdentities(repoPaths, login);
666
+ authorEmails = detected.emails;
667
+ authorNames = detected.names;
668
+ }
669
+
670
+ if (!authorEmails.length && !authorNames.length) {
671
+ throw new Error("could not auto-detect your author identity; pass --author-email or --author-name");
672
+ }
673
+
674
+ const { series, rawDaily } = await aggregate(
675
+ repoPaths,
676
+ startDate,
677
+ endDate,
678
+ args.bucket,
679
+ authorEmails,
680
+ authorNames
681
+ );
682
+
683
+ renderChart(series, login, startDate, endDate, args.bucket, output);
684
+
685
+ const values = Object.values(series);
686
+ fs.writeFileSync(
687
+ jsonOutput,
688
+ JSON.stringify(
689
+ {
690
+ login,
691
+ from: dateIso(startDate),
692
+ to: dateIso(endDate),
693
+ bucket: args.bucket,
694
+ author_emails: authorEmails,
695
+ author_names: authorNames,
696
+ local_repositories_used: repoPaths.map(([name]) => name),
697
+ missing_repositories: missing,
698
+ total_lines_changed: values.reduce((sum, value) => sum + value, 0),
699
+ peak_lines_changed: values.length ? Math.max(...values) : 0,
700
+ bucketed_lines_changed: series,
701
+ daily_lines_changed: rawDaily
702
+ },
703
+ null,
704
+ 2
705
+ )
706
+ );
707
+
708
+ process.stdout.write(`${output}\n${jsonOutput}\n`);
709
+ }
710
+
711
+ main().catch((error) => {
712
+ process.stderr.write(`${error.message}\n`);
713
+ process.exit(1);
714
+ });
Binary file
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "locmeter",
3
+ "version": "0.1.0",
4
+ "description": "Render a PNG chart of lines changed over time from your GitHub contribution repos.",
5
+ "license": "MIT",
6
+ "preferGlobal": true,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jojopirker/locmeter.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/jojopirker/locmeter/issues"
13
+ },
14
+ "bin": {
15
+ "locmeter": "./bin/locmeter.js"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "README.md",
20
+ "examples/jojo-weekly.png"
21
+ ],
22
+ "keywords": [
23
+ "cli",
24
+ "git",
25
+ "github",
26
+ "loc",
27
+ "png"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "check": "node --check ./bin/locmeter.js"
34
+ }
35
+ }