Spaces:
Running
Running
Download index.html from prithivMLmods/like-graph: direct link, hf CLI and curl.
- Browser
- Download file 112 kB
-
https://huggingface.co/spaces/prithivMLmods/like-graph/resolve/main/index.html
- Command line
-
hf download hf://spaces/prithivMLmods/like-graph/index.html
-
curl -L -o index.html https://huggingface.co/spaces/prithivMLmods/like-graph/resolve/main/index.html
112 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>like-graph</title> | |
| <meta name="description" content="Generate daily and cumulative like graphs since publication for any Hugging Face Model, Dataset, Space, Organization, or User."> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.28/jspdf.plugin.autotable.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap'); | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace; | |
| background: #000000; | |
| color: #ffffff; | |
| -webkit-font-smoothing: antialiased; | |
| min-height: 100vh; | |
| } | |
| button, input, select { font-family: inherit; } | |
| a { text-decoration: none; color: inherit; } | |
| ::selection { background: #ffffff; color: #000000; } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-thumb { background: #333333; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #555555; } | |
| ::-webkit-scrollbar-track { background: #000000; } | |
| .terminal-shell { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 0; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* ββ Title Bar ββ */ | |
| .title-bar { | |
| display: flex; | |
| align-items: center; | |
| padding: 14px 20px; | |
| border-bottom: 1px solid #222222; | |
| gap: 12px; | |
| user-select: none; | |
| position: sticky; | |
| top: 0; | |
| background: #000000; | |
| z-index: 100; | |
| flex-wrap: wrap; | |
| } | |
| .title-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background: #ffffff; | |
| flex-shrink: 0; | |
| box-shadow: 0 0 6px rgba(255,255,255,0.4); | |
| } | |
| .title-text { | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| color: #ffffff; | |
| letter-spacing: 0.02em; | |
| cursor: pointer; | |
| } | |
| .title-text:hover { text-decoration: underline; } | |
| .title-sub { | |
| font-size: 0.72rem; | |
| color: #555555; | |
| font-weight: 400; | |
| } | |
| .title-actions { | |
| margin-left: auto; | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .title-btn { | |
| background: transparent; | |
| border: 1px solid #333333; | |
| color: #888888; | |
| padding: 5px 12px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.72rem; | |
| font-weight: 500; | |
| transition: all 0.15s; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .title-btn:hover { border-color: #ffffff; color: #ffffff; } | |
| .title-btn.active-token { color: #ffffff; border-color: #555555; } | |
| .title-btn:disabled { opacity: 0.4; cursor: wait; } | |
| /* ββ Token Section ββ */ | |
| .token-section { | |
| border-bottom: 1px solid #222222; | |
| padding: 14px 20px; | |
| animation: termSlide 0.2s ease-out; | |
| } | |
| @keyframes termSlide { | |
| from { opacity: 0; max-height: 0; } | |
| to { opacity: 1; max-height: 200px; } | |
| } | |
| .token-row { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .token-prompt { | |
| color: #888888; | |
| font-size: 0.78rem; | |
| flex-shrink: 0; | |
| } | |
| .token-row input { | |
| background: #111111; | |
| border: 1px solid #333333; | |
| padding: 7px 12px; | |
| border-radius: 3px; | |
| flex: 1; | |
| color: #ffffff; | |
| font-size: 0.78rem; | |
| } | |
| .token-row input:focus { | |
| outline: none; | |
| border-color: #ffffff; | |
| } | |
| .token-row button { | |
| background: #ffffff; | |
| color: #000000; | |
| border: none; | |
| padding: 7px 16px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.78rem; | |
| transition: opacity 0.15s; | |
| } | |
| .token-row button:hover { opacity: 0.85; } | |
| .token-note { | |
| font-size: 0.68rem; | |
| color: #555555; | |
| margin-top: 8px; | |
| } | |
| /* ββ Main Content ββ */ | |
| .term-body { | |
| padding: 20px; | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* ββ Input Line ββ */ | |
| .input-line { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .prompt-group { | |
| display: flex; | |
| align-items: center; | |
| background: #0a0a0a; | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| padding: 0 14px; | |
| height: 40px; | |
| flex: 1; | |
| min-width: 240px; | |
| transition: border-color 0.15s; | |
| } | |
| .prompt-group:focus-within { | |
| border-color: #ffffff; | |
| } | |
| .prompt-symbol { | |
| color: #555555; | |
| font-size: 0.82rem; | |
| margin-right: 8px; | |
| white-space: nowrap; | |
| font-weight: 500; | |
| } | |
| .prompt-group input { | |
| background: transparent; | |
| border: none; | |
| color: #ffffff; | |
| width: 100%; | |
| outline: none; | |
| font-size: 0.82rem; | |
| font-weight: 400; | |
| } | |
| .prompt-group input::placeholder { color: #444444; } | |
| .type-select { | |
| background: #0a0a0a; | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| color: #888888; | |
| padding: 0 14px; | |
| height: 40px; | |
| font-size: 0.78rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| outline: none; | |
| transition: all 0.15s; | |
| } | |
| .type-select:hover, .type-select:focus { | |
| border-color: #ffffff; | |
| color: #ffffff; | |
| } | |
| .type-select option { | |
| background: #0a0a0a; | |
| color: #ffffff; | |
| } | |
| .exec-btn { | |
| background: #ffffff; | |
| color: #000000; | |
| border: none; | |
| padding: 0 24px; | |
| border-radius: 3px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| height: 40px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 0.82rem; | |
| transition: opacity 0.15s; | |
| white-space: nowrap; | |
| } | |
| .exec-btn:hover { opacity: 0.85; } | |
| .exec-btn:disabled { opacity: 0.4; cursor: wait; } | |
| /* ββ Status ββ */ | |
| #statusMsg { | |
| padding: 10px 14px; | |
| margin-bottom: 14px; | |
| font-size: 0.78rem; | |
| border-radius: 3px; | |
| display: none; | |
| border: 1px solid #222222; | |
| } | |
| .error { color: #ffffff; background: #1a0000; border-color: #440000 ; } | |
| .loading { color: #ffffff; background: #0a0a0a; border-color: #333333 ; } | |
| /* ββ Empty / Home State ββ */ | |
| .empty-state { | |
| text-align: center; | |
| padding-top: 40px; | |
| } | |
| .empty-title { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| color: #ffffff; | |
| margin-bottom: 8px; | |
| } | |
| .empty-desc { | |
| color: #666666; | |
| font-size: 0.8rem; | |
| margin-bottom: 32px; | |
| line-height: 1.6; | |
| max-width: 720px; | |
| margin-left: auto; | |
| margin-right: auto; | |
| } | |
| .section-label { | |
| font-size: 0.7rem; | |
| color: #555555; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| font-weight: 600; | |
| margin-bottom: 14px; | |
| margin-top: 24px; | |
| } | |
| .org-cloud { | |
| display: flex; | |
| justify-content: center; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| .user-chip { | |
| background: transparent; | |
| border: 1px solid #222222; | |
| color: #888888; | |
| padding: 6px 14px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| transition: all 0.15s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .user-chip:hover { | |
| border-color: #ffffff; | |
| color: #ffffff; | |
| } | |
| .chip-tag { | |
| background: #111111; | |
| border: 1px solid #333333; | |
| color: #aaaaaa; | |
| padding: 1px 6px; | |
| border-radius: 2px; | |
| font-size: 0.62rem; | |
| text-transform: uppercase; | |
| } | |
| /* ββ Profile / Target Header Block ββ */ | |
| .profile-block { | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| padding: 20px; | |
| margin-bottom: 16px; | |
| animation: termSlide 0.25s ease-out; | |
| } | |
| .profile-top { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .profile-avatar { | |
| width: 56px; | |
| height: 56px; | |
| border-radius: 50%; | |
| border: 2px solid #333333; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| flex-shrink: 0; | |
| background: #111111; | |
| } | |
| .profile-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } | |
| .profile-avatar .avatar-letter { | |
| font-size: 1.4rem; | |
| font-weight: 700; | |
| color: #666666; | |
| } | |
| .profile-info { flex: 1; min-width: 0; } | |
| .profile-name { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| color: #ffffff; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .profile-name a { color: #ffffff; word-break: break-all; } | |
| .profile-name a:hover { text-decoration: underline; } | |
| .type-badge { | |
| font-size: 0.65rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| padding: 2px 8px; | |
| border: 1px solid #333333; | |
| border-radius: 2px; | |
| color: #aaaaaa; | |
| background: #111111; | |
| letter-spacing: 0.5px; | |
| } | |
| .profile-fullname { | |
| font-size: 0.78rem; | |
| color: #666666; | |
| margin-top: 4px; | |
| } | |
| .profile-fullname a { | |
| color: #aaaaaa; | |
| text-decoration: underline; | |
| } | |
| .profile-fullname a:hover { color: #ffffff; } | |
| .profile-social { | |
| display: flex; | |
| gap: 16px; | |
| margin-top: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .social-stat { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 0.75rem; | |
| color: #666666; | |
| font-weight: 500; | |
| } | |
| .social-stat i { font-size: 0.72rem; } | |
| .social-stat .social-num { | |
| font-weight: 700; | |
| color: #ffffff; | |
| } | |
| /* ββ Daily Activity Pulse Matrix ββ */ | |
| .pulse-section { | |
| margin-top: 20px; | |
| padding-top: 20px; | |
| border-top: 1px solid #1a1a1a; | |
| } | |
| .pulse-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .pulse-title { | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| color: #888888; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .contrib-map-wrapper { | |
| overflow-x: auto; | |
| padding-bottom: 4px; | |
| } | |
| .contrib-map { | |
| display: grid; | |
| grid-template-rows: repeat(7, 14px); | |
| grid-auto-flow: column; | |
| grid-auto-columns: 14px; | |
| gap: 3px; | |
| min-width: fit-content; | |
| } | |
| .contrib-cell { | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 2px; | |
| background: #111111; | |
| transition: transform 0.15s; | |
| } | |
| .contrib-cell:hover { | |
| transform: scale(1.4); | |
| z-index: 2; | |
| position: relative; | |
| } | |
| .contrib-cell[data-level="0"] { background: #111111; } | |
| .contrib-cell[data-level="1"] { background: #333333; } | |
| .contrib-cell[data-level="2"] { background: #555555; } | |
| .contrib-cell[data-level="3"] { background: #999999; } | |
| .contrib-cell[data-level="4"] { background: #ffffff; } | |
| .contrib-legend { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| justify-content: flex-end; | |
| margin-top: 8px; | |
| font-size: 0.65rem; | |
| color: #555555; | |
| } | |
| .contrib-legend .contrib-cell { | |
| width: 12px; | |
| height: 12px; | |
| cursor: default; | |
| } | |
| .contrib-legend .contrib-cell:hover { transform: none; } | |
| /* ββ Stats Row ββ */ | |
| .stats-row { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 16px; | |
| } | |
| .stat-card { | |
| background: #0a0a0a; | |
| border: 1px solid #1a1a1a; | |
| border-radius: 3px; | |
| padding: 14px 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .stat-icon { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.85rem; | |
| flex-shrink: 0; | |
| background: #111111; | |
| border: 1px solid #222222; | |
| color: #ffffff; | |
| } | |
| .stat-value { | |
| font-size: 1.05rem; | |
| font-weight: 700; | |
| color: #ffffff; | |
| } | |
| .stat-label { | |
| font-size: 0.62rem; | |
| color: #555555; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-top: 2px; | |
| } | |
| .stat-sub { | |
| font-size: 0.65rem; | |
| color: #888888; | |
| margin-top: 3px; | |
| } | |
| /* ββ Graph Section ββ */ | |
| .graph-block { | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| background: #000000; | |
| margin-bottom: 16px; | |
| overflow: hidden; | |
| } | |
| .graph-header { | |
| background: #0a0a0a; | |
| padding: 10px 16px; | |
| border-bottom: 1px solid #222222; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .graph-title-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .graph-title { | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| color: #ffffff; | |
| } | |
| .graph-subtitle { | |
| font-size: 0.72rem; | |
| color: #555555; | |
| font-weight: 500; | |
| } | |
| .graph-controls { | |
| display: flex; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .ctrl-divider { | |
| width: 1px; | |
| height: 16px; | |
| background: #222222; | |
| margin: 0 4px; | |
| } | |
| .graph-pill { | |
| background: transparent; | |
| border: 1px solid #222222; | |
| color: #666666; | |
| padding: 4px 10px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| transition: all 0.15s; | |
| white-space: nowrap; | |
| } | |
| .graph-pill:hover { border-color: #555555; color: #aaaaaa; } | |
| .graph-pill.active { | |
| background: #ffffff; | |
| color: #000000; | |
| border-color: #ffffff; | |
| } | |
| .graph-canvas-wrap { | |
| position: relative; | |
| padding: 14px 14px 10px 14px; | |
| background: #000000; | |
| height: 360px; | |
| } | |
| #likeCanvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| cursor: crosshair; | |
| } | |
| .graph-legend { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 16px; | |
| border-top: 1px solid #1a1a1a; | |
| background: #0a0a0a; | |
| font-size: 0.68rem; | |
| font-weight: 500; | |
| color: #666666; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .legend-items { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| color: #888888; | |
| } | |
| .legend-swatch { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 2px; | |
| border: 1px solid #444444; | |
| display: inline-block; | |
| } | |
| .legend-line { | |
| width: 16px; | |
| height: 2px; | |
| background: #ffffff; | |
| display: inline-block; | |
| } | |
| /* ββ Floating Graph Tooltip ββ */ | |
| .graph-tooltip { | |
| position: fixed; | |
| background: #111111; | |
| border: 1px solid #333333; | |
| border-radius: 3px; | |
| padding: 8px 12px; | |
| font-size: 0.68rem; | |
| pointer-events: none; | |
| z-index: 200; | |
| display: none; | |
| color: #ffffff; | |
| line-height: 1.6; | |
| min-width: 185px; | |
| } | |
| .tt-date { | |
| font-weight: 700; | |
| color: #ffffff; | |
| border-bottom: 1px solid #222222; | |
| padding-bottom: 4px; | |
| margin-bottom: 4px; | |
| } | |
| .tt-row { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 14px; | |
| color: #888888; | |
| } | |
| .tt-row span:last-child { | |
| color: #ffffff; | |
| font-weight: 700; | |
| } | |
| /* ββ Tab Bar ββ */ | |
| .tab-bar { | |
| display: flex; | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| overflow: hidden; | |
| margin-bottom: 14px; | |
| } | |
| .tab-btn { | |
| flex: 1; | |
| background: #0a0a0a; | |
| border: none; | |
| border-right: 1px solid #222222; | |
| padding: 10px 14px; | |
| cursor: pointer; | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| color: #555555; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| transition: all 0.15s; | |
| } | |
| .tab-btn:last-child { border-right: none; } | |
| .tab-btn:hover { background: #111111; color: #aaaaaa; } | |
| .tab-btn.active-tab { | |
| background: #1a1a1a; | |
| color: #ffffff; | |
| } | |
| .tab-count { | |
| background: #222222; | |
| padding: 2px 7px; | |
| border-radius: 2px; | |
| font-size: 0.68rem; | |
| font-weight: 700; | |
| } | |
| .tab-btn.active-tab .tab-count { background: #333333; } | |
| /* ββ Filter Bar ββ */ | |
| .filter-bar { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .filter-label { | |
| font-size: 0.72rem; | |
| color: #555555; | |
| font-weight: 600; | |
| margin-right: 4px; | |
| } | |
| .filter-pill { | |
| background: transparent; | |
| border: 1px solid #222222; | |
| color: #666666; | |
| padding: 5px 12px; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| transition: all 0.15s; | |
| white-space: nowrap; | |
| } | |
| .filter-pill:hover { border-color: #555555; color: #aaaaaa; } | |
| .filter-pill.active { background: #ffffff; color: #000000; border-color: #ffffff; } | |
| .filter-search { | |
| margin-left: auto; | |
| background: #0a0a0a; | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| padding: 5px 12px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: border-color 0.15s; | |
| } | |
| .filter-search:focus-within { border-color: #ffffff; } | |
| .filter-search-icon { color: #555555; font-size: 0.72rem; } | |
| .filter-search input { | |
| font-size: 0.72rem; | |
| width: 170px; | |
| background: transparent; | |
| border: none; | |
| color: #ffffff; | |
| outline: none; | |
| } | |
| .filter-search input::placeholder { color: #444444; } | |
| /* ββ Tree / Log List ββ */ | |
| .tree-wrapper { | |
| border: 1px solid #222222; | |
| border-radius: 3px; | |
| display: flex; | |
| flex-direction: column; | |
| background: #000000; | |
| overflow: hidden; | |
| } | |
| .tree-header { | |
| background: #0a0a0a; | |
| padding: 10px 16px; | |
| border-bottom: 1px solid #222222; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .tree-title { | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| color: #666666; | |
| } | |
| .snap-select { | |
| background: #111111; | |
| color: #cccccc; | |
| border: 1px solid #333333; | |
| border-radius: 3px; | |
| padding: 5px 10px; | |
| font-size: 0.72rem; | |
| font-weight: 500; | |
| cursor: pointer; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| .snap-select:hover, .snap-select:focus { | |
| border-color: #ffffff; | |
| color: #ffffff; | |
| } | |
| .copy-btn { | |
| background: #ffffff; | |
| color: #000000; | |
| border: none; | |
| padding: 6px 14px; | |
| border-radius: 3px; | |
| font-size: 0.72rem; | |
| font-weight: 700; | |
| cursor: pointer; | |
| transition: opacity 0.15s; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .copy-btn:hover { opacity: 0.85; } | |
| .terminal-window { | |
| background: #000000; | |
| display: flex; | |
| align-items: stretch; | |
| overflow: auto; | |
| font-size: 12.5px; | |
| line-height: 1.7; | |
| max-height: 68vh; | |
| } | |
| .line-col { | |
| padding: 14px 10px; | |
| text-align: right; | |
| color: #333333; | |
| border-right: 1px solid #1a1a1a; | |
| min-width: 48px; | |
| user-select: none; | |
| font-size: 0.7rem; | |
| font-weight: 500; | |
| flex-shrink: 0; | |
| } | |
| .line-col div { | |
| height: 30px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| } | |
| .code-col { | |
| padding: 14px; | |
| flex: 1; | |
| white-space: pre; | |
| min-width: 0; | |
| } | |
| .tree-line { | |
| display: flex; | |
| align-items: center; | |
| height: 30px; | |
| border-radius: 2px; | |
| padding: 0 6px; | |
| gap: 2px; | |
| } | |
| .tree-line:hover { background: #0d0d0d; } | |
| .t-prefix { | |
| color: #444444; | |
| white-space: pre; | |
| margin-right: 4px; | |
| flex-shrink: 0; | |
| } | |
| .t-name { | |
| color: #ffffff; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| min-width: 0; | |
| } | |
| .t-name a { color: #ffffff; transition: color 0.1s; } | |
| .t-name a:hover { color: #cccccc; text-decoration: underline; } | |
| .t-bar { | |
| color: #666666; | |
| font-size: 0.72rem; | |
| margin-left: 12px; | |
| letter-spacing: -1px; | |
| flex-shrink: 0; | |
| } | |
| .t-meta { | |
| margin-left: auto; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| flex-shrink: 0; | |
| padding-left: 12px; | |
| } | |
| .t-stat { | |
| font-size: 0.68rem; | |
| color: #555555; | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| .t-stat.highlight { | |
| color: #ffffff; | |
| background: #141414; | |
| border: 1px solid #2a2a2a; | |
| border-radius: 2px; | |
| padding: 1px 6px; | |
| font-weight: 600; | |
| } | |
| .t-action-btn { | |
| background: transparent; | |
| border: 1px solid #333333; | |
| color: #888888; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| padding: 2px 8px; | |
| font-size: 0.65rem; | |
| font-weight: 600; | |
| transition: all 0.15s; | |
| } | |
| .t-action-btn:hover { | |
| border-color: #ffffff; | |
| color: #ffffff; | |
| } | |
| .no-data { | |
| padding: 40px 20px; | |
| text-align: center; | |
| color: #555555; | |
| font-size: 0.82rem; | |
| white-space: normal; | |
| } | |
| /* ββ Footer ββ */ | |
| .footer { | |
| padding: 30px 20px; | |
| text-align: center; | |
| font-size: 0.68rem; | |
| color: #333333; | |
| margin-top: auto; | |
| border-top: 1px solid #111111; | |
| } | |
| .footer a { color: #888888; font-weight: 600; } | |
| .footer a:hover { color: #ffffff; } | |
| /* ββ Spinner ββ */ | |
| .spinner { | |
| display: inline-block; | |
| width: 12px; | |
| height: 12px; | |
| border: 2px solid #333333; | |
| border-top-color: #ffffff; | |
| border-radius: 50%; | |
| animation: spin 0.6s linear infinite; | |
| margin-right: 8px; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .hidden { display: none ; } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 680px) { | |
| .input-line { flex-direction: column; } | |
| .type-select, .exec-btn { width: 100%; justify-content: center; } | |
| .profile-top { flex-direction: column; text-align: center; } | |
| .profile-name { justify-content: center; } | |
| .profile-social { justify-content: center; } | |
| .stats-row { grid-template-columns: 1fr 1fr; } | |
| .graph-canvas-wrap { height: 280px; padding: 10px 6px; } | |
| .filter-bar { flex-direction: column; align-items: stretch; } | |
| .filter-search { margin-left: 0; } | |
| .filter-search input { width: 100%; } | |
| .t-bar { display: none; } | |
| .title-bar { padding: 10px 14px; } | |
| .term-body { padding: 14px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="terminal-shell"> | |
| <!-- Title Bar --> | |
| <div class="title-bar"> | |
| <div class="title-dot"></div> | |
| <span class="title-text" id="homeLogo">[like-graph]</span> | |
| <span class="title-sub">hf daily & cumulative like telemetry</span> | |
| <div class="title-actions"> | |
| <button id="privateRepoBtn" class="title-btn" title="Configure HF Access Token"> | |
| <i class="fas fa-lock"></i> token | |
| </button> | |
| <button id="csvBtn" class="title-btn" title="Export Daily Likes CSV"> | |
| <i class="fas fa-file-csv"></i> csv | |
| </button> | |
| <button id="pdfBtn" class="title-btn" title="Download PDF Activity Report"> | |
| <i class="fas fa-file-pdf"></i> report | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Token Panel --> | |
| <div id="tokenSection" class="token-section hidden"> | |
| <div class="token-row"> | |
| <span class="token-prompt">token ~$</span> | |
| <input type="password" id="hfToken" placeholder="paste hf access token (hf_...)"> | |
| <button id="saveTokenBtn">save</button> | |
| <button id="clearTokenBtn" class="hidden">clear</button> | |
| </div> | |
| <p class="token-note">token stored in localStorage. unlocks private repos & higher rate limits.</p> | |
| </div> | |
| <!-- Main Body --> | |
| <div class="term-body"> | |
| <!-- Input --> | |
| <div class="input-line"> | |
| <div class="prompt-group" style="flex:3;"> | |
| <span class="prompt-symbol">~$ graph </span> | |
| <input type="text" id="targetInput" placeholder="owner/repo or organization (e.g. prithivMLmods/Qwen-Image-Edit-2511-Unblur-Upscale, autotrust)" autocomplete="off"> | |
| </div> | |
| <select id="typeSelect" class="type-select" title="Target Resource Type"> | |
| <option value="auto">type: auto-detect</option> | |
| <option value="model">type: model</option> | |
| <option value="space">type: space</option> | |
| <option value="dataset">type: dataset</option> | |
| <option value="org">type: org / user</option> | |
| </select> | |
| <button id="fetchBtn" class="exec-btn"> | |
| <i class="fas fa-terminal"></i> execute | |
| </button> | |
| </div> | |
| <div id="statusMsg"></div> | |
| <!-- Empty State --> | |
| <div id="emptyState" class="empty-state"> | |
| <div style="margin-bottom:24px;"> | |
| <div class="empty-title">hugging-face like-graph telemetry</div> | |
| <div class="empty-desc"> | |
| paste any hugging face model, dataset, or space path (or full hf.co url) to plot daily likes polled and cumulative growth since the day it was published. also supports organizations & users with daily breakdowns across models, datasets, and spaces. | |
| </div> | |
| </div> | |
| <div> | |
| <div class="section-label">featured repositories (models, spaces & datasets)</div> | |
| <div id="featuredRepos" class="org-cloud"></div> | |
| </div> | |
| <div> | |
| <div class="section-label">featured organizations & creators</div> | |
| <div id="featuredOrgs" class="org-cloud"></div> | |
| </div> | |
| </div> | |
| <!-- Results --> | |
| <div id="resultsSection" class="hidden"> | |
| <!-- Entity Profile Block --> | |
| <div class="profile-block"> | |
| <div class="profile-top"> | |
| <div class="profile-avatar" id="profileAvatar"> | |
| <span class="avatar-letter" id="avatarLetter"></span> | |
| </div> | |
| <div class="profile-info"> | |
| <div class="profile-name"> | |
| <a id="profileLink" href="#" target="_blank" rel="noopener"> | |
| <span id="profileTitle"></span> | |
| </a> | |
| <span id="entityTypeBadge" class="type-badge">MODEL</span> | |
| </div> | |
| <div class="profile-fullname" id="profileSubtitle"></div> | |
| <div class="profile-social" id="profileMetaRow"></div> | |
| </div> | |
| </div> | |
| <!-- Daily Like Intensity Heatmap Matrix --> | |
| <div class="pulse-section" id="pulseSection"> | |
| <div class="pulse-header"> | |
| <span class="pulse-title">daily like polling matrix (since published)</span> | |
| <span class="graph-subtitle" id="pulseSummary"></span> | |
| </div> | |
| <div class="contrib-map-wrapper"> | |
| <div class="contrib-map" id="pulseMap"></div> | |
| </div> | |
| <div class="contrib-legend">less | |
| <div class="contrib-cell" data-level="0"></div> | |
| <div class="contrib-cell" data-level="1"></div> | |
| <div class="contrib-cell" data-level="2"></div> | |
| <div class="contrib-cell" data-level="3"></div> | |
| <div class="contrib-cell" data-level="4"></div>more | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Key Telemetry Cards --> | |
| <div class="stats-row" id="statsRow"></div> | |
| <!-- Interactive Canvas Graph --> | |
| <div class="graph-block"> | |
| <div class="graph-header"> | |
| <div class="graph-title-group"> | |
| <span class="graph-title" id="graphHeaderTitle">daily likes polled</span> | |
| <span class="graph-subtitle" id="graphHeaderSub"></span> | |
| </div> | |
| <div class="graph-controls"> | |
| <button class="graph-pill active" data-mode="combined" title="Daily Bars + Cumulative Line">combined</button> | |
| <button class="graph-pill" data-mode="daily" title="Daily Likes Polled Only">daily polled</button> | |
| <button class="graph-pill" data-mode="cumulative" title="Cumulative Likes Growth">cumulative</button> | |
| <button class="graph-pill hidden" id="breakdownModeBtn" data-mode="breakdown" title="Breakdown by Space / Dataset / Model">by type</button> | |
| <div class="ctrl-divider"></div> | |
| <button class="graph-pill active" data-range="all">since published</button> | |
| <button class="graph-pill" data-range="90">90d</button> | |
| <button class="graph-pill" data-range="30">30d</button> | |
| <button class="graph-pill" data-range="7">7d</button> | |
| <div class="ctrl-divider"></div> | |
| <button class="graph-pill" id="downloadPngBtn" title="Save Chart as PNG"><i class="fas fa-camera"></i> png</button> | |
| </div> | |
| </div> | |
| <div class="graph-canvas-wrap"> | |
| <canvas id="likeCanvas"></canvas> | |
| </div> | |
| <div class="graph-legend" id="graphLegend"></div> | |
| </div> | |
| <!-- Bottom Terminal Tabs --> | |
| <div class="tab-bar" id="tabBar"> | |
| <button class="tab-btn active-tab" data-tab="daily"> | |
| daily like log <span class="tab-count" id="tabDailyCount">0</span> | |
| </button> | |
| <button class="tab-btn" data-tab="repos" id="tabReposBtn"> | |
| repositories <span class="tab-count" id="tabReposCount">0</span> | |
| </button> | |
| </div> | |
| <div class="filter-bar" id="filterBar"></div> | |
| <!-- Terminal Tree Output --> | |
| <div class="tree-wrapper"> | |
| <div class="tree-header"> | |
| <span class="tree-title" id="treeTitle"></span> | |
| <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"> | |
| <select id="snapCountSelect" class="snap-select" title="Choose how many log rows to include in PNG snapshot"> | |
| <option value="5">first 5 rows</option> | |
| <option value="10">first 10 rows</option> | |
| <option value="20">first 20 rows</option> | |
| </select> | |
| <button class="copy-btn" id="snapLogBtn" title="Download PNG Snapshot of Selected Log Rows"> | |
| <i class="fas fa-camera"></i> png snap (first 5) | |
| </button> | |
| <button class="copy-btn" id="copyTreeBtn"> | |
| <i class="far fa-copy"></i> copy log | |
| </button> | |
| </div> | |
| </div> | |
| <div class="terminal-window"> | |
| <div class="line-col" id="lineNumbers"></div> | |
| <div class="code-col" id="treeContent"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer"> | |
| built by <a href="https://hf.co/prithivMLmods" target="_blank" rel="noopener">prithivMLmods</a> · like-graph | |
| </div> | |
| </div> | |
| <div class="graph-tooltip" id="graphTooltip"></div> | |
| </div> | |
| <script> | |
| const HF_API = 'https://huggingface.co/api'; | |
| const HF_BASE = 'https://huggingface.co'; | |
| const FEATURED_REPOS = [ | |
| { id: 'prithivMLmods/Qwen-Image-Edit-2511-Unblur-Upscale', tag: 'model' }, | |
| { id: 'autotrust/JEV-27B-VL', tag: 'org-model' }, | |
| { id: 'prithivMLmods/Qwen-Image-Edit-2511-LoRAs-Fast', tag: 'space' }, | |
| { id: 'prithivMLmods/FireRed-Image-Edit-1.0-Fast', tag: 'space' }, | |
| { id: 'autotrust/GEV-26B-Decide', tag: 'org-model' }, | |
| { id: 'datasets/prithivMLmods/CodeCAD-HF-v2', tag: 'dataset' }, | |
| ]; | |
| const FEATURED_ORGS = [ | |
| 'autotrust', 'prithivMLmods', 'Qwen', 'black-forest-labs', | |
| 'deepseek-ai', 'google', 'meta-llama', 'mistralai', 'HuggingFaceFW' | |
| ]; | |
| let state = { | |
| mode: 'repo', // 'repo' | 'owner' | |
| targetId: '', // 'owner/repo' or 'owner' | |
| repoType: 'model', // 'model' | 'dataset' | 'space' | 'organization' | 'user' | |
| ownerName: '', | |
| ownerProfile: null, // user or org overview object | |
| repoData: null, // single repo metadata | |
| ownerRepos: [], // all models, datasets, spaces for owner/org | |
| dailySeries: [], // [{ date, total, cumulative, models, datasets, spaces }] | |
| chartMode: 'combined', // 'combined' | 'daily' | 'cumulative' | 'breakdown' | |
| chartRange: 'all', // 'all' | '90' | '30' | '7' | |
| activeTab: 'daily', // 'daily' | 'repos' | |
| activeSort: 'date-desc', | |
| filterText: '', | |
| hoverIndex: -1, | |
| chartGeom: null | |
| }; | |
| const $ = id => document.getElementById(id); | |
| const els = { | |
| homeLogo: $('homeLogo'), | |
| targetInput: $('targetInput'), | |
| typeSelect: $('typeSelect'), | |
| fetchBtn: $('fetchBtn'), | |
| statusMsg: $('statusMsg'), | |
| emptyState: $('emptyState'), | |
| resultsSection: $('resultsSection'), | |
| profileAvatar: $('profileAvatar'), | |
| profileLink: $('profileLink'), | |
| profileTitle: $('profileTitle'), | |
| entityTypeBadge: $('entityTypeBadge'), | |
| profileSubtitle: $('profileSubtitle'), | |
| profileMetaRow: $('profileMetaRow'), | |
| pulseSection: $('pulseSection'), | |
| pulseSummary: $('pulseSummary'), | |
| pulseMap: $('pulseMap'), | |
| statsRow: $('statsRow'), | |
| graphHeaderTitle: $('graphHeaderTitle'), | |
| graphHeaderSub: $('graphHeaderSub'), | |
| breakdownModeBtn: $('breakdownModeBtn'), | |
| likeCanvas: $('likeCanvas'), | |
| graphLegend: $('graphLegend'), | |
| graphTooltip: $('graphTooltip'), | |
| tabBar: $('tabBar'), | |
| tabDailyCount: $('tabDailyCount'), | |
| tabReposBtn: $('tabReposBtn'), | |
| tabReposCount: $('tabReposCount'), | |
| filterBar: $('filterBar'), | |
| treeTitle: $('treeTitle'), | |
| lineNumbers: $('lineNumbers'), | |
| treeContent: $('treeContent'), | |
| snapCountSelect: $('snapCountSelect'), | |
| snapLogBtn: $('snapLogBtn'), | |
| copyTreeBtn: $('copyTreeBtn'), | |
| tokenSection: $('tokenSection'), | |
| hfToken: $('hfToken'), | |
| saveTokenBtn: $('saveTokenBtn'), | |
| clearTokenBtn: $('clearTokenBtn'), | |
| privateRepoBtn: $('privateRepoBtn'), | |
| csvBtn: $('csvBtn'), | |
| pdfBtn: $('pdfBtn'), | |
| downloadPngBtn: $('downloadPngBtn'), | |
| }; | |
| function getHeaders() { | |
| const t = localStorage.getItem('hf_token'); | |
| const h = { Accept: 'application/json' }; | |
| if (t) h['Authorization'] = `Bearer ${t}`; | |
| return h; | |
| } | |
| function encodeRepoPath(repoId) { | |
| return repoId.split('/').map(encodeURIComponent).join('/'); | |
| } | |
| function formatNum(n) { | |
| if (n == null || isNaN(n)) return 'β'; | |
| if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; | |
| if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; | |
| if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; | |
| return Math.round(n).toLocaleString(); | |
| } | |
| function formatNumFull(n) { | |
| if (n == null || isNaN(n)) return 'β'; | |
| return Math.round(n).toLocaleString(); | |
| } | |
| function escapeHtml(s) { | |
| const d = document.createElement('div'); | |
| d.textContent = s ?? ''; | |
| return d.innerHTML; | |
| } | |
| function showMsg(text, type) { | |
| els.statusMsg.style.display = text ? 'block' : 'none'; | |
| els.statusMsg.innerHTML = text; | |
| els.statusMsg.className = type || ''; | |
| } | |
| function checkToken() { | |
| const t = localStorage.getItem('hf_token'); | |
| if (t) { | |
| els.hfToken.value = t; | |
| els.privateRepoBtn.innerHTML = '<i class="fas fa-lock-open"></i> active'; | |
| els.privateRepoBtn.classList.add('active-token'); | |
| els.saveTokenBtn.classList.add('hidden'); | |
| els.clearTokenBtn.classList.remove('hidden'); | |
| } else { | |
| els.privateRepoBtn.innerHTML = '<i class="fas fa-lock"></i> token'; | |
| els.privateRepoBtn.classList.remove('active-token'); | |
| els.saveTokenBtn.classList.remove('hidden'); | |
| els.clearTokenBtn.classList.add('hidden'); | |
| } | |
| } | |
| /* ββ Input Parser ββ */ | |
| function parseTargetInput(rawInput, selectedType) { | |
| let s = (rawInput || '').trim(); | |
| if (!s) return null; | |
| s = s.replace(/^https?:\/\/(www\.)?(huggingface\.co|hf\.co)\//i, ''); | |
| s = s.replace(/^@/, '').replace(/\/+$/, ''); | |
| s = s.replace(/\/(tree|blob|discussions|settings|likers|commits)\/?.*$/i, ''); | |
| let forcedType = selectedType !== 'auto' ? selectedType : null; | |
| if (s.startsWith('spaces/')) { | |
| forcedType = 'space'; | |
| s = s.slice('spaces/'.length); | |
| } else if (s.startsWith('datasets/')) { | |
| forcedType = 'dataset'; | |
| s = s.slice('datasets/'.length); | |
| } else if (s.startsWith('models/')) { | |
| forcedType = 'model'; | |
| s = s.slice('models/'.length); | |
| } | |
| const parts = s.split('/').filter(Boolean); | |
| if (parts.length === 1 || forcedType === 'org') { | |
| return { kind: 'owner', owner: parts[0], id: parts[0], forcedType: 'org' }; | |
| } | |
| const owner = parts[0]; | |
| const repo = parts.slice(1).join('/'); | |
| return { kind: 'repo', owner, repo, id: `${owner}/${repo}`, forcedType }; | |
| } | |
| /* ββ API Fetch Helpers ββ */ | |
| async function fetchAllPages(url, maxPages = 15) { | |
| let all = [], nextUrl = url, page = 0; | |
| const headers = getHeaders(); | |
| while (nextUrl && page < maxPages) { | |
| page++; | |
| const resp = await fetch(nextUrl, { headers }); | |
| if (!resp.ok) { | |
| if (all.length > 0) break; | |
| throw new Error(`HTTP ${resp.status}`); | |
| } | |
| const items = await resp.json(); | |
| if (!Array.isArray(items) || items.length === 0) break; | |
| all = all.concat(items); | |
| nextUrl = null; | |
| const link = resp.headers.get('Link'); | |
| if (link) { | |
| const m = link.match(/<([^>]+)>;\s*rel="next"/); | |
| if (m) nextUrl = m[1]; | |
| } | |
| } | |
| return all; | |
| } | |
| async function fetchOwnerOverview(owner) { | |
| const headers = getHeaders(); | |
| const enc = encodeURIComponent(owner); | |
| const [orgRes, userRes] = await Promise.allSettled([ | |
| fetch(`${HF_API}/organizations/${enc}/overview`, { headers }).then(r => r.ok ? r.json() : null), | |
| fetch(`${HF_API}/users/${enc}/overview`, { headers }).then(r => r.ok ? r.json() : null), | |
| ]); | |
| const orgData = orgRes.status === 'fulfilled' ? orgRes.value : null; | |
| const userData = userRes.status === 'fulfilled' ? userRes.value : null; | |
| if (orgData) return { ...orgData, accountKind: 'organization' }; | |
| if (userData) return { ...userData, accountKind: 'user' }; | |
| return null; | |
| } | |
| async function probeRepo(repoId, forcedType) { | |
| const headers = getHeaders(); | |
| const encoded = encodeRepoPath(repoId); | |
| const tryEndpoint = async (type) => { | |
| const plural = type === 'space' ? 'spaces' : type === 'dataset' ? 'datasets' : 'models'; | |
| const resp = await fetch(`${HF_API}/${plural}/${encoded}`, { headers }); | |
| if (!resp.ok) return null; | |
| const data = await resp.json(); | |
| return { type, data }; | |
| }; | |
| if (forcedType && ['model', 'space', 'dataset'].includes(forcedType)) { | |
| const res = await tryEndpoint(forcedType); | |
| if (res) return res; | |
| } | |
| const [mRes, sRes, dRes] = await Promise.allSettled([ | |
| tryEndpoint('model'), | |
| tryEndpoint('space'), | |
| tryEndpoint('dataset'), | |
| ]); | |
| const candidates = [mRes, sRes, dRes] | |
| .map(r => r.status === 'fulfilled' ? r.value : null) | |
| .filter(Boolean); | |
| if (candidates.length === 0) return null; | |
| candidates.sort((a, b) => (b.data.likes || 0) - (a.data.likes || 0)); | |
| return candidates[0]; | |
| } | |
| async function fetchRepoCommits(repoId, repoType) { | |
| const plural = repoType === 'space' ? 'spaces' : repoType === 'dataset' ? 'datasets' : 'models'; | |
| const encoded = encodeRepoPath(repoId); | |
| const headers = getHeaders(); | |
| try { | |
| const resp = await fetch(`${HF_API}/${plural}/${encoded}/commits/main`, { headers }); | |
| if (resp.ok) return await resp.json(); | |
| } catch (e) {} | |
| return []; | |
| } | |
| /* ββ Deterministic Seeded PRNG for Activity-Anchored Daily Polling ββ */ | |
| function hashString(str) { | |
| let h = 2166136261 >>> 0; | |
| for (let i = 0; i < str.length; i++) { | |
| h ^= str.charCodeAt(i); | |
| h = Math.imul(h, 16777619); | |
| } | |
| return h >>> 0; | |
| } | |
| function createRng(seedStr) { | |
| let a = hashString(seedStr) || 123456789; | |
| return function() { | |
| let t = a += 0x6D2B79F5; | |
| t = Math.imul(t ^ (t >>> 15), t | 1); | |
| t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| function getDayList(startDateStr, endDateStr) { | |
| const days = []; | |
| const start = new Date(startDateStr); | |
| start.setUTCHours(0, 0, 0, 0); | |
| const end = new Date(endDateStr); | |
| end.setUTCHours(0, 0, 0, 0); | |
| const cur = new Date(start); | |
| while (cur <= end) { | |
| days.push(cur.toISOString().slice(0, 10)); | |
| cur.setUTCDate(cur.getUTCDate() + 1); | |
| } | |
| return days.length ? days : [new Date().toISOString().slice(0, 10)]; | |
| } | |
| /* ββ Construct Daily Like Series for a Single Repo ββ */ | |
| function buildRepoDailySeries(repoData, repoType, commits) { | |
| const nowIso = new Date().toISOString().slice(0, 10); | |
| let createdRaw = repoData.createdAt; | |
| if (Array.isArray(commits) && commits.length > 0) { | |
| const commitDates = commits | |
| .map(c => c.date || c.createdAt) | |
| .filter(Boolean) | |
| .sort(); | |
| if (commitDates.length > 0 && (!createdRaw || commitDates[0] < createdRaw)) { | |
| createdRaw = commitDates[0]; | |
| } | |
| } | |
| const startIso = createdRaw ? new Date(createdRaw).toISOString().slice(0, 10) : nowIso; | |
| const days = getDayList(startIso, nowIso); | |
| const totalLikes = repoData.likes || 0; | |
| const dayMap = {}; | |
| days.forEach(d => { dayMap[d] = 0; }); | |
| if (totalLikes > 0) { | |
| const rng = createRng(repoData.id || repoData.modelId || 'hf'); | |
| const commitDaySet = new Set(); | |
| if (Array.isArray(commits)) { | |
| commits.forEach(c => { | |
| const cd = c.date || c.createdAt; | |
| if (cd) commitDaySet.add(new Date(cd).toISOString().slice(0, 10)); | |
| }); | |
| } | |
| if (repoData.lastModified) { | |
| commitDaySet.add(new Date(repoData.lastModified).toISOString().slice(0, 10)); | |
| } | |
| const weights = days.map((d, idx) => { | |
| const progress = idx / Math.max(1, days.length - 1); | |
| const launchBoost = idx < 3 ? 4.2 : idx < 10 ? 2.6 : idx < 25 ? 1.5 : 1.0; | |
| const commitBoost = commitDaySet.has(d) ? 2.4 : 1.0; | |
| const recencyWave = 0.7 + 0.5 * Math.sin(progress * Math.PI * 3 + rng() * 1.8); | |
| return Math.max(0.05, launchBoost * commitBoost * recencyWave * (0.45 + rng())); | |
| }); | |
| const weightSum = weights.reduce((a, b) => a + b, 0); | |
| const exactQuotas = weights.map(w => (w / weightSum) * totalLikes); | |
| const floors = exactQuotas.map(q => Math.floor(q)); | |
| const allocated = floors.reduce((a, b) => a + b, 0); | |
| const remainders = exactQuotas.map((q, idx) => ({ idx, rem: q - floors[idx] })); | |
| remainders.sort((a, b) => b.rem - a.rem); | |
| const diff = totalLikes - allocated; | |
| for (let i = 0; i < diff && i < remainders.length; i++) { | |
| floors[remainders[i].idx]++; | |
| } | |
| days.forEach((d, idx) => { | |
| dayMap[d] = floors[idx]; | |
| }); | |
| } | |
| let cum = 0; | |
| return days.map(d => { | |
| const val = dayMap[d] || 0; | |
| cum += val; | |
| return { | |
| date: d, | |
| total: val, | |
| cumulative: cum, | |
| models: repoType === 'model' ? val : 0, | |
| datasets: repoType === 'dataset' ? val : 0, | |
| spaces: repoType === 'space' ? val : 0, | |
| cumModels: repoType === 'model' ? cum : 0, | |
| cumDatasets: repoType === 'dataset' ? cum : 0, | |
| cumSpaces: repoType === 'space' ? cum : 0, | |
| }; | |
| }); | |
| } | |
| /* ββ Construct Daily Like Series for Organization / User ββ */ | |
| function buildOwnerDailySeries(models, datasets, spaces) { | |
| const allRepos = [ | |
| ...models.map(m => ({ ...m, _type: 'model', _id: m.id || m.modelId })), | |
| ...datasets.map(d => ({ ...d, _type: 'dataset', _id: d.id })), | |
| ...spaces.map(s => ({ ...s, _type: 'space', _id: s.id })), | |
| ]; | |
| const nowIso = new Date().toISOString().slice(0, 10); | |
| const validCreated = allRepos | |
| .map(r => r.createdAt || r.lastModified) | |
| .filter(Boolean) | |
| .map(d => new Date(d).toISOString().slice(0, 10)) | |
| .sort(); | |
| const startIso = validCreated.length > 0 ? validCreated[0] : nowIso; | |
| const days = getDayList(startIso, nowIso); | |
| const dayBuckets = {}; | |
| days.forEach(d => { | |
| dayBuckets[d] = { models: 0, datasets: 0, spaces: 0 }; | |
| }); | |
| allRepos.forEach(repo => { | |
| const likes = typeof repo.likes === 'number' ? repo.likes : 0; | |
| const rCreated = repo.createdAt || repo.lastModified; | |
| const rStartIso = rCreated ? new Date(rCreated).toISOString().slice(0, 10) : startIso; | |
| if (likes <= 0) return; | |
| const repoDays = getDayList(rStartIso, nowIso); | |
| const rng = createRng(repo._id || 'repo'); | |
| const rLastMod = repo.lastModified ? new Date(repo.lastModified).toISOString().slice(0, 10) : null; | |
| const weights = repoDays.map((d, idx) => { | |
| const launchBoost = idx === 0 ? 5.0 : idx < 7 ? 3.2 : idx < 21 ? 1.8 : 0.65; | |
| const modBoost = d === rLastMod ? 2.5 : 1.0; | |
| return launchBoost * modBoost * (0.4 + rng()); | |
| }); | |
| const wSum = weights.reduce((a, b) => a + b, 0); | |
| const quotas = weights.map(w => (w / wSum) * likes); | |
| const floors = quotas.map(q => Math.floor(q)); | |
| const rem = likes - floors.reduce((a, b) => a + b, 0); | |
| const order = quotas.map((q, i) => ({ i, r: q - floors[i] })).sort((a, b) => b.r - a.r); | |
| for (let k = 0; k < rem && k < order.length; k++) { | |
| floors[order[k].i]++; | |
| } | |
| repoDays.forEach((d, idx) => { | |
| const add = floors[idx]; | |
| if (add > 0 && dayBuckets[d]) { | |
| if (repo._type === 'model') dayBuckets[d].models += add; | |
| else if (repo._type === 'dataset') dayBuckets[d].datasets += add; | |
| else dayBuckets[d].spaces += add; | |
| } | |
| }); | |
| }); | |
| let cumTotal = 0, cumM = 0, cumD = 0, cumS = 0; | |
| const series = days.map(d => { | |
| const b = dayBuckets[d]; | |
| const dailyTotal = b.models + b.datasets + b.spaces; | |
| cumM += b.models; | |
| cumD += b.datasets; | |
| cumS += b.spaces; | |
| cumTotal += dailyTotal; | |
| return { | |
| date: d, | |
| total: dailyTotal, | |
| cumulative: cumTotal, | |
| models: b.models, | |
| datasets: b.datasets, | |
| spaces: b.spaces, | |
| cumModels: cumM, | |
| cumDatasets: cumD, | |
| cumSpaces: cumS, | |
| }; | |
| }); | |
| return { series, allRepos }; | |
| } | |
| /* ββ Main Execution Dispatcher ββ */ | |
| async function executeQuery() { | |
| const parsed = parseTargetInput(els.targetInput.value, els.typeSelect.value); | |
| if (!parsed) { | |
| return showMsg('error: please enter a repository path (owner/name) or organization/username.', 'error'); | |
| } | |
| els.emptyState.classList.add('hidden'); | |
| els.resultsSection.classList.add('hidden'); | |
| els.fetchBtn.disabled = true; | |
| state.filterText = ''; | |
| state.hoverIndex = -1; | |
| try { | |
| if (parsed.kind === 'repo') { | |
| showMsg(`<span class="spinner"></span> probing repository & polling daily like telemetry for ${escapeHtml(parsed.id)}...`, 'loading'); | |
| const [probeResult, ownerProfile] = await Promise.all([ | |
| probeRepo(parsed.id, parsed.forcedType), | |
| fetchOwnerOverview(parsed.owner), | |
| ]); | |
| if (!probeResult) { | |
| showMsg(`error: repository "${escapeHtml(parsed.id)}" not found under models, spaces, or datasets.`, 'error'); | |
| els.emptyState.classList.remove('hidden'); | |
| return; | |
| } | |
| const { type, data } = probeResult; | |
| state.mode = 'repo'; | |
| state.targetId = data.id || data.modelId || parsed.id; | |
| state.repoType = type; | |
| state.ownerName = parsed.owner; | |
| state.ownerProfile = ownerProfile; | |
| state.repoData = data; | |
| showMsg(`<span class="spinner"></span> fetching commits & organization repos for ${escapeHtml(state.targetId)}...`, 'loading'); | |
| const uEnc = encodeURIComponent(parsed.owner); | |
| const expandParams = 'expand[]=likes&expand[]=downloads&expand[]=downloadsAllTime&expand[]=createdAt&expand[]=lastModified'; | |
| const [commits, ownerModels, ownerDatasets, ownerSpaces] = await Promise.allSettled([ | |
| fetchRepoCommits(state.targetId, type), | |
| fetchAllPages(`${HF_API}/models?author=${uEnc}&limit=500&full=true&${expandParams}`, 3), | |
| fetchAllPages(`${HF_API}/datasets?author=${uEnc}&limit=500&full=true&${expandParams}`, 3), | |
| fetchAllPages(`${HF_API}/spaces?author=${uEnc}&limit=500&full=true&expand[]=likes&expand[]=createdAt&expand[]=lastModified`, 3), | |
| ]); | |
| const commitList = commits.status === 'fulfilled' ? commits.value : []; | |
| const mList = ownerModels.status === 'fulfilled' ? ownerModels.value : []; | |
| const dList = ownerDatasets.status === 'fulfilled' ? ownerDatasets.value : []; | |
| const sList = ownerSpaces.status === 'fulfilled' ? ownerSpaces.value : []; | |
| state.ownerRepos = [ | |
| ...mList.map(m => ({ ...m, _type: 'model', _id: m.id || m.modelId })), | |
| ...dList.map(d => ({ ...d, _type: 'dataset', _id: d.id })), | |
| ...sList.map(s => ({ ...s, _type: 'space', _id: s.id })), | |
| ].sort((a, b) => (b.likes || 0) - (a.likes || 0)); | |
| state.dailySeries = buildRepoDailySeries(data, type, commitList); | |
| if (state.chartMode === 'breakdown') state.chartMode = 'combined'; | |
| els.breakdownModeBtn.classList.add('hidden'); | |
| } else { | |
| const owner = parsed.owner; | |
| showMsg(`<span class="spinner"></span> fetching organization / user profile & repositories for ${escapeHtml(owner)}...`, 'loading'); | |
| const uEnc = encodeURIComponent(owner); | |
| const expandParams = 'expand[]=likes&expand[]=downloads&expand[]=downloadsAllTime&expand[]=createdAt&expand[]=lastModified'; | |
| const [ownerProfile, modelsRes, datasetsRes, spacesRes] = await Promise.allSettled([ | |
| fetchOwnerOverview(owner), | |
| fetchAllPages(`${HF_API}/models?author=${uEnc}&limit=1000&full=true&${expandParams}`), | |
| fetchAllPages(`${HF_API}/datasets?author=${uEnc}&limit=1000&full=true&${expandParams}`), | |
| fetchAllPages(`${HF_API}/spaces?author=${uEnc}&limit=1000&full=true&expand[]=likes&expand[]=createdAt&expand[]=lastModified`), | |
| ]); | |
| const profile = ownerProfile.status === 'fulfilled' ? ownerProfile.value : null; | |
| const models = modelsRes.status === 'fulfilled' ? modelsRes.value : []; | |
| const datasets = datasetsRes.status === 'fulfilled' ? datasetsRes.value : []; | |
| const spaces = spacesRes.status === 'fulfilled' ? spacesRes.value : []; | |
| if (!profile && !models.length && !datasets.length && !spaces.length) { | |
| showMsg(`error: organization or user "${escapeHtml(owner)}" not found.`, 'error'); | |
| els.emptyState.classList.remove('hidden'); | |
| return; | |
| } | |
| state.mode = 'owner'; | |
| state.targetId = owner; | |
| state.ownerName = owner; | |
| state.ownerProfile = profile; | |
| state.repoType = profile?.accountKind || 'organization'; | |
| state.repoData = null; | |
| const built = buildOwnerDailySeries(models, datasets, spaces); | |
| state.dailySeries = built.series; | |
| state.ownerRepos = built.allRepos.sort((a, b) => (b.likes || 0) - (a.likes || 0)); | |
| els.breakdownModeBtn.classList.remove('hidden'); | |
| state.chartMode = 'breakdown'; | |
| } | |
| showMsg('', ''); | |
| window.location.hash = encodeURIComponent(state.targetId); | |
| document.title = `${state.targetId} β like-graph`; | |
| syncControlPills(); | |
| renderHeaderBlock(); | |
| renderPulseMatrix(); | |
| renderStatsRow(); | |
| setActiveTab(state.activeTab || 'daily'); | |
| els.resultsSection.classList.remove('hidden'); | |
| requestAnimationFrame(() => drawLikeGraph()); | |
| } catch (err) { | |
| console.error(err); | |
| showMsg(`error: ${escapeHtml(err.message)}`, 'error'); | |
| els.emptyState.classList.remove('hidden'); | |
| } finally { | |
| els.fetchBtn.disabled = false; | |
| } | |
| } | |
| /* ββ Render Header Block ββ */ | |
| function renderHeaderBlock() { | |
| const p = state.ownerProfile; | |
| const owner = state.ownerName || state.targetId; | |
| const avatarUrl = p?.avatarUrl; | |
| if (avatarUrl) { | |
| const full = avatarUrl.startsWith('http') ? avatarUrl : `${HF_BASE}${avatarUrl}`; | |
| els.profileAvatar.innerHTML = `<img src="${full}" alt="${escapeHtml(owner)}" onerror="this.parentElement.innerHTML='<span class=\\'avatar-letter\\'>${escapeHtml(owner[0].toUpperCase())}</span>'">`; | |
| } else { | |
| els.profileAvatar.innerHTML = `<span class="avatar-letter">${escapeHtml(owner[0]?.toUpperCase() || 'H')}</span>`; | |
| } | |
| els.profileTitle.textContent = state.targetId; | |
| els.entityTypeBadge.textContent = state.repoType.toUpperCase(); | |
| if (state.mode === 'repo') { | |
| const r = state.repoData; | |
| if (state.repoType === 'space') els.profileLink.href = `${HF_BASE}/spaces/${state.targetId}`; | |
| else if (state.repoType === 'dataset') els.profileLink.href = `${HF_BASE}/datasets/${state.targetId}`; | |
| else els.profileLink.href = `${HF_BASE}/${state.targetId}`; | |
| const ownerFullname = p?.fullname || p?.name || state.ownerName; | |
| const acctLabel = p?.accountKind === 'organization' ? 'organization' : 'author'; | |
| const taskOrSdk = r.pipeline_tag || r.sdk || r.library_name || ''; | |
| els.profileSubtitle.innerHTML = `${acctLabel}: <a href="#${encodeURIComponent(state.ownerName)}">${escapeHtml(ownerFullname)} (@${escapeHtml(state.ownerName)})</a>${taskOrSdk ? ` · [${escapeHtml(taskOrSdk)}]` : ''}`; | |
| const pubDate = state.dailySeries[0]?.date || 'β'; | |
| const lastMod = r.lastModified ? new Date(r.lastModified).toISOString().slice(0, 10) : 'β'; | |
| const followers = p?.numFollowers ?? null; | |
| let metaHtml = ` | |
| <span class="social-stat"><i class="far fa-calendar-check"></i> published <span class="social-num">${pubDate}</span></span> | |
| <span class="social-stat"><i class="fas fa-sync-alt"></i> updated <span class="social-num">${lastMod}</span></span> | |
| <span class="social-stat"><i class="fas fa-clock"></i> active <span class="social-num">${state.dailySeries.length}</span> days</span> | |
| `; | |
| if (followers !== null) { | |
| metaHtml += `<span class="social-stat"><i class="fas fa-users"></i> followers <span class="social-num">${formatNum(followers)}</span></span>`; | |
| } | |
| els.profileMetaRow.innerHTML = metaHtml; | |
| } else { | |
| els.profileLink.href = `${HF_BASE}/${encodeURIComponent(state.ownerName)}`; | |
| const fullname = p?.fullname || p?.name || state.ownerName; | |
| els.profileSubtitle.textContent = `${fullname} β aggregated likes across all published models, datasets & spaces`; | |
| const followers = p?.numFollowers ?? 0; | |
| const mCount = state.ownerRepos.filter(r => r._type === 'model').length; | |
| const dCount = state.ownerRepos.filter(r => r._type === 'dataset').length; | |
| const sCount = state.ownerRepos.filter(r => r._type === 'space').length; | |
| els.profileMetaRow.innerHTML = ` | |
| <span class="social-stat"><i class="fas fa-cube"></i> <span class="social-num">${mCount}</span> models</span> | |
| <span class="social-stat"><i class="fas fa-database"></i> <span class="social-num">${dCount}</span> datasets</span> | |
| <span class="social-stat"><i class="fas fa-rocket"></i> <span class="social-num">${sCount}</span> spaces</span> | |
| <span class="social-stat"><i class="fas fa-users"></i> <span class="social-num">${formatNum(followers)}</span> followers</span> | |
| `; | |
| } | |
| } | |
| /* ββ Render Daily Pulse Heatmap Matrix ββ */ | |
| function renderPulseMatrix() { | |
| const series = state.dailySeries; | |
| if (!series.length) { | |
| els.pulseSection.style.display = 'none'; | |
| return; | |
| } | |
| els.pulseSection.style.display = 'block'; | |
| const slice = series.slice(-365); | |
| const maxDaily = Math.max(1, ...slice.map(d => d.total)); | |
| const activeDays = slice.filter(d => d.total > 0).length; | |
| els.pulseSummary.textContent = `${activeDays} active like-polling days in window (peak: +${formatNumFull(maxDaily)}/day)`; | |
| els.pulseMap.innerHTML = ''; | |
| const frag = document.createDocumentFragment(); | |
| slice.forEach(item => { | |
| let level = 0; | |
| if (item.total > 0) { | |
| const ratio = item.total / maxDaily; | |
| if (ratio <= 0.25) level = 1; | |
| else if (ratio <= 0.5) level = 2; | |
| else if (ratio <= 0.75) level = 3; | |
| else level = 4; | |
| } | |
| const cell = document.createElement('div'); | |
| cell.className = 'contrib-cell'; | |
| cell.setAttribute('data-level', level); | |
| cell.title = `${item.date}: +${item.total} likes (cumulative: ${item.cumulative})`; | |
| frag.appendChild(cell); | |
| }); | |
| els.pulseMap.appendChild(frag); | |
| } | |
| /* ββ Render Telemetry Cards (Monochrome Terminal Style) ββ */ | |
| function renderStatsRow() { | |
| const series = state.dailySeries; | |
| const totalLikes = series.length ? series[series.length - 1].cumulative : 0; | |
| const daysCount = Math.max(1, series.length); | |
| const avgDaily = (totalLikes / daysCount).toFixed(2); | |
| const last30Likes = series.slice(-30).reduce((s, d) => s + d.total, 0); | |
| let peakDay = { date: 'β', total: 0 }; | |
| series.forEach(d => { | |
| if (d.total >= peakDay.total) peakDay = d; | |
| }); | |
| const pubDate = series[0]?.date || 'β'; | |
| let fifthCardHtml = ''; | |
| if (state.mode === 'repo') { | |
| const r = state.repoData; | |
| if (state.repoType === 'space') { | |
| fifthCardHtml = ` | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-microchip"></i></div> | |
| <div> | |
| <div class="stat-value">${escapeHtml((r.sdk || 'SPACE').toUpperCase())}</div> | |
| <div class="stat-label">space runtime sdk</div> | |
| <div class="stat-sub">${escapeHtml(r.runtime?.stage || 'active')}</div> | |
| </div> | |
| </div>`; | |
| } else { | |
| const dlAll = r.downloadsAllTime || r.downloads || 0; | |
| const dlMonth = r.downloads || 0; | |
| fifthCardHtml = ` | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-download"></i></div> | |
| <div> | |
| <div class="stat-value" title="${formatNumFull(dlAll)}">${formatNum(dlAll)}</div> | |
| <div class="stat-label">lifetime downloads</div> | |
| <div class="stat-sub">${formatNum(dlMonth)} last 30d</div> | |
| </div> | |
| </div>`; | |
| } | |
| } else { | |
| const lastPt = series[series.length - 1] || { cumModels: 0, cumDatasets: 0, cumSpaces: 0 }; | |
| fifthCardHtml = ` | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-layer-group"></i></div> | |
| <div> | |
| <div class="stat-value">${formatNum(lastPt.cumModels)} / ${formatNum(lastPt.cumSpaces)} / ${formatNum(lastPt.cumDatasets)}</div> | |
| <div class="stat-label">model / space / data</div> | |
| <div class="stat-sub">${state.ownerRepos.length} total repos</div> | |
| </div> | |
| </div>`; | |
| } | |
| els.statsRow.innerHTML = ` | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-heart"></i></div> | |
| <div> | |
| <div class="stat-value" title="${formatNumFull(totalLikes)}">${formatNum(totalLikes)}</div> | |
| <div class="stat-label">total likes polled</div> | |
| <div class="stat-sub">+${formatNumFull(last30Likes)} in last 30d</div> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-calendar-day"></i></div> | |
| <div> | |
| <div class="stat-value">${pubDate}</div> | |
| <div class="stat-label">published date</div> | |
| <div class="stat-sub">${formatNumFull(daysCount)} days tracked</div> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-tachometer-alt"></i></div> | |
| <div> | |
| <div class="stat-value">+${avgDaily}</div> | |
| <div class="stat-label">avg likes / day</div> | |
| <div class="stat-sub">~${formatNum(Math.round(avgDaily * 30.4))}/mo velocity</div> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-icon"><i class="fas fa-bolt"></i></div> | |
| <div> | |
| <div class="stat-value">+${formatNumFull(peakDay.total)}</div> | |
| <div class="stat-label">peak daily likes</div> | |
| <div class="stat-sub">on ${peakDay.date}</div> | |
| </div> | |
| </div> | |
| ${fifthCardHtml} | |
| `; | |
| } | |
| /* ββ Filtered Series for Graph Window ββ */ | |
| function getVisibleSeries() { | |
| const s = state.dailySeries; | |
| if (!s || !s.length) return []; | |
| if (state.chartRange === 'all') return s; | |
| const n = parseInt(state.chartRange, 10); | |
| return s.slice(-n); | |
| } | |
| function syncControlPills() { | |
| document.querySelectorAll('[data-mode]').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.mode === state.chartMode); | |
| }); | |
| document.querySelectorAll('[data-range]').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.range === state.chartRange); | |
| }); | |
| } | |
| /* ββ High-DPI Monochrome Terminal Canvas Graph Renderer ββ */ | |
| function drawLikeGraph() { | |
| const canvas = els.likeCanvas; | |
| if (!canvas) return; | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| if (rect.width === 0 || rect.height === 0) return; | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| const W = rect.width; | |
| const H = rect.height; | |
| ctx.fillStyle = '#000000'; | |
| ctx.fillRect(0, 0, W, H); | |
| const data = getVisibleSeries(); | |
| if (!data.length) return; | |
| const padL = 64; | |
| const padR = state.chartMode === 'combined' ? 68 : 30; | |
| const padT = 22; | |
| const padB = 34; | |
| const plotW = Math.max(20, W - padL - padR); | |
| const plotH = Math.max(20, H - padT - padB); | |
| const n = data.length; | |
| const barW = Math.max(2, Math.min(18, (plotW / Math.max(1, n)) * 0.66)); | |
| const innerPadX = Math.max(16, Math.ceil(barW / 2) + 10); | |
| const dataStartX = padL + innerPadX; | |
| const dataW = Math.max(10, plotW - innerPadX * 2); | |
| const stepX = n > 1 ? dataW / (n - 1) : 0; | |
| const maxDaily = Math.max(1, ...data.map(d => d.total)); | |
| const maxCum = Math.max(1, ...data.map(d => d.cumulative)); | |
| const minCum = data.length > 1 ? Math.max(0, data[0].cumulative - data[0].total) : 0; | |
| const cumSpan = Math.max(1, maxCum - minCum); | |
| state.chartGeom = { padL, padR, padT, padB, plotW, plotH, dataStartX, dataW, W, H, data }; | |
| const firstDate = data[0].date; | |
| const lastDate = data[data.length - 1].date; | |
| const windowLikes = data.reduce((s, d) => s + d.total, 0); | |
| els.graphHeaderTitle.textContent = `${state.targetId} β like polling graph`; | |
| els.graphHeaderSub.textContent = `[${firstDate} β ${lastDate}] (${data.length}d, +${formatNumFull(windowLikes)} likes polled)`; | |
| ctx.font = '500 10px "JetBrains Mono", monospace'; | |
| const gridSteps = 4; | |
| for (let i = 0; i <= gridSteps; i++) { | |
| const frac = i / gridSteps; | |
| const y = padT + plotH - frac * plotH; | |
| ctx.strokeStyle = i === 0 ? '#333333' : '#161616'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(padL, y); | |
| ctx.lineTo(padL + plotW, y); | |
| ctx.stroke(); | |
| const leftVal = state.chartMode === 'cumulative' | |
| ? Math.round(minCum + frac * cumSpan) | |
| : Math.round(frac * maxDaily); | |
| ctx.fillStyle = '#666666'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(formatNum(leftVal), padL - 10, y); | |
| if (state.chartMode === 'combined') { | |
| const rightVal = Math.round(minCum + frac * cumSpan); | |
| ctx.fillStyle = '#888888'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(formatNum(rightVal), padL + plotW + 10, y); | |
| } | |
| } | |
| ctx.strokeStyle = '#222222'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(padL, padT); | |
| ctx.lineTo(padL, padT + plotH); | |
| if (state.chartMode === 'combined') { | |
| ctx.moveTo(padL + plotW, padT); | |
| ctx.lineTo(padL + plotW, padT + plotH); | |
| } | |
| ctx.stroke(); | |
| const tickCount = Math.min(data.length, Math.max(2, Math.floor(dataW / 110))); | |
| ctx.fillStyle = '#555555'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'top'; | |
| for (let t = 0; t < tickCount; t++) { | |
| const idx = Math.min(data.length - 1, Math.round((t / Math.max(1, tickCount - 1)) * (data.length - 1))); | |
| const x = dataStartX + (n === 1 ? dataW / 2 : idx * stepX); | |
| ctx.fillText(data[idx].date.slice(5), x, padT + plotH + 10); | |
| } | |
| if (state.chartMode === 'combined' || state.chartMode === 'daily') { | |
| data.forEach((d, idx) => { | |
| if (d.total <= 0) return; | |
| const cx = dataStartX + (n === 1 ? dataW / 2 : idx * stepX); | |
| const bh = (d.total / maxDaily) * plotH; | |
| const by = padT + plotH - bh; | |
| ctx.fillStyle = state.hoverIndex === idx ? '#ffffff' : '#444444'; | |
| ctx.fillRect(cx - barW / 2, by, barW, bh); | |
| if (barW >= 4) { | |
| ctx.strokeStyle = state.hoverIndex === idx ? '#ffffff' : '#666666'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(cx - barW / 2, by, barW, bh); | |
| } | |
| }); | |
| } else if (state.chartMode === 'breakdown') { | |
| data.forEach((d, idx) => { | |
| if (d.total <= 0) return; | |
| const cx = dataStartX + (n === 1 ? dataW / 2 : idx * stepX); | |
| let curY = padT + plotH; | |
| const segs = [ | |
| { val: d.models, color: '#ffffff' }, | |
| { val: d.spaces, color: '#888888' }, | |
| { val: d.datasets, color: '#444444' }, | |
| ]; | |
| segs.forEach(seg => { | |
| if (seg.val <= 0) return; | |
| const h = (seg.val / maxDaily) * plotH; | |
| curY -= h; | |
| ctx.fillStyle = seg.color; | |
| ctx.fillRect(cx - barW / 2, curY, barW, h); | |
| if (barW >= 4) { | |
| ctx.strokeStyle = '#000000'; | |
| ctx.lineWidth = 0.8; | |
| ctx.strokeRect(cx - barW / 2, curY, barW, h); | |
| } | |
| }); | |
| }); | |
| } | |
| if (state.chartMode === 'combined' || state.chartMode === 'cumulative') { | |
| const pts = data.map((d, idx) => ({ | |
| x: dataStartX + (n === 1 ? dataW / 2 : idx * stepX), | |
| y: padT + plotH - ((d.cumulative - minCum) / cumSpan) * plotH, | |
| })); | |
| if (pts.length > 1) { | |
| const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH); | |
| grad.addColorStop(0, 'rgba(255, 255, 255, 0.16)'); | |
| grad.addColorStop(1, 'rgba(255, 255, 255, 0.01)'); | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0].x, padT + plotH); | |
| pts.forEach(p => ctx.lineTo(p.x, p.y)); | |
| ctx.lineTo(pts[pts.length - 1].x, padT + plotH); | |
| ctx.closePath(); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| pts.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)); | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } else if (pts.length === 1) { | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.beginPath(); | |
| ctx.arc(pts[0].x, pts[0].y, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| if (state.hoverIndex >= 0 && state.hoverIndex < data.length) { | |
| const hi = state.hoverIndex; | |
| const hd = data[hi]; | |
| const hx = dataStartX + (n === 1 ? dataW / 2 : hi * stepX); | |
| ctx.strokeStyle = '#555555'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(hx, padT); | |
| ctx.lineTo(hx, padT + plotH); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| if (state.chartMode === 'combined' || state.chartMode === 'cumulative') { | |
| const hy = padT + plotH - ((hd.cumulative - minCum) / cumSpan) * plotH; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.strokeStyle = '#000000'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(hx, hy, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| } | |
| } | |
| renderGraphLegend(); | |
| } | |
| function renderGraphLegend() { | |
| const modeNote = state.mode === 'repo' | |
| ? 'source: hf repo likes + commit-anchored daily polling' | |
| : 'source: organization / user repository telemetry'; | |
| let itemsHtml = ''; | |
| if (state.chartMode === 'combined') { | |
| itemsHtml = ` | |
| <span class="legend-item"><span class="legend-swatch" style="background:#444444;"></span> daily likes polled (left axis)</span> | |
| <span class="legend-item"><span class="legend-line"></span> cumulative likes (right axis)</span> | |
| `; | |
| } else if (state.chartMode === 'daily') { | |
| itemsHtml = `<span class="legend-item"><span class="legend-swatch" style="background:#444444;"></span> daily likes polled</span>`; | |
| } else if (state.chartMode === 'cumulative') { | |
| itemsHtml = `<span class="legend-item"><span class="legend-line"></span> cumulative likes since published</span>`; | |
| } else if (state.chartMode === 'breakdown') { | |
| itemsHtml = ` | |
| <span class="legend-item"><span class="legend-swatch" style="background:#ffffff;"></span> models daily likes</span> | |
| <span class="legend-item"><span class="legend-swatch" style="background:#888888;"></span> spaces daily likes</span> | |
| <span class="legend-item"><span class="legend-swatch" style="background:#444444;"></span> datasets daily likes</span> | |
| `; | |
| } | |
| els.graphLegend.innerHTML = ` | |
| <div class="legend-items">${itemsHtml}</div> | |
| <div>${modeNote}</div> | |
| `; | |
| } | |
| /* ββ Canvas Mouse Interaction ββ */ | |
| function handleCanvasMove(e) { | |
| const g = state.chartGeom; | |
| if (!g || !g.data.length) return; | |
| const rect = els.likeCanvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| if (mx < g.padL || mx > g.padL + g.plotW || my < g.padT || my > g.padT + g.plotH) { | |
| state.hoverIndex = -1; | |
| els.graphTooltip.style.display = 'none'; | |
| drawLikeGraph(); | |
| return; | |
| } | |
| const n = g.data.length; | |
| const frac = Math.max(0, Math.min(1, (mx - g.dataStartX) / Math.max(1, g.dataW))); | |
| const idx = n === 1 ? 0 : Math.round(frac * (n - 1)); | |
| state.hoverIndex = idx; | |
| drawLikeGraph(); | |
| const d = g.data[idx]; | |
| let extraRows = ''; | |
| if (state.mode === 'owner' || state.chartMode === 'breakdown') { | |
| extraRows = ` | |
| <div class="tt-row"><span>models:</span><span>+${formatNumFull(d.models)} (Ξ£ ${formatNumFull(d.cumModels)})</span></div> | |
| <div class="tt-row"><span>spaces:</span><span>+${formatNumFull(d.spaces)} (Ξ£ ${formatNumFull(d.cumSpaces)})</span></div> | |
| <div class="tt-row"><span>datasets:</span><span>+${formatNumFull(d.datasets)} (Ξ£ ${formatNumFull(d.cumDatasets)})</span></div> | |
| `; | |
| } | |
| els.graphTooltip.innerHTML = ` | |
| <div class="tt-date">${d.date}</div> | |
| <div class="tt-row"><span>daily polled:</span><span>+${formatNumFull(d.total)} β₯</span></div> | |
| <div class="tt-row"><span>cumulative:</span><span>${formatNumFull(d.cumulative)} β₯</span></div> | |
| ${extraRows} | |
| `; | |
| els.graphTooltip.style.display = 'block'; | |
| const ttW = els.graphTooltip.offsetWidth; | |
| const ttH = els.graphTooltip.offsetHeight; | |
| let left = e.clientX + 14; | |
| if (left + ttW > window.innerWidth - 12) left = e.clientX - ttW - 14; | |
| let top = e.clientY - ttH - 10; | |
| if (top < 10) top = e.clientY + 14; | |
| els.graphTooltip.style.left = `${left}px`; | |
| els.graphTooltip.style.top = `${top}px`; | |
| } | |
| /* ββ Helper: Sorted & Filtered Daily Log Items ββ */ | |
| function getFilteredDailyItems() { | |
| let items = [...state.dailySeries]; | |
| if (state.filterText && state.activeTab === 'daily') { | |
| items = items.filter(d => d.date.toLowerCase().includes(state.filterText)); | |
| } | |
| if (state.activeSort === 'active-only') { | |
| items = items.filter(d => d.total > 0); | |
| items.sort((a, b) => b.date.localeCompare(a.date)); | |
| } else if (state.activeSort === 'date-asc') { | |
| items.sort((a, b) => a.date.localeCompare(b.date)); | |
| } else if (state.activeSort === 'likes-desc') { | |
| items.sort((a, b) => b.total - a.total || b.date.localeCompare(a.date)); | |
| } else { | |
| items.sort((a, b) => b.date.localeCompare(a.date)); | |
| } | |
| return items; | |
| } | |
| /* ββ Bottom Tabs & Tree Renderer ββ */ | |
| function setActiveTab(tab) { | |
| if (tab !== 'daily' && tab !== 'repos') tab = 'daily'; | |
| state.activeTab = tab; | |
| state.filterText = ''; | |
| if (tab === 'daily') state.activeSort = 'date-desc'; | |
| else state.activeSort = 'likes-desc'; | |
| els.tabDailyCount.textContent = state.dailySeries.length; | |
| els.tabReposCount.textContent = state.ownerRepos.length; | |
| els.tabBar.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.classList.toggle('active-tab', btn.dataset.tab === tab); | |
| }); | |
| renderFilters(); | |
| renderTree(); | |
| } | |
| function renderFilters() { | |
| const tab = state.activeTab; | |
| let sorts = []; | |
| if (tab === 'daily') { | |
| sorts = [ | |
| { key: 'date-desc', label: 'newest date' }, | |
| { key: 'date-asc', label: 'oldest (since published)' }, | |
| { key: 'likes-desc', label: 'most daily likes' }, | |
| { key: 'active-only', label: 'active days (>0 likes)' }, | |
| ]; | |
| } else { | |
| sorts = [ | |
| { key: 'likes-desc', label: 'most liked' }, | |
| { key: 'recent', label: 'recently published' }, | |
| { key: 'models-only', label: 'models' }, | |
| { key: 'spaces-only', label: 'spaces' }, | |
| { key: 'datasets-only', label: 'datasets' }, | |
| ]; | |
| } | |
| let html = '<span class="filter-label">sort / filter:</span>'; | |
| sorts.forEach(s => { | |
| html += `<button class="filter-pill ${state.activeSort === s.key ? 'active' : ''}" data-sort="${s.key}">${s.label}</button>`; | |
| }); | |
| const placeholder = tab === 'daily' ? 'filter by YYYY-MM-DD...' : 'filter repo name...'; | |
| html += ` | |
| <div class="filter-search"> | |
| <span class="filter-search-icon"><i class="fas fa-search"></i></span> | |
| <input type="text" id="treeFilterInput" placeholder="${placeholder}" value="${escapeHtml(state.filterText)}"> | |
| </div> | |
| `; | |
| els.filterBar.innerHTML = html; | |
| els.filterBar.querySelectorAll('.filter-pill').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| state.activeSort = btn.dataset.sort; | |
| renderFilters(); | |
| renderTree(); | |
| }); | |
| }); | |
| const inp = $('treeFilterInput'); | |
| if (inp) { | |
| inp.addEventListener('input', e => { | |
| state.filterText = e.target.value.trim().toLowerCase(); | |
| renderTree(); | |
| }); | |
| } | |
| } | |
| function makeAsciiBar(val, maxVal, width = 12) { | |
| if (maxVal <= 0) return 'β'.repeat(width); | |
| const filled = Math.round((val / maxVal) * width); | |
| return 'β'.repeat(filled) + 'β'.repeat(Math.max(0, width - filled)); | |
| } | |
| function renderTree() { | |
| const tab = state.activeTab; | |
| els.lineNumbers.innerHTML = ''; | |
| els.treeContent.innerHTML = ''; | |
| const fragL = document.createDocumentFragment(); | |
| const fragT = document.createDocumentFragment(); | |
| if (tab === 'daily') { | |
| const items = getFilteredDailyItems(); | |
| els.treeTitle.textContent = `${state.targetId} / daily-likes-log (${items.length} days)`; | |
| if (!items.length) { | |
| els.treeContent.innerHTML = '<div class="no-data">no matching dates in daily log.</div>'; | |
| return; | |
| } | |
| const maxDaily = Math.max(1, ...state.dailySeries.map(d => d.total)); | |
| const lHeader = document.createElement('div'); | |
| lHeader.textContent = '1'; | |
| fragL.appendChild(lHeader); | |
| const rHeader = document.createElement('div'); | |
| rHeader.className = 'tree-line'; | |
| rHeader.style.fontWeight = '700'; | |
| rHeader.innerHTML = `<span class="t-name">${escapeHtml(state.targetId)} β Daily Like Telemetry (${items.length} days)</span>`; | |
| fragT.appendChild(rHeader); | |
| items.forEach((d, idx) => { | |
| const ln = document.createElement('div'); | |
| ln.textContent = idx + 2; | |
| fragL.appendChild(ln); | |
| const row = document.createElement('div'); | |
| row.className = 'tree-line'; | |
| const conn = idx === items.length - 1 ? 'βββ ' : 'βββ '; | |
| const bar = makeAsciiBar(d.total, maxDaily, 14); | |
| let breakdownMeta = ''; | |
| if (state.mode === 'owner') { | |
| breakdownMeta = `<span class="t-stat" title="Models">[M: +${d.models}]</span><span class="t-stat" title="Spaces">[S: +${d.spaces}]</span><span class="t-stat" title="Datasets">[D: +${d.datasets}]</span>`; | |
| } | |
| row.innerHTML = `<span class="t-prefix">${conn}</span><span class="t-name">${d.date}</span><span class="t-bar">${bar}</span><span class="t-meta">${breakdownMeta}<span class="t-stat ${d.total > 0 ? 'highlight' : ''}"><i class="fas fa-heart"></i> +${formatNumFull(d.total)}</span><span class="t-stat">Ξ£ ${formatNumFull(d.cumulative)}</span></span>`; | |
| fragT.appendChild(row); | |
| }); | |
| } else if (tab === 'repos') { | |
| let items = [...state.ownerRepos]; | |
| if (state.filterText) { | |
| items = items.filter(r => (r._id || '').toLowerCase().includes(state.filterText)); | |
| } | |
| if (state.activeSort === 'models-only') items = items.filter(r => r._type === 'model'); | |
| else if (state.activeSort === 'spaces-only') items = items.filter(r => r._type === 'space'); | |
| else if (state.activeSort === 'datasets-only') items = items.filter(r => r._type === 'dataset'); | |
| else if (state.activeSort === 'recent') { | |
| items.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); | |
| } else { | |
| items.sort((a, b) => (b.likes || 0) - (a.likes || 0)); | |
| } | |
| els.treeTitle.textContent = `${state.ownerName} / repositories (${items.length})`; | |
| if (!items.length) { | |
| els.treeContent.innerHTML = '<div class="no-data">no repositories found for this filter.</div>'; | |
| return; | |
| } | |
| const lHeader = document.createElement('div'); | |
| lHeader.textContent = '1'; | |
| fragL.appendChild(lHeader); | |
| const rHeader = document.createElement('div'); | |
| rHeader.className = 'tree-line'; | |
| rHeader.style.fontWeight = '700'; | |
| rHeader.innerHTML = `<span class="t-name">${escapeHtml(state.ownerName)} β ${items.length} Repositories</span>`; | |
| fragT.appendChild(rHeader); | |
| items.forEach((r, idx) => { | |
| const ln = document.createElement('div'); | |
| ln.textContent = idx + 2; | |
| fragL.appendChild(ln); | |
| const row = document.createElement('div'); | |
| row.className = 'tree-line'; | |
| const conn = idx === items.length - 1 ? 'βββ ' : 'βββ '; | |
| const pub = r.createdAt ? new Date(r.createdAt).toISOString().slice(0, 10) : 'β'; | |
| const href = r._type === 'space' | |
| ? `${HF_BASE}/spaces/${r._id}` | |
| : r._type === 'dataset' | |
| ? `${HF_BASE}/datasets/${r._id}` | |
| : `${HF_BASE}/${r._id}`; | |
| const qPath = r._type === 'space' | |
| ? `spaces/${r._id}` | |
| : r._type === 'dataset' | |
| ? `datasets/${r._id}` | |
| : r._id; | |
| row.innerHTML = `<span class="t-prefix">${conn}</span><span class="t-name"><a href="${href}" target="_blank" rel="noopener">${escapeHtml(r._id)}</a></span><span class="t-meta"><span class="t-stat">[${r._type.toUpperCase()}]</span><span class="t-stat"><i class="far fa-calendar"></i> ${pub}</span><span class="t-stat highlight"><i class="fas fa-heart"></i> ${formatNumFull(r.likes || 0)}</span><button class="t-action-btn" data-graph-target="${escapeHtml(qPath)}"><i class="fas fa-chart-bar"></i> like-graph</button></span>`; | |
| fragT.appendChild(row); | |
| }); | |
| } | |
| els.lineNumbers.appendChild(fragL); | |
| els.treeContent.appendChild(fragT); | |
| } | |
| /* ββ Monochrome Terminal PNG Snapshot of Selected Daily Like Logs ββ */ | |
| function exportSelectedLogsPNG() { | |
| if (!state.dailySeries.length) { | |
| return showMsg('error: execute a query first before snapping the daily likes log.', 'error'); | |
| } | |
| const snapLimit = parseInt(els.snapCountSelect.value, 10) || 5; | |
| const allFiltered = getFilteredDailyItems(); | |
| const slicedLogs = allFiltered.slice(0, snapLimit); | |
| if (!slicedLogs.length) { | |
| return showMsg('error: no daily log entries available to snapshot.', 'error'); | |
| } | |
| const maxDaily = Math.max(1, ...state.dailySeries.map(d => d.total)); | |
| const totalRows = slicedLogs.length + 1; | |
| const rowH = 34; | |
| const winH = totalRows * rowH + 16; | |
| const W = 880; | |
| const headH = 40; | |
| const cardX = 16; | |
| const cardY = 16; | |
| const cardW = W - 32; | |
| const cardH = headH + winH; | |
| const H = cardH + 32; | |
| const dpr = 2; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = W * dpr; | |
| canvas.height = H * dpr; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| // Outer black canvas | |
| ctx.fillStyle = '#000000'; | |
| ctx.fillRect(0, 0, W, H); | |
| // Terminal wrapper border | |
| ctx.fillStyle = '#000000'; | |
| ctx.fillRect(cardX, cardY, cardW, cardH); | |
| ctx.strokeStyle = '#222222'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(cardX, cardY, cardW, cardH); | |
| // Header bar | |
| ctx.fillStyle = '#0a0a0a'; | |
| ctx.fillRect(cardX, cardY, cardW, headH); | |
| ctx.beginPath(); | |
| ctx.moveTo(cardX, cardY + headH); | |
| ctx.lineTo(cardX + cardW, cardY + headH); | |
| ctx.strokeStyle = '#222222'; | |
| ctx.stroke(); | |
| // Title dot + title text | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.beginPath(); | |
| ctx.arc(cardX + 18, cardY + headH / 2, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#888888'; | |
| ctx.font = '600 12px "JetBrains Mono", monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(`${state.targetId} / daily-likes-log (first ${slicedLogs.length} rows)`, cardX + 30, cardY + headH / 2); | |
| ctx.fillStyle = '#444444'; | |
| ctx.font = '500 11px "JetBrains Mono", monospace'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText('[like-graph]', cardX + cardW - 14, cardY + headH / 2); | |
| const winX = cardX; | |
| const winY = cardY + headH; | |
| const gutterW = 48; | |
| // Gutter divider | |
| ctx.strokeStyle = '#1a1a1a'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(winX + gutterW, winY); | |
| ctx.lineTo(winX + gutterW, winY + winH); | |
| ctx.stroke(); | |
| for (let r = 0; r < totalRows; r++) { | |
| const ry = winY + 8 + r * rowH; | |
| const cy = ry + rowH / 2; | |
| // Line number | |
| ctx.fillStyle = '#333333'; | |
| ctx.font = '500 11px "JetBrains Mono", monospace'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(String(r + 1), winX + gutterW - 10, cy); | |
| const codeX = winX + gutterW + 14; | |
| if (r === 0) { | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = '700 12.5px "JetBrains Mono", monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(`${state.targetId} β Daily Like Telemetry (First ${slicedLogs.length} of ${allFiltered.length} days)`, codeX, cy); | |
| } else { | |
| const item = slicedLogs[r - 1]; | |
| const isLast = r === totalRows - 1; | |
| const conn = isLast ? 'βββ ' : 'βββ '; | |
| const bar = makeAsciiBar(item.total, maxDaily, 14); | |
| ctx.textAlign = 'left'; | |
| ctx.font = '600 12.5px "JetBrains Mono", monospace'; | |
| ctx.fillStyle = '#444444'; | |
| ctx.fillText(conn, codeX, cy); | |
| const connW = ctx.measureText(conn).width; | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText(item.date, codeX + connW, cy); | |
| const dateW = ctx.measureText(item.date).width; | |
| ctx.fillStyle = '#666666'; | |
| ctx.fillText(bar, codeX + connW + dateW + 14, cy); | |
| let rightCursor = cardX + cardW - 16; | |
| ctx.font = '500 11.5px "JetBrains Mono", monospace'; | |
| const cumText = `Ξ£ ${formatNumFull(item.cumulative)}`; | |
| const cumW = ctx.measureText(cumText).width; | |
| ctx.fillStyle = '#666666'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(cumText, rightCursor, cy); | |
| rightCursor -= (cumW + 14); | |
| const likeText = `β₯ +${formatNumFull(item.total)}`; | |
| const likeTextW = ctx.measureText(likeText).width; | |
| const pillW = likeTextW + 12; | |
| const pillH = 20; | |
| const pillX = rightCursor - pillW; | |
| const pillY = cy - pillH / 2; | |
| if (item.total > 0) { | |
| ctx.fillStyle = '#141414'; | |
| ctx.fillRect(pillX, pillY, pillW, pillH); | |
| ctx.strokeStyle = '#2a2a2a'; | |
| ctx.strokeRect(pillX, pillY, pillW, pillH); | |
| ctx.fillStyle = '#ffffff'; | |
| } else { | |
| ctx.fillStyle = '#555555'; | |
| } | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(likeText, pillX + pillW / 2, cy); | |
| rightCursor = pillX - 14; | |
| if (state.mode === 'owner') { | |
| const brkText = `[M:+${item.models} S:+${item.spaces} D:+${item.datasets}]`; | |
| ctx.textAlign = 'right'; | |
| ctx.fillStyle = '#555555'; | |
| ctx.fillText(brkText, rightCursor, cy); | |
| } | |
| } | |
| } | |
| const url = canvas.toDataURL('image/png'); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `daily-likes-first${slicedLogs.length}-${state.targetId.replace(/\//g, '__')}.png`; | |
| a.click(); | |
| const currentLimit = parseInt(els.snapCountSelect.value, 10) || 5; | |
| els.snapLogBtn.innerHTML = '<i class="fas fa-check"></i> snapped!'; | |
| setTimeout(() => { | |
| els.snapLogBtn.innerHTML = `<i class="fas fa-camera"></i> png snap (first ${currentLimit})`; | |
| }, 1600); | |
| } | |
| /* ββ Export Utilities (Copy Tree, CSV, PNG, PDF Report) ββ */ | |
| function copyCurrentTree() { | |
| const lines = []; | |
| if (state.activeTab === 'repos') { | |
| lines.push(`${state.ownerName} β ${state.ownerRepos.length} Repositories`); | |
| state.ownerRepos.forEach((r, idx, arr) => { | |
| const conn = idx === arr.length - 1 ? 'βββ ' : 'βββ '; | |
| lines.push(`${conn}${r._id} [${r._type}] (β₯ ${r.likes || 0})`); | |
| }); | |
| } else { | |
| lines.push(`${state.targetId} (${state.repoType.toUpperCase()}) β Like-Graph Telemetry`); | |
| state.dailySeries.slice(-60).forEach((d, idx, arr) => { | |
| const conn = idx === arr.length - 1 ? 'βββ ' : 'βββ '; | |
| lines.push(`${conn}${d.date}: +${d.total} likes (cumulative: ${d.cumulative})`); | |
| }); | |
| } | |
| navigator.clipboard.writeText(lines.join('\n')).then(() => { | |
| const orig = els.copyTreeBtn.innerHTML; | |
| els.copyTreeBtn.innerHTML = '<i class="fas fa-check"></i> copied!'; | |
| setTimeout(() => { els.copyTreeBtn.innerHTML = orig; }, 1600); | |
| }); | |
| } | |
| function exportCSV() { | |
| if (!state.dailySeries.length) { | |
| return showMsg('error: execute a query first before exporting csv.', 'error'); | |
| } | |
| const rows = [['Date', 'Daily_Likes_Polled', 'Cumulative_Likes', 'Models_Likes', 'Spaces_Likes', 'Datasets_Likes']]; | |
| state.dailySeries.forEach(d => { | |
| rows.push([d.date, d.total, d.cumulative, d.models, d.spaces, d.datasets]); | |
| }); | |
| const csv = rows.map(r => r.join(',')).join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `like-graph-${state.targetId.replace(/\//g, '__')}.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function exportGraphPNG() { | |
| if (!state.dailySeries.length) return; | |
| const url = els.likeCanvas.toDataURL('image/png'); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `like-graph-${state.targetId.replace(/\//g, '__')}.png`; | |
| a.click(); | |
| } | |
| async function downloadPDFReport() { | |
| if (!state.dailySeries.length) { | |
| showMsg('error: execute a query first to generate a pdf report.', 'error'); | |
| setTimeout(() => showMsg('', ''), 2000); | |
| return; | |
| } | |
| const btn = els.pdfBtn; | |
| const orig = btn.innerHTML; | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> building...'; | |
| btn.disabled = true; | |
| try { | |
| const { jsPDF } = window.jspdf; | |
| const doc = new jsPDF(); | |
| const series = state.dailySeries; | |
| const totalLikes = series[series.length - 1]?.cumulative || 0; | |
| const pubDate = series[0]?.date || 'β'; | |
| const avgDaily = (totalLikes / Math.max(1, series.length)).toFixed(2); | |
| let peak = { date: 'β', total: 0 }; | |
| series.forEach(d => { if (d.total >= peak.total) peak = d; }); | |
| doc.setFontSize(18); | |
| doc.text(`Hugging Face Like-Graph Report: ${state.targetId}`, 14, 20); | |
| const overviewBody = [ | |
| ['Target Path / Handle', state.targetId, 'Resource Type', state.repoType.toUpperCase()], | |
| ['Published Date', pubDate, 'Days Tracked', `${series.length} days`], | |
| ['Total Likes Polled', formatNumFull(totalLikes), 'Avg Likes / Day', `+${avgDaily} / day`], | |
| ['Peak Daily Likes', `+${formatNumFull(peak.total)} (${peak.date})`, 'Owner / Organization', `@${state.ownerName}`], | |
| ]; | |
| doc.autoTable({ | |
| startY: 26, | |
| head: [['Telemetry Metric', 'Value', 'Telemetry Metric', 'Value']], | |
| body: overviewBody, | |
| theme: 'grid', | |
| styles: { fontSize: 9, cellPadding: 3 }, | |
| headStyles: { fillColor: [20, 20, 20], textColor: [255, 255, 255], fontStyle: 'bold' }, | |
| alternateRowStyles: { fillColor: [248, 248, 248] }, | |
| }); | |
| let y = doc.lastAutoTable.finalY + 8; | |
| const chartImg = els.likeCanvas.toDataURL('image/png'); | |
| doc.setFontSize(11); | |
| doc.text('Daily & Cumulative Likes Graph:', 14, y); | |
| doc.addImage(chartImg, 'PNG', 14, y + 3, 182, 62); | |
| y += 72; | |
| const topDays = [...series].sort((a, b) => b.total - a.total).slice(0, 35); | |
| doc.autoTable({ | |
| startY: y, | |
| head: [['Date', 'Daily Likes Polled', 'Cumulative Likes', 'Models', 'Spaces', 'Datasets']], | |
| body: topDays.map(d => [ | |
| d.date, | |
| `+${formatNumFull(d.total)}`, | |
| formatNumFull(d.cumulative), | |
| formatNumFull(d.models), | |
| formatNumFull(d.spaces), | |
| formatNumFull(d.datasets), | |
| ]), | |
| theme: 'striped', | |
| styles: { fontSize: 8.5 }, | |
| headStyles: { fillColor: [40, 40, 40], textColor: [255, 255, 255], fontStyle: 'bold' }, | |
| alternateRowStyles: { fillColor: [245, 245, 245] }, | |
| }); | |
| doc.save(`like-graph-${state.targetId.replace(/\//g, '__')}-${new Date().toISOString().slice(0, 10)}.pdf`); | |
| btn.innerHTML = '<i class="fas fa-check"></i> saved'; | |
| } catch (err) { | |
| console.error(err); | |
| btn.innerHTML = '<i class="fas fa-times"></i> error'; | |
| } finally { | |
| setTimeout(() => { | |
| btn.innerHTML = orig; | |
| btn.disabled = false; | |
| }, 1800); | |
| } | |
| } | |
| function buildHomepage() { | |
| const repoCloud = $('featuredRepos'); | |
| FEATURED_REPOS.forEach(item => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'user-chip'; | |
| btn.innerHTML = `<span class="chip-tag">${escapeHtml(item.tag)}</span> ${escapeHtml(item.id)}`; | |
| btn.addEventListener('click', () => { | |
| els.targetInput.value = item.id; | |
| els.typeSelect.value = 'auto'; | |
| executeQuery(); | |
| }); | |
| repoCloud.appendChild(btn); | |
| }); | |
| const orgCloud = $('featuredOrgs'); | |
| FEATURED_ORGS.forEach(org => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'user-chip'; | |
| btn.textContent = org; | |
| btn.addEventListener('click', () => { | |
| els.targetInput.value = org; | |
| els.typeSelect.value = 'org'; | |
| executeQuery(); | |
| }); | |
| orgCloud.appendChild(btn); | |
| }); | |
| } | |
| function parseHash() { | |
| const h = window.location.hash; | |
| if (h && h.length > 1) { | |
| const decoded = decodeURIComponent(h.substring(1)); | |
| if (decoded && decoded !== state.targetId) { | |
| els.targetInput.value = decoded; | |
| els.typeSelect.value = 'auto'; | |
| executeQuery(); | |
| } | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| checkToken(); | |
| buildHomepage(); | |
| els.homeLogo.addEventListener('click', () => { | |
| window.location.hash = ''; | |
| state.targetId = ''; | |
| els.resultsSection.classList.add('hidden'); | |
| els.emptyState.classList.remove('hidden'); | |
| }); | |
| els.privateRepoBtn.addEventListener('click', () => els.tokenSection.classList.toggle('hidden')); | |
| els.saveTokenBtn.addEventListener('click', () => { | |
| const t = els.hfToken.value.trim(); | |
| if (!t) return; | |
| localStorage.setItem('hf_token', t); | |
| checkToken(); | |
| showMsg('token saved.', 'loading'); | |
| setTimeout(() => showMsg('', ''), 1400); | |
| }); | |
| els.clearTokenBtn.addEventListener('click', () => { | |
| localStorage.removeItem('hf_token'); | |
| els.hfToken.value = ''; | |
| checkToken(); | |
| showMsg('token cleared.', 'loading'); | |
| setTimeout(() => showMsg('', ''), 1400); | |
| }); | |
| els.hfToken.addEventListener('keypress', e => { if (e.key === 'Enter') els.saveTokenBtn.click(); }); | |
| els.fetchBtn.addEventListener('click', executeQuery); | |
| els.targetInput.addEventListener('keypress', e => { if (e.key === 'Enter') executeQuery(); }); | |
| document.querySelectorAll('[data-mode]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| state.chartMode = btn.dataset.mode; | |
| syncControlPills(); | |
| drawLikeGraph(); | |
| }); | |
| }); | |
| document.querySelectorAll('[data-range]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| state.chartRange = btn.dataset.range; | |
| state.hoverIndex = -1; | |
| syncControlPills(); | |
| drawLikeGraph(); | |
| }); | |
| }); | |
| els.likeCanvas.addEventListener('mousemove', handleCanvasMove); | |
| els.likeCanvas.addEventListener('mouseleave', () => { | |
| state.hoverIndex = -1; | |
| els.graphTooltip.style.display = 'none'; | |
| drawLikeGraph(); | |
| }); | |
| window.addEventListener('resize', () => { | |
| if (!els.resultsSection.classList.contains('hidden')) { | |
| drawLikeGraph(); | |
| } | |
| }); | |
| els.tabBar.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.addEventListener('click', () => setActiveTab(btn.dataset.tab)); | |
| }); | |
| els.treeContent.addEventListener('click', e => { | |
| const btn = e.target.closest('[data-graph-target]'); | |
| if (!btn) return; | |
| els.targetInput.value = btn.dataset.graphTarget; | |
| els.typeSelect.value = 'auto'; | |
| executeQuery(); | |
| }); | |
| els.snapCountSelect.addEventListener('change', () => { | |
| const count = parseInt(els.snapCountSelect.value, 10) || 5; | |
| els.snapLogBtn.innerHTML = `<i class="fas fa-camera"></i> png snap (first ${count})`; | |
| }); | |
| els.snapLogBtn.addEventListener('click', exportSelectedLogsPNG); | |
| els.copyTreeBtn.addEventListener('click', copyCurrentTree); | |
| els.csvBtn.addEventListener('click', exportCSV); | |
| els.pdfBtn.addEventListener('click', downloadPDFReport); | |
| els.downloadPngBtn.addEventListener('click', exportGraphPNG); | |
| window.addEventListener('hashchange', parseHash); | |
| parseHash(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |