PageSourceSearch

https://wcowin.work/javascripts/github-heatmap.js

js wcowin.work collected 2026-09-28 05:46:14 UTC 12,702 bytes, 368 lines download raw bytes

1(function () {
2  const CELL = 11;
3  const GAP = 2;
4  const ROWS = 7;
5  const LEVEL_OPACITY = [0.05, 0.25, 0.5, 0.75, 1.0];
6  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
7  const DAYS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
8
9  // 缓存配置
10  const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时
11  const CACHE_KEY_PREFIX = 'github-heatmap-';
12
13  function formatDate(dateStr) {
14    const d = new Date(dateStr);
15    return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
16  }
17
18  function buildHeatmapInnerHTML(username) {
19    return `
20      <div class="github-heatmap-glass-container">
21        <div class="github-heatmap-glass-card">
22          <div class="github-heatmap-header">
23            <span class="github-heatmap-title">GitHub</span>
24            <span class="github-heatmap-stats">
25              ${username}过去一年 <strong class="github-heatmap-count">--</strong> 次贡献
26            </span>
27            <div class="github-heatmap-legend">
28              <span class="legend-label">少</span>
29              <div class="legend-cell" style="--opacity: 0.05;"></div>
30              <div class="legend-cell" style="--opacity: 0.25;"></div>
31              <div class="legend-cell" style="--opacity: 0.5;"></div>
32              <div class="legend-cell" style="--opacity: 0.75;"></div>
33              <div class="legend-cell" style="--opacity: 1;"></div>
34              <span class="legend-label">多</span>
35            </div>
36          </div>
37          <div class="github-heatmap-content">
38            <svg class="github-heatmap-svg" preserveAspectRatio="xMidYMid meet"></svg>
39          </div>
40        </div>
41        <div class="github-heatmap-tooltip">
42          <div class="github-heatmap-tooltip__arrow"></div>
43          <div class="github-heatmap-tooltip__content"></div>
44        </div>
45      </div>
46    `;
47  }
48
49  // 从缓存获取数据
50  function getCachedData(username) {
51    try {
52      const cacheKey = CACHE_KEY_PREFIX + username;
53      const cached = localStorage.getItem(cacheKey);
54      const cacheTime = localStorage.getItem(cacheKey + '-time');
55
56      if (cached && cacheTime) {
57        const age = Date.now() - parseInt(cacheTime, 10);
58        if (age < CACHE_DURATION) {
59          return JSON.parse(cached);
60        }
61      }
62    } catch (e) {
63      // localStorage 不可用(隐私模式等),静默失败
64    }
65    return null;
66  }
67
68  // 保存数据到缓存
69  function setCachedData(username, data) {
70    try {
71      const cacheKey = CACHE_KEY_PREFIX + username;
72      localStorage.setItem(cacheKey, JSON.stringify(data));
73      localStorage.setItem(cacheKey + '-time', Date.now().toString());
74    } catch (e) {
75      // localStorage 不可用或已满,静默失败
76    }
77  }
78
79  // 清除缓存数据
80  function clearCachedData(username) {
81    try {
82      const cacheKey = CACHE_KEY_PREFIX + username;
83      localStorage.removeItem(cacheKey);
84      localStorage.removeItem(cacheKey + '-time');
85    } catch (e) {
86      // localStorage 不可用,静默失败
87    }
88  }
89
90  // 验证 API 响应格式
91  function isValidHeatmapData(data) {
92    return data &&
93           typeof data === 'object' &&
94           data.total && typeof data.total.lastYear === 'number' &&
95           Array.isArray(data.contributions) &&
96           data.contributions.every(day => 
97             day && typeof day.date === 'string' && 
98             typeof day.count === 'number' && 
99             typeof day.level === 'number'
100           );
101  }
102
103  // 从 API 获取数据
104  async function fetchHeatmapData(username) {
105    const response = await fetch(`https://github-contributions-api.jogruber.de/v4/${username}?y=last`);
106    if (!response.ok) {
107      throw new Error('GitHub contributions API error: ' + response.status);
108    }
109    const data = await response.json();
110    
111    // 验证响应格式
112    if (!isValidHeatmapData(data)) {
113      throw new Error('Invalid API response format');
114    }
115    
116    return data;
117  }
118
119  async function loadHeatmap(container) {
120    // 避免重复初始化
121    if (container.dataset.githubHeatmapInitialized === 'true') return;
122    container.dataset.githubHeatmapInitialized = 'true';
123
124    const username = container.getAttribute('data-username') || 'Wcowin';
125    container.innerHTML = buildHeatmapInnerHTML(username);
126
127    const svg = container.querySelector('.github-heatmap-svg');
128    const statsEl = container.querySelector('.github-heatmap-count');
129    const tooltipEl = container.querySelector('.github-heatmap-tooltip');
130
131    if (!svg || !statsEl || !tooltipEl) return;
132
133    // 尝试获取缓存数据
134    const cachedData = getCachedData(username);
135
136    if (cachedData && isValidHeatmapData(cachedData)) {
137      // 有缓存:先显示缓存数据
138      renderHeatmap(container, cachedData, username);
139
140      // 后台静默刷新(不阻塞,失败时清理缓存)
141      fetchHeatmapData(username)
142        .then(data => {
143          setCachedData(username, data);
144          renderHeatmap(container, data, username);
145        })
146        .catch(() => {
147          // 获取失败,清除过期缓存,下次会重新尝试
148          clearCachedData(username);
149        });
150    } else {
151      // 无缓存或缓存无效:从 API 获取
152      try {
153        const data = await fetchHeatmapData(username);
154        setCachedData(username, data);
155        renderHeatmap(container, data, username);
156      } catch (error) {
157        console.error('Error loading GitHub contributions:', error);
158        statsEl.textContent = '加载失败';
159        // 清除可能存在的无效缓存
160        clearCachedData(username);
161      }
162    }
163  }
164
165  function renderHeatmap(container, data, username) {
166    const svg = container.querySelector('.github-heatmap-svg');
167    const statsEl = container.querySelector('.github-heatmap-count');
168    const tooltipEl = container.querySelector('.github-heatmap-tooltip');
169
170    if (!svg || !statsEl || !tooltipEl) return;
171
172    // 安全获取贡献数
173    const totalContributions = data.total && typeof data.total.lastYear === 'number' 
174      ? data.total.lastYear 
175      : 0;
176    statsEl.textContent = totalContributions.toString();
177
178    // 安全获取贡献数据
179    const contributions = Array.isArray(data.contributions) ? data.contributions : [];
180    
181    const weeks = Math.ceil(contributions.length / ROWS);
182    const labelOffset = 28;
183    const headerOffset = 16;
184    const svgWidth = labelOffset + weeks * (CELL + GAP);
185    const svgHeight = headerOffset + ROWS * (CELL + GAP);
186
187    svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
188    svg.innerHTML = '';
189
190    const monthLabels = [];
191    let lastMonth = -1;
192    for (let w = 0; w < weeks; w++) {
193      const idx = w * ROWS;
194      if (idx < contributions.length) {
195        const date = new Date(contributions[idx].date);
196        if (!isNaN(date.getTime())) {
197          const month = date.getMonth();
198          if (month !== lastMonth) {
199            monthLabels.push({ label: MONTHS[month], x: labelOffset + w * (CELL + GAP) });
200            lastMonth = month;
201          }
202        }
203      }
204    }
205
206    const ns = 'http://www.w3.org/2000/svg';
207
208    monthLabels.forEach((m) => {
209      const text = document.createElementNS(ns, 'text');
210      text.setAttribute('x', m.x);
211      text.setAttribute('y', 11);
212      text.setAttribute('class', 'heatmap-label');
213      text.setAttribute('font-size', 10);
214      text.textContent = m.label;
215      svg.appendChild(text);
216    });
217
218    DAYS.forEach((d, i) => {
219      if (d) {
220        const text = document.createElementNS(ns, 'text');
221        text.setAttribute('x', 0);
222        text.setAttribute('y', headerOffset + i * (CELL + GAP) + CELL - 1);
223        text.setAttribute('class', 'heatmap-label');
224        text.setAttribute('font-size', 9);
225        text.textContent = d;
226        svg.appendChild(text);
227      }
228    });
229
230    contributions.forEach((day, idx) => {
231      // 验证日期数据
232      if (!day || typeof day.date !== 'string') return;
233      
234      const col = Math.floor(idx / ROWS);
235      const row = idx % ROWS;
236      const rect = document.createElementNS(ns, 'rect');
237
238      rect.setAttribute('x', labelOffset + col * (CELL + GAP));
239      rect.setAttribute('y', headerOffset + row * (CELL + GAP));
240      rect.setAttribute('width', CELL);
241      rect.setAttribute('height', CELL);
242      rect.setAttribute('rx', 2);
243      rect.setAttribute('ry', 2);
244      rect.setAttribute('class', 'heatmap-cell');
245      rect.setAttribute('data-date', day.date);
246      rect.setAttribute('data-count', day.count || 0);
247
248      // 安全的 level 值
249      const level = typeof day.level === 'number' ? day.level : 0;
250      const opacity = LEVEL_OPACITY[Math.min(level, 4)];
251      rect.setAttribute('fill', `rgba(35, 154, 59, ${opacity})`);
252
253      rect.addEventListener('mouseenter', (e) => {
254        const date = rect.getAttribute('data-date');
255        const count = parseInt(rect.getAttribute('data-count') || '0', 10);
256        tooltip(date, count, e);
257      });
258
259      rect.addEventListener('mousemove', (e) => {
260        const date = rect.getAttribute('data-date');
261        const count = parseInt(rect.getAttribute('data-count') || '0', 10);
262        tooltip(date, count, e);
263      });
264
265      rect.addEventListener('mouseleave', () => {
266        tooltipEl.classList.remove('visible');
267      });
268
269      rect.addEventListener('click', () => {
270        const date = day.date;
271        const url = `https://github.com/${username}?tab=overview&from=${date}&to=${date}`;
272        window.open(url, '_blank', 'noopener,noreferrer');
273      });
274
275      svg.appendChild(rect);
276    });
277
278    if (!document.querySelector('style[data-heatmap-style]')) {
279      const style = document.createElement('style');
280      style.setAttribute('data-heatmap-style', '');
281      style.textContent = `
282        .heatmap-label {
283          fill: rgba(0, 0, 0, 0.4);
284          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
285          font-size: 10px;
286        }
287        [data-md-color-scheme="slate"] .heatmap-label {
288          fill: rgba(255, 255, 255, 0.7);
289        }
290        .heatmap-cell {
291          transition: opacity 150ms, filter 150ms;
292          cursor: default;
293          filter: drop-shadow(0 0 0 rgba(35, 154, 59, 0));
294        }
295        .heatmap-cell:hover {
296          opacity: 1 !important;
297          filter: drop-shadow(0 2px 6px rgba(35, 154, 59, 0.4));
298        }
299      `;
300      document.head.appendChild(style);
301    }
302
303    function tooltip(date, count, event) {
304      const dateObj = new Date(date);
305      const dateText = !isNaN(dateObj.getTime()) 
306        ? formatDate(date) 
307        : date;
308      const text = count > 0 ? `${dateText}: ${count} 次贡献` : `${dateText}: 无贡献`;
309      const contentEl = tooltipEl.querySelector('.github-heatmap-tooltip__content');
310      if (contentEl) {
311        contentEl.textContent = text;
312      }
313      tooltipEl.classList.add('visible');
314
315      const target = event.currentTarget;
316      const margin = 8;
317      const gap = 8;
318
319      if (target && target.getBoundingClientRect) {
320        const cellRect = target.getBoundingClientRect();
321        
322        // 先显示 tooltip 才能获取正确尺寸
323        tooltipEl.style.visibility = 'hidden';
324        tooltipEl.style.display = 'block';
325        const tooltipRect = tooltipEl.getBoundingClientRect();
326        tooltipEl.style.visibility = '';
327        tooltipEl.style.display = '';
328
329        let x = cellRect.left + cellRect.width / 2 - tooltipRect.width / 2;
330        let y = cellRect.top - tooltipRect.height - gap;
331        let arrowPosition = cellRect.left + cellRect.width / 2;
332        let isAbove = true;
333
334        const maxX = window.innerWidth - tooltipRect.width - margin;
335        x = Math.max(margin, Math.min(x, maxX));
336
337        if (y < margin) {
338          y = cellRect.bottom + gap;
339          isAbove = false;
340        }
341
342        tooltipEl.style.left = x + 'px';
343        tooltipEl.style.top = y + 'px';
344
345        tooltipEl.classList.remove('arrow-top', 'arrow-bottom');
346        tooltipEl.classList.add(isAbove ? 'arrow-bottom' : 'arrow-top');
347
348        const arrowEl = tooltipEl.querySelector('.github-heatmap-tooltip__arrow');
349        if (arrowEl) {
350          const arrowOffset = arrowPosition - x;
351          arrowEl.style.left = arrowOffset + 'px';
352        }
353      }
354    }
355  }
356
357  async function initHeatmaps() {
358    const containers = document.querySelectorAll('.github-heatmap');
359    if (!containers.length) return;
360    await Promise.all(Array.from(containers).map((c) => loadHeatmap(c)));
361  }
362
363  if (document.readyState === 'loading') {
364    document.addEventListener('DOMContentLoaded', initHeatmaps);
365  } else {
366    initHeatmaps();
367  }
368})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.