@danielwh2/contribution-graph 1.0.14
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 +21 -0
- package/README.md +327 -0
- package/dist/contributionGraph.d.ts +150 -0
- package/dist/contributionGraph.js +328 -0
- package/dist/github.d.ts +19 -0
- package/dist/github.js +109 -0
- package/dist/htmlGraph.d.ts +13 -0
- package/dist/htmlGraph.js +199 -0
- package/dist/hudGraph.d.ts +25 -0
- package/dist/hudGraph.js +194 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +109 -0
- package/dist/profileGraph.d.ts +7 -0
- package/dist/profileGraph.js +81 -0
- package/dist/react.d.ts +7 -0
- package/dist/react.js +22 -0
- package/dist/solid.d.ts +14 -0
- package/dist/solid.js +13 -0
- package/dist/svelte.d.ts +9 -0
- package/dist/svelte.js +12 -0
- package/dist/tooltip.d.ts +2 -0
- package/dist/tooltip.js +84 -0
- package/dist/vue.d.ts +25 -0
- package/dist/vue.js +33 -0
- package/package.json +101 -0
- package/style.css +610 -0
- package/style.css.d.ts +2 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contribution-graph core — GitHub-style heatmap renderer.
|
|
3
|
+
*
|
|
4
|
+
* Renders an SVG contribution graph into a host element. All date math uses
|
|
5
|
+
* UTC to stay timezone-stable; weeks start on Sunday (GitHub convention).
|
|
6
|
+
* Human-readable date labels are produced with dayjs (UTC mode).
|
|
7
|
+
*/
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import utc from "dayjs/plugin/utc.js";
|
|
10
|
+
dayjs.extend(utc);
|
|
11
|
+
const DEFAULT_COLORS = [
|
|
12
|
+
"#ebedf0",
|
|
13
|
+
"#9be9a8",
|
|
14
|
+
"#40c463",
|
|
15
|
+
"#30a14e",
|
|
16
|
+
"#216e39",
|
|
17
|
+
];
|
|
18
|
+
const WEEKDAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""];
|
|
19
|
+
const MONTH_LABELS = [
|
|
20
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
21
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
22
|
+
];
|
|
23
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
24
|
+
const ROOT_CLASS = "contribution-graph";
|
|
25
|
+
/** Parse a YYYY-MM-DD string into a UTC midnight Date. */
|
|
26
|
+
export function parseDate(value) {
|
|
27
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
28
|
+
return new Date(Date.UTC(y, m - 1, d));
|
|
29
|
+
}
|
|
30
|
+
/** Format a UTC Date as YYYY-MM-DD. */
|
|
31
|
+
export function formatDate(date) {
|
|
32
|
+
const y = date.getUTCFullYear();
|
|
33
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
34
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
35
|
+
return `${y}-${m}-${d}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Format a YYYY-MM-DD date as a human-readable label in UTC, e.g.
|
|
39
|
+
* "Jun 22, 2026". Used for tooltip/click-handler context so consumers don't
|
|
40
|
+
* have to format ISO strings themselves.
|
|
41
|
+
*/
|
|
42
|
+
export function formatHumanDate(date) {
|
|
43
|
+
return dayjs.utc(date).format("MMM D, YYYY");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Row index of a UTC date within its week column, given the week-start day.
|
|
47
|
+
* weekStart=1 (Monday) → Mon=0 … Sun=6; weekStart=0 (Sunday) → Sun=0 … Sat=6.
|
|
48
|
+
*/
|
|
49
|
+
function utcDay(date, weekStart = 1) {
|
|
50
|
+
return (date.getUTCDay() - weekStart + 7) % 7;
|
|
51
|
+
}
|
|
52
|
+
/** Add n UTC days to a date. */
|
|
53
|
+
function addDays(date, days) {
|
|
54
|
+
const next = new Date(date.getTime());
|
|
55
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
export function resolveOptions(options = {}) {
|
|
59
|
+
const levels = options.levels ?? 5;
|
|
60
|
+
const colors = options.colors ?? DEFAULT_COLORS;
|
|
61
|
+
const showCounts = options.showCounts ?? false;
|
|
62
|
+
const cellSize = options.cellSize ?? 11;
|
|
63
|
+
const showLabels = options.showLabels ?? true;
|
|
64
|
+
return {
|
|
65
|
+
showCounts,
|
|
66
|
+
weeks: options.weeks ?? 53,
|
|
67
|
+
weekStart: options.weekStart === 0 ? 0 : 1,
|
|
68
|
+
cellSize,
|
|
69
|
+
cellGap: options.cellGap ?? 3,
|
|
70
|
+
cellRadius: options.cellRadius ?? 3,
|
|
71
|
+
colors: colors.length >= levels ? colors : DEFAULT_COLORS,
|
|
72
|
+
levels,
|
|
73
|
+
showLabels,
|
|
74
|
+
showMonthLabels: options.showMonthLabels ?? showLabels,
|
|
75
|
+
showWeekdayLabels: options.showWeekdayLabels ?? showLabels,
|
|
76
|
+
labelColor: options.labelColor ?? "#a1a1aa",
|
|
77
|
+
labelFontSize: options.labelFontSize ?? 11,
|
|
78
|
+
countColors: options.countColors,
|
|
79
|
+
countFontSize: options.countFontSize ?? 0,
|
|
80
|
+
monthLabels: options.monthLabels ?? MONTH_LABELS,
|
|
81
|
+
weekdayLabels: normalizeWeekdayLabels(options.weekdayLabels),
|
|
82
|
+
until: normalizeUntil(options.until),
|
|
83
|
+
tooltip: options.tooltip,
|
|
84
|
+
onCellClick: options.onCellClick,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Coerce a weekday-label override to exactly 7 entries (Mon..Sun). */
|
|
88
|
+
function normalizeWeekdayLabels(labels) {
|
|
89
|
+
if (!labels)
|
|
90
|
+
return WEEKDAY_LABELS;
|
|
91
|
+
return Array.from({ length: 7 }, (_, i) => labels[i] ?? "");
|
|
92
|
+
}
|
|
93
|
+
function normalizeUntil(until) {
|
|
94
|
+
if (until instanceof Date)
|
|
95
|
+
return new Date(until.getTime());
|
|
96
|
+
if (typeof until === "string")
|
|
97
|
+
return parseDate(until);
|
|
98
|
+
const now = new Date();
|
|
99
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the grid for a date range ending at `opts.until`, spanning `opts.weeks`
|
|
103
|
+
* week columns. The range is snapped back so the first column starts on Monday.
|
|
104
|
+
*/
|
|
105
|
+
export function computeGrid(data, opts) {
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
let max = 0;
|
|
108
|
+
for (const d of data) {
|
|
109
|
+
counts.set(d.date, d.count);
|
|
110
|
+
if (d.count > max)
|
|
111
|
+
max = d.count;
|
|
112
|
+
}
|
|
113
|
+
// End at the last day of the week containing `until`.
|
|
114
|
+
const end = addDays(opts.until, 6 - utcDay(opts.until, opts.weekStart));
|
|
115
|
+
// Start `weeks*7 - 1` days earlier, then snap to the week-start day.
|
|
116
|
+
const rawStart = addDays(end, -(opts.weeks * 7 - 1));
|
|
117
|
+
const start = addDays(rawStart, -utcDay(rawStart, opts.weekStart));
|
|
118
|
+
const cells = [];
|
|
119
|
+
const months = [];
|
|
120
|
+
const actualWeeks = Math.round((end.getTime() - start.getTime()) / 86400000 / 7) + 1;
|
|
121
|
+
let lastMonth = start.getUTCMonth();
|
|
122
|
+
for (let w = 0; w < actualWeeks; w++) {
|
|
123
|
+
const firstDayOfWeek = addDays(start, w * 7);
|
|
124
|
+
const month = firstDayOfWeek.getUTCMonth();
|
|
125
|
+
if (month !== lastMonth && w > 0) {
|
|
126
|
+
months.push({ label: opts.monthLabels[month], col: w });
|
|
127
|
+
lastMonth = month;
|
|
128
|
+
}
|
|
129
|
+
else if (w === 0) {
|
|
130
|
+
months.push({ label: opts.monthLabels[month], col: w });
|
|
131
|
+
}
|
|
132
|
+
for (let d = 0; d < 7; d++) {
|
|
133
|
+
const date = addDays(start, w * 7 + d);
|
|
134
|
+
if (date > end)
|
|
135
|
+
continue;
|
|
136
|
+
const key = formatDate(date);
|
|
137
|
+
const currentDay = data.find(item => item.date === key);
|
|
138
|
+
const count = counts.get(key) ?? 0;
|
|
139
|
+
cells.push({
|
|
140
|
+
date: key,
|
|
141
|
+
count: currentDay ? currentDay.count : count,
|
|
142
|
+
level: levelForCount(count, max, opts.levels),
|
|
143
|
+
color: currentDay?.color,
|
|
144
|
+
week: w,
|
|
145
|
+
day: d,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { cells, weeks: actualWeeks, months, max, totalCols: actualWeeks };
|
|
150
|
+
}
|
|
151
|
+
/** Map a count to an intensity level 0..levels-1. */
|
|
152
|
+
export function levelForCount(count, max, levels) {
|
|
153
|
+
if (count <= 0)
|
|
154
|
+
return 0;
|
|
155
|
+
if (max <= 0)
|
|
156
|
+
return 0;
|
|
157
|
+
// Even division across the active levels 1..levels-1.
|
|
158
|
+
const step = max / (levels - 1);
|
|
159
|
+
let level = Math.ceil(count / step);
|
|
160
|
+
if (level < 1)
|
|
161
|
+
level = 1;
|
|
162
|
+
if (level > levels - 1)
|
|
163
|
+
level = levels - 1;
|
|
164
|
+
return level;
|
|
165
|
+
}
|
|
166
|
+
export function normalizeCssColor(value, fallback) {
|
|
167
|
+
const color = value?.trim();
|
|
168
|
+
if (!color)
|
|
169
|
+
return fallback;
|
|
170
|
+
if (/["'<>;\\]/.test(color) || /url\s*\(/i.test(color))
|
|
171
|
+
return fallback;
|
|
172
|
+
if (typeof CSS !== "undefined" && typeof CSS.supports === "function") {
|
|
173
|
+
return CSS.supports("color", color) ? color : fallback;
|
|
174
|
+
}
|
|
175
|
+
if (/^#[0-9a-f]{3,8}$/i.test(color))
|
|
176
|
+
return color;
|
|
177
|
+
if (/^[a-z]+$/i.test(color))
|
|
178
|
+
return color;
|
|
179
|
+
if (/^(?:rgb|rgba|hsl|hsla)\([\d\s.,%/-]+\)$/i.test(color))
|
|
180
|
+
return color;
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
function cellColor(cell, opts) {
|
|
184
|
+
const paletteFallback = normalizeCssColor(opts.colors[cell.level] ?? opts.colors[0], DEFAULT_COLORS[0]);
|
|
185
|
+
return normalizeCssColor(cell.color, paletteFallback);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Render the graph SVG into `element`. Replaces prior content.
|
|
189
|
+
* Returns the SVG element for callers that need to attach listeners.
|
|
190
|
+
*/
|
|
191
|
+
export function buildGraph(element, data, options = {}) {
|
|
192
|
+
const opts = resolveOptions(options);
|
|
193
|
+
const grid = computeGrid(data, opts);
|
|
194
|
+
const svg = renderSvg(grid, opts);
|
|
195
|
+
clearGraph(element);
|
|
196
|
+
element.classList.add(ROOT_CLASS);
|
|
197
|
+
element.appendChild(svg);
|
|
198
|
+
if (opts.onCellClick)
|
|
199
|
+
attachClickHandler(svg, opts.onCellClick);
|
|
200
|
+
return svg;
|
|
201
|
+
}
|
|
202
|
+
/** Remove the rendered graph and root class. */
|
|
203
|
+
export function clearGraph(element) {
|
|
204
|
+
element.classList.remove(ROOT_CLASS);
|
|
205
|
+
element.replaceChildren();
|
|
206
|
+
}
|
|
207
|
+
const LABEL_FONT_FAMILY = "Geist Mono,monospace";
|
|
208
|
+
let measureContext;
|
|
209
|
+
/** Lazily create (and cache) a canvas 2D context for measuring text. */
|
|
210
|
+
function getMeasureContext() {
|
|
211
|
+
if (measureContext !== undefined)
|
|
212
|
+
return measureContext;
|
|
213
|
+
try {
|
|
214
|
+
const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
|
|
215
|
+
measureContext = canvas?.getContext?.("2d") ?? null;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
measureContext = null;
|
|
219
|
+
}
|
|
220
|
+
return measureContext;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Measure the width of the widest label at the given font size. Uses canvas
|
|
224
|
+
* text metrics when available so the gutter always fits the real glyphs;
|
|
225
|
+
* falls back to a generous per-character estimate otherwise.
|
|
226
|
+
*/
|
|
227
|
+
function maxLabelWidth(labels, fontSize) {
|
|
228
|
+
const ctx = getMeasureContext();
|
|
229
|
+
if (ctx) {
|
|
230
|
+
ctx.font = `${fontSize}px ${LABEL_FONT_FAMILY}`;
|
|
231
|
+
let max = 0;
|
|
232
|
+
for (const label of labels) {
|
|
233
|
+
if (label)
|
|
234
|
+
max = Math.max(max, ctx.measureText(label).width);
|
|
235
|
+
}
|
|
236
|
+
return max;
|
|
237
|
+
}
|
|
238
|
+
const longest = labels.reduce((n, label) => Math.max(n, label.length), 0);
|
|
239
|
+
return longest * fontSize * 0.65;
|
|
240
|
+
}
|
|
241
|
+
function renderSvg(grid, opts) {
|
|
242
|
+
const { cellSize, cellGap, cellRadius, showMonthLabels, showWeekdayLabels, labelFontSize } = opts;
|
|
243
|
+
// Reserve a gutter wide enough for the actual weekday text (+2px breathing
|
|
244
|
+
// room) plus a gap, so labels never overflow into the cells.
|
|
245
|
+
const weekdayLabelWidth = showWeekdayLabels
|
|
246
|
+
? Math.ceil(maxLabelWidth(opts.weekdayLabels, labelFontSize)) + 2
|
|
247
|
+
: 0;
|
|
248
|
+
const labelCol = showWeekdayLabels ? weekdayLabelWidth + cellGap : 0;
|
|
249
|
+
const labelRow = showMonthLabels ? labelFontSize + cellGap : 0;
|
|
250
|
+
const step = cellSize + cellGap;
|
|
251
|
+
const gridWidth = grid.totalCols * step - cellGap;
|
|
252
|
+
const gridHeight = 7 * step - cellGap;
|
|
253
|
+
// Month labels are left-anchored at each column; pad only for the last label
|
|
254
|
+
// so the svg does not end with a large empty strip after the final week.
|
|
255
|
+
const lastMonthLabel = grid.months[grid.months.length - 1]?.label ?? "";
|
|
256
|
+
const monthLabelPad = showMonthLabels && lastMonthLabel
|
|
257
|
+
? Math.ceil(maxLabelWidth([lastMonthLabel], labelFontSize)) + 2
|
|
258
|
+
: 0;
|
|
259
|
+
const width = labelCol + gridWidth + monthLabelPad;
|
|
260
|
+
const height = labelRow + gridHeight;
|
|
261
|
+
const labelColor = normalizeCssColor(opts.labelColor, "#a1a1aa");
|
|
262
|
+
const svg = document.createElementNS(NS, "svg");
|
|
263
|
+
svg.setAttribute("width", String(width));
|
|
264
|
+
svg.setAttribute("height", String(height));
|
|
265
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
266
|
+
svg.setAttribute("role", "img");
|
|
267
|
+
svg.setAttribute("aria-label", "Contribution graph");
|
|
268
|
+
if (showMonthLabels) {
|
|
269
|
+
for (const m of grid.months) {
|
|
270
|
+
if (!m.label)
|
|
271
|
+
continue;
|
|
272
|
+
const text = document.createElementNS(NS, "text");
|
|
273
|
+
text.setAttribute("x", String(labelCol + m.col * step));
|
|
274
|
+
text.setAttribute("y", String(labelFontSize));
|
|
275
|
+
text.setAttribute("fill", labelColor);
|
|
276
|
+
text.setAttribute("font-size", String(labelFontSize));
|
|
277
|
+
text.setAttribute("font-weight", "600");
|
|
278
|
+
text.setAttribute("font-family", LABEL_FONT_FAMILY);
|
|
279
|
+
text.textContent = m.label;
|
|
280
|
+
svg.appendChild(text);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (showWeekdayLabels) {
|
|
284
|
+
for (let d = 0; d < 7; d++) {
|
|
285
|
+
const label = opts.weekdayLabels[d];
|
|
286
|
+
if (!label)
|
|
287
|
+
continue;
|
|
288
|
+
const text = document.createElementNS(NS, "text");
|
|
289
|
+
text.setAttribute("x", String(weekdayLabelWidth));
|
|
290
|
+
text.setAttribute("y", String(labelRow + d * step + cellSize - 1));
|
|
291
|
+
text.setAttribute("fill", labelColor);
|
|
292
|
+
text.setAttribute("font-size", String(labelFontSize));
|
|
293
|
+
text.setAttribute("font-family", LABEL_FONT_FAMILY);
|
|
294
|
+
text.setAttribute("text-anchor", "end");
|
|
295
|
+
text.textContent = label;
|
|
296
|
+
svg.appendChild(text);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const cell of grid.cells) {
|
|
300
|
+
const rect = document.createElementNS(NS, "rect");
|
|
301
|
+
rect.setAttribute("x", String(labelCol + cell.week * step));
|
|
302
|
+
rect.setAttribute("y", String(labelRow + cell.day * step));
|
|
303
|
+
rect.setAttribute("width", String(cellSize));
|
|
304
|
+
rect.setAttribute("height", String(cellSize));
|
|
305
|
+
rect.setAttribute("rx", String(cellRadius));
|
|
306
|
+
rect.setAttribute("ry", String(cellRadius));
|
|
307
|
+
rect.setAttribute("fill", cellColor(cell, opts));
|
|
308
|
+
rect.setAttribute("data-date", cell.date);
|
|
309
|
+
rect.setAttribute("data-count", String(cell.count));
|
|
310
|
+
rect.setAttribute("data-level", String(cell.level));
|
|
311
|
+
svg.appendChild(rect);
|
|
312
|
+
}
|
|
313
|
+
return svg;
|
|
314
|
+
}
|
|
315
|
+
function attachClickHandler(svg, handler) {
|
|
316
|
+
svg.addEventListener("click", (event) => {
|
|
317
|
+
const target = event.target;
|
|
318
|
+
const rect = target?.closest?.("rect");
|
|
319
|
+
if (!rect)
|
|
320
|
+
return;
|
|
321
|
+
const date = rect.getAttribute("data-date");
|
|
322
|
+
const count = Number(rect.getAttribute("data-count") ?? 0);
|
|
323
|
+
const level = Number(rect.getAttribute("data-level") ?? 0);
|
|
324
|
+
if (!date)
|
|
325
|
+
return;
|
|
326
|
+
handler({ date, count, level, dateLabel: formatHumanDate(date) });
|
|
327
|
+
});
|
|
328
|
+
}
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ContributionDay } from "./contributionGraph.js";
|
|
2
|
+
export interface FetchGitHubContributionsOptions {
|
|
3
|
+
username: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
from?: Date | string;
|
|
6
|
+
to?: Date | string;
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
includeGitHubColors?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface GitHubContributionsResult {
|
|
13
|
+
username: string;
|
|
14
|
+
from: string;
|
|
15
|
+
to: string;
|
|
16
|
+
total: number;
|
|
17
|
+
data: ContributionDay[];
|
|
18
|
+
}
|
|
19
|
+
export declare function fetchGitHubContributions(options: FetchGitHubContributionsOptions): Promise<GitHubContributionsResult>;
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { formatDate, parseDate } from "./contributionGraph.js";
|
|
2
|
+
const GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql";
|
|
3
|
+
const CONTRIBUTIONS_QUERY = `
|
|
4
|
+
query ContributionGraph($username: String!, $from: DateTime!, $to: DateTime!) {
|
|
5
|
+
user(login: $username) {
|
|
6
|
+
contributionsCollection(from: $from, to: $to) {
|
|
7
|
+
contributionCalendar {
|
|
8
|
+
totalContributions
|
|
9
|
+
weeks {
|
|
10
|
+
contributionDays {
|
|
11
|
+
date
|
|
12
|
+
contributionCount
|
|
13
|
+
color
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
export async function fetchGitHubContributions(options) {
|
|
22
|
+
const username = options.username.trim();
|
|
23
|
+
if (!username)
|
|
24
|
+
throw new Error("GitHub username is required.");
|
|
25
|
+
const fetcher = options.fetcher ?? globalThis.fetch;
|
|
26
|
+
if (!fetcher)
|
|
27
|
+
throw new Error("A fetch implementation is required.");
|
|
28
|
+
const toDate = normalizeDate(options.to ?? todayUtc());
|
|
29
|
+
const fromDate = normalizeDate(options.from ?? addUtcDays(toDate, -364));
|
|
30
|
+
const from = formatDate(fromDate);
|
|
31
|
+
const to = formatDate(toDate);
|
|
32
|
+
const endpoint = options.endpoint ?? GITHUB_GRAPHQL_ENDPOINT;
|
|
33
|
+
const headers = {
|
|
34
|
+
"Accept": "application/vnd.github+json",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
};
|
|
37
|
+
if (options.token) {
|
|
38
|
+
if (!isGitHubGraphQLEndpoint(endpoint)) {
|
|
39
|
+
throw new Error("GitHub tokens can only be sent to the official GitHub GraphQL endpoint. Omit token when using a proxy endpoint.");
|
|
40
|
+
}
|
|
41
|
+
headers.Authorization = `Bearer ${options.token}`;
|
|
42
|
+
}
|
|
43
|
+
const response = await fetcher(endpoint, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
query: CONTRIBUTIONS_QUERY,
|
|
49
|
+
variables: {
|
|
50
|
+
username,
|
|
51
|
+
from: `${from}T00:00:00Z`,
|
|
52
|
+
to: `${to}T23:59:59Z`,
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
const payload = await readJson(response);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`GitHub contributions request failed (${response.status}): ${readError(payload)}`);
|
|
59
|
+
}
|
|
60
|
+
if (payload.errors?.length) {
|
|
61
|
+
throw new Error(`GitHub contributions request failed: ${readError(payload)}`);
|
|
62
|
+
}
|
|
63
|
+
const calendar = payload.data?.user?.contributionsCollection?.contributionCalendar;
|
|
64
|
+
if (!calendar)
|
|
65
|
+
throw new Error(`GitHub user "${username}" was not found.`);
|
|
66
|
+
return {
|
|
67
|
+
username,
|
|
68
|
+
from,
|
|
69
|
+
to,
|
|
70
|
+
total: calendar.totalContributions,
|
|
71
|
+
data: calendar.weeks.flatMap((week) => week.contributionDays.map((day) => ({
|
|
72
|
+
date: day.date,
|
|
73
|
+
count: day.contributionCount,
|
|
74
|
+
...(options.includeGitHubColors && day.color ? { color: day.color } : {}),
|
|
75
|
+
}))),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function isGitHubGraphQLEndpoint(endpoint) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(endpoint);
|
|
81
|
+
return url.protocol === "https:" && url.hostname === "api.github.com" && url.pathname === "/graphql";
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function normalizeDate(value) {
|
|
88
|
+
return value instanceof Date ? new Date(value.getTime()) : parseDate(value);
|
|
89
|
+
}
|
|
90
|
+
function todayUtc() {
|
|
91
|
+
const now = new Date();
|
|
92
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
93
|
+
}
|
|
94
|
+
function addUtcDays(date, days) {
|
|
95
|
+
const next = new Date(date.getTime());
|
|
96
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
async function readJson(response) {
|
|
100
|
+
try {
|
|
101
|
+
return await response.json();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function readError(payload) {
|
|
108
|
+
return payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || "Unknown error";
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML renderer for contribution-graph.
|
|
3
|
+
*
|
|
4
|
+
* Renders a CSS-grid of cells for consumers that want HTML layout semantics
|
|
5
|
+
* instead of SVG while keeping the same date math and customization options.
|
|
6
|
+
*/
|
|
7
|
+
import { type ContributionDay, type ContributionGraphOptions, type ResolvedOptions } from "./contributionGraph.js";
|
|
8
|
+
export interface HtmlGraphHandle {
|
|
9
|
+
update(data: ContributionDay[], options: ResolvedOptions): void;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildHtmlGraph(element: HTMLElement, data: ContributionDay[], options?: ContributionGraphOptions): HtmlGraphHandle;
|
|
13
|
+
export declare function clearHtmlGraph(element: HTMLElement): void;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML renderer for contribution-graph.
|
|
3
|
+
*
|
|
4
|
+
* Renders a CSS-grid of cells for consumers that want HTML layout semantics
|
|
5
|
+
* instead of SVG while keeping the same date math and customization options.
|
|
6
|
+
*/
|
|
7
|
+
import { slotText } from "slot-text";
|
|
8
|
+
import { computeGrid, formatHumanDate, normalizeCssColor, resolveOptions, } from "./contributionGraph.js";
|
|
9
|
+
const ROOT_CLASS = "contribution-graph";
|
|
10
|
+
const COUNTS_CLASS = "contribution-graph--counts";
|
|
11
|
+
export function buildHtmlGraph(element, data, options = {}) {
|
|
12
|
+
const opts = resolveOptions(options);
|
|
13
|
+
const grid = computeGrid(data, opts);
|
|
14
|
+
clearHtmlGraph(element);
|
|
15
|
+
element.classList.add(ROOT_CLASS, COUNTS_CLASS);
|
|
16
|
+
const container = document.createElement("div");
|
|
17
|
+
container.className = "cg-grid";
|
|
18
|
+
applyGridStyles(container, grid, opts);
|
|
19
|
+
const cells = new Map();
|
|
20
|
+
renderLabels(container, grid, opts);
|
|
21
|
+
renderCells(container, grid, opts, cells);
|
|
22
|
+
element.appendChild(container);
|
|
23
|
+
let currentClickHandler = opts.onCellClick;
|
|
24
|
+
const cleanupClickHandler = attachClickHandler(container, () => currentClickHandler);
|
|
25
|
+
let currentGrid = grid;
|
|
26
|
+
let currentOpts = opts;
|
|
27
|
+
return {
|
|
28
|
+
update(data, options) {
|
|
29
|
+
currentClickHandler = options.onCellClick;
|
|
30
|
+
const newGrid = computeGrid(data, options);
|
|
31
|
+
const sameStructure = newGrid.cells.length === currentGrid.cells.length &&
|
|
32
|
+
newGrid.cells.every((c, i) => c.date === currentGrid.cells[i].date);
|
|
33
|
+
if (sameStructure && canUpdateCellsOnly(currentOpts, options)) {
|
|
34
|
+
updateCounts(newGrid, options, cells);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
destroyCells(cells);
|
|
38
|
+
container.replaceChildren();
|
|
39
|
+
applyGridStyles(container, newGrid, options);
|
|
40
|
+
renderLabels(container, newGrid, options);
|
|
41
|
+
renderCells(container, newGrid, options, cells);
|
|
42
|
+
}
|
|
43
|
+
currentGrid = newGrid;
|
|
44
|
+
currentOpts = options;
|
|
45
|
+
},
|
|
46
|
+
destroy() {
|
|
47
|
+
cleanupClickHandler();
|
|
48
|
+
destroyCells(cells);
|
|
49
|
+
clearHtmlGraph(element);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function clearHtmlGraph(element) {
|
|
54
|
+
element.classList.remove(ROOT_CLASS, COUNTS_CLASS);
|
|
55
|
+
element.replaceChildren();
|
|
56
|
+
}
|
|
57
|
+
function applyGridStyles(container, grid, opts) {
|
|
58
|
+
container.style.setProperty("--cg-cell", `${opts.cellSize}px`);
|
|
59
|
+
container.style.setProperty("--cg-gap", `${opts.cellGap}px`);
|
|
60
|
+
container.style.setProperty("--cg-radius", `${opts.cellRadius}px`);
|
|
61
|
+
container.style.gridTemplateColumns = `${opts.showWeekdayLabels ? "auto " : ""}repeat(${grid.totalCols}, ${opts.cellSize}px)`;
|
|
62
|
+
container.style.gridTemplateRows = `${opts.showMonthLabels ? "auto " : ""}repeat(7, ${opts.cellSize}px)`;
|
|
63
|
+
}
|
|
64
|
+
function renderLabels(container, grid, opts) {
|
|
65
|
+
const monthRow = 1;
|
|
66
|
+
const weekdayCol = 1;
|
|
67
|
+
const colOffset = opts.showWeekdayLabels ? 2 : 1;
|
|
68
|
+
const rowOffset = opts.showMonthLabels ? 2 : 1;
|
|
69
|
+
if (opts.showMonthLabels) {
|
|
70
|
+
for (const m of grid.months) {
|
|
71
|
+
if (!m.label)
|
|
72
|
+
continue;
|
|
73
|
+
const label = document.createElement("span");
|
|
74
|
+
label.className = "cg-month-label";
|
|
75
|
+
label.textContent = m.label;
|
|
76
|
+
label.style.gridColumn = `${m.col + colOffset} / span 3`;
|
|
77
|
+
label.style.gridRow = String(monthRow);
|
|
78
|
+
label.style.color = opts.labelColor;
|
|
79
|
+
label.style.fontSize = `${opts.labelFontSize}px`;
|
|
80
|
+
container.appendChild(label);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (opts.showWeekdayLabels) {
|
|
84
|
+
for (let d = 0; d < 7; d++) {
|
|
85
|
+
const text = opts.weekdayLabels[d];
|
|
86
|
+
if (!text)
|
|
87
|
+
continue;
|
|
88
|
+
const label = document.createElement("span");
|
|
89
|
+
label.className = "cg-weekday-label";
|
|
90
|
+
label.textContent = text;
|
|
91
|
+
label.style.gridColumn = String(weekdayCol);
|
|
92
|
+
label.style.gridRow = `${d + rowOffset}`;
|
|
93
|
+
label.style.color = opts.labelColor;
|
|
94
|
+
label.style.fontSize = `${opts.labelFontSize}px`;
|
|
95
|
+
container.appendChild(label);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function renderCells(container, grid, opts, cells) {
|
|
100
|
+
const colOffset = opts.showWeekdayLabels ? 2 : 1;
|
|
101
|
+
const rowOffset = opts.showMonthLabels ? 2 : 1;
|
|
102
|
+
for (const cell of grid.cells) {
|
|
103
|
+
const cellEl = document.createElement("div");
|
|
104
|
+
cellEl.className = "cg-cell";
|
|
105
|
+
cellEl.dataset.date = cell.date;
|
|
106
|
+
const count = cell.count;
|
|
107
|
+
const color = cellColor(cell, opts);
|
|
108
|
+
cellEl.dataset.count = String(count);
|
|
109
|
+
cellEl.dataset.level = String(cell.level);
|
|
110
|
+
cellEl.style.gridColumn = `${cell.week + colOffset}`;
|
|
111
|
+
cellEl.style.gridRow = `${cell.day + rowOffset}`;
|
|
112
|
+
cellEl.style.background = color;
|
|
113
|
+
const entry = { cellEl };
|
|
114
|
+
if (opts.countFontSize > 0) {
|
|
115
|
+
const countEl = document.createElement("span");
|
|
116
|
+
countEl.className = "cg-count";
|
|
117
|
+
countEl.style.fontSize = `${opts.countFontSize}px`;
|
|
118
|
+
countEl.style.color = countColor(color, cell.level, opts);
|
|
119
|
+
cellEl.appendChild(countEl);
|
|
120
|
+
entry.countEl = countEl;
|
|
121
|
+
entry.countController = slotText(countEl, String(count), {
|
|
122
|
+
direction: "up",
|
|
123
|
+
duration: 220,
|
|
124
|
+
stagger: 12,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
cells.set(cell.date, entry);
|
|
128
|
+
container.appendChild(cellEl);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function updateCounts(grid, opts, cells) {
|
|
132
|
+
for (const cell of grid.cells) {
|
|
133
|
+
const entry = cells.get(cell.date);
|
|
134
|
+
if (!entry)
|
|
135
|
+
continue;
|
|
136
|
+
const count = cell.count;
|
|
137
|
+
const color = cellColor(cell, opts);
|
|
138
|
+
entry.cellEl.dataset.count = String(count);
|
|
139
|
+
entry.cellEl.dataset.level = String(cell.level);
|
|
140
|
+
entry.cellEl.style.background = color;
|
|
141
|
+
if (entry.countEl)
|
|
142
|
+
entry.countEl.style.color = countColor(color, cell.level, opts);
|
|
143
|
+
entry.countController?.set(String(count), { skipUnchanged: true, direction: "up" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function destroyCells(cells) {
|
|
147
|
+
for (const entry of cells.values()) {
|
|
148
|
+
entry.countController?.destroy();
|
|
149
|
+
}
|
|
150
|
+
cells.clear();
|
|
151
|
+
}
|
|
152
|
+
function cellColor(cell, opts) {
|
|
153
|
+
const paletteFallback = normalizeCssColor(opts.colors[cell.level] ?? opts.colors[0], "#ebedf0");
|
|
154
|
+
return normalizeCssColor(cell.color, paletteFallback);
|
|
155
|
+
}
|
|
156
|
+
function countColor(color, level, opts) {
|
|
157
|
+
return normalizeCssColor(opts.countColors?.[level], readableTextColor(color));
|
|
158
|
+
}
|
|
159
|
+
function readableTextColor(color) {
|
|
160
|
+
const hex = color.trim().replace(/^#/, "");
|
|
161
|
+
if (!/^[0-9a-f]{6}$/i.test(hex))
|
|
162
|
+
return "#171717";
|
|
163
|
+
const r = Number.parseInt(hex.slice(0, 2), 16);
|
|
164
|
+
const g = Number.parseInt(hex.slice(2, 4), 16);
|
|
165
|
+
const b = Number.parseInt(hex.slice(4, 6), 16);
|
|
166
|
+
return (r * 0.299 + g * 0.587 + b * 0.114) > 150 ? "#171717" : "#ffffff";
|
|
167
|
+
}
|
|
168
|
+
function canUpdateCellsOnly(prev, next) {
|
|
169
|
+
return prev.cellSize === next.cellSize &&
|
|
170
|
+
prev.cellGap === next.cellGap &&
|
|
171
|
+
prev.cellRadius === next.cellRadius &&
|
|
172
|
+
prev.showMonthLabels === next.showMonthLabels &&
|
|
173
|
+
prev.showWeekdayLabels === next.showWeekdayLabels &&
|
|
174
|
+
prev.labelColor === next.labelColor &&
|
|
175
|
+
prev.labelFontSize === next.labelFontSize &&
|
|
176
|
+
prev.countFontSize === next.countFontSize &&
|
|
177
|
+
(prev.countColors ?? []).join("\u0000") === (next.countColors ?? []).join("\u0000") &&
|
|
178
|
+
prev.monthLabels.join("\u0000") === next.monthLabels.join("\u0000") &&
|
|
179
|
+
prev.weekdayLabels.join("\u0000") === next.weekdayLabels.join("\u0000");
|
|
180
|
+
}
|
|
181
|
+
function attachClickHandler(container, getHandler) {
|
|
182
|
+
const listener = (event) => {
|
|
183
|
+
const handler = getHandler();
|
|
184
|
+
if (!handler)
|
|
185
|
+
return;
|
|
186
|
+
const target = event.target;
|
|
187
|
+
const cellEl = target?.closest?.(".cg-cell");
|
|
188
|
+
if (!cellEl)
|
|
189
|
+
return;
|
|
190
|
+
const date = cellEl.dataset.date;
|
|
191
|
+
if (!date)
|
|
192
|
+
return;
|
|
193
|
+
const count = Number(cellEl.dataset.count ?? 0);
|
|
194
|
+
const level = Number(cellEl.dataset.level ?? 0);
|
|
195
|
+
handler({ date, count, level, dateLabel: formatHumanDate(date) });
|
|
196
|
+
};
|
|
197
|
+
container.addEventListener("click", listener);
|
|
198
|
+
return () => container.removeEventListener("click", listener);
|
|
199
|
+
}
|