| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Download Gravity Field</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet"> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| html, body { width: 100%; height: 100%; overflow: hidden; background: #F6F4EF; } |
| canvas { display: block; } |
| #tooltip { |
| position: absolute; |
| pointer-events: none; |
| font: 14px/1.4 'SF Mono', 'Menlo', 'Consolas', monospace; |
| color: #161513; |
| text-align: center; |
| white-space: nowrap; |
| opacity: 0; |
| transition: opacity 0.2s; |
| } |
| #tooltip.visible { |
| opacity: 1; |
| pointer-events: auto; |
| } |
| #tooltip a { |
| color: #161513; |
| text-decoration: none; |
| } |
| #pinnedLabel { |
| position: absolute; |
| pointer-events: auto; |
| font: 14px/1.4 'SF Mono', 'Menlo', 'Consolas', monospace; |
| color: #161513; |
| text-align: center; |
| white-space: nowrap; |
| } |
| #pinnedLabel a { |
| color: #161513; |
| text-decoration: none; |
| } |
| |
| |
| #addModelBtn { |
| position: fixed; |
| top: 24px; |
| right: 24px; |
| z-index: 10; |
| } |
| #addPill { |
| display: inline-flex; |
| align-items: center; |
| background: #161513; |
| color: #F6F4EF; |
| border-radius: 999px; |
| height: 38px; |
| padding: 0 16px; |
| font: 500 13px/1 'Inter', sans-serif; |
| cursor: pointer; |
| white-space: nowrap; |
| user-select: none; |
| } |
| #addPill .label-text { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| flex-shrink: 0; |
| } |
| #addPill .label-text .hf-logo { |
| width: 20px; |
| height: 20px; |
| vertical-align: -3px; |
| } |
| #addPill .input-wrap { |
| display: none; |
| align-items: center; |
| gap: 6px; |
| } |
| #addPill.expanded { |
| padding: 0 5px 0 14px; |
| } |
| #addPill.expanded .input-wrap { |
| display: flex; |
| } |
| #addPill.expanded .label-text { |
| display: none; |
| } |
| #addPill input[type="text"] { |
| background: transparent; |
| border: none; |
| outline: none; |
| color: #F6F4EF; |
| font: 400 13px/1 'Inter', sans-serif; |
| width: 176px; |
| padding: 0; |
| caret-color: #E8820C; |
| } |
| #addPill input[type="text"]::placeholder { |
| color: rgba(246,244,239,0.4); |
| } |
| #addPill .add-btn { |
| flex-shrink: 0; |
| background: rgba(246,244,239,0.15); |
| color: #F6F4EF; |
| border: none; |
| border-radius: 999px; |
| height: 28px; |
| padding: 0 12px; |
| font: 500 12px/1 'Inter', sans-serif; |
| cursor: pointer; |
| transition: background 0.15s; |
| } |
| #addPill .add-btn:hover { |
| background: rgba(246,244,239,0.25); |
| } |
| #addPill .error-msg { |
| display: none; |
| color: #E8820C; |
| font-size: 11px; |
| margin-left: 2px; |
| margin-right: 10px; |
| white-space: nowrap; |
| } |
| |
| |
| #addPill.active { |
| cursor: pointer; |
| } |
| #addPill .active-wrap { |
| display: none; |
| align-items: center; |
| gap: 8px; |
| } |
| #addPill.active .active-wrap { |
| display: flex; |
| } |
| #addPill.active .label-text, |
| #addPill.active .input-wrap { |
| display: none; |
| } |
| #addPill .orange-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: #E8820C; |
| flex-shrink: 0; |
| } |
| #addPill .model-name { |
| font: 400 13px/1 'Inter', sans-serif; |
| color: #F6F4EF; |
| max-width: 180px; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| #addPill .remove-btn { |
| flex-shrink: 0; |
| background: rgba(246,244,239,0.15); |
| color: #F6F4EF; |
| border: none; |
| border-radius: 50%; |
| width: 20px; |
| height: 20px; |
| font-size: 14px; |
| line-height: 20px; |
| text-align: center; |
| cursor: pointer; |
| padding: 0; |
| transition: background 0.15s; |
| } |
| #addPill .remove-btn:hover { |
| background: rgba(232,130,12,0.4); |
| } |
| |
| |
| #addPill .spinner { |
| display: none; |
| width: 14px; |
| height: 14px; |
| border: 2px solid rgba(246,244,239,0.3); |
| border-top-color: #E8820C; |
| border-radius: 50%; |
| animation: pillSpin 0.6s linear infinite; |
| flex-shrink: 0; |
| margin-left: 4px; |
| margin-right: 10px; |
| } |
| #addPill.loading .spinner { |
| display: block; |
| } |
| #addPill.loading .add-btn { |
| display: none; |
| } |
| @keyframes pillSpin { |
| to { transform: rotate(360deg); } |
| } |
| |
| |
| #modelDropdown { |
| position: absolute; |
| top: calc(100% + 8px); |
| right: 0; |
| width: 380px; |
| max-height: 420px; |
| background: #161513; |
| border: 1px solid rgba(246,244,239,0.1); |
| border-radius: 12px; |
| overflow: hidden; |
| display: none; |
| flex-direction: column; |
| box-shadow: 0 16px 48px rgba(0,0,0,0.35); |
| z-index: 20; |
| } |
| #modelDropdown.open { |
| display: flex; |
| } |
| |
| .dropdown-search { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 10px 14px; |
| border-bottom: 1px solid rgba(246,244,239,0.08); |
| } |
| .dropdown-search svg { |
| width: 16px; |
| height: 16px; |
| flex-shrink: 0; |
| opacity: 0.4; |
| } |
| .dropdown-search input { |
| flex: 1; |
| background: transparent; |
| border: none; |
| outline: none; |
| color: #F6F4EF; |
| font: 400 13px/1 'Inter', sans-serif; |
| caret-color: #E8820C; |
| } |
| .dropdown-search input::placeholder { color: rgba(246,244,239,0.3); } |
| |
| .dropdown-content { |
| overflow-y: auto; |
| flex: 1; |
| padding: 4px 0; |
| } |
| |
| .dropdown-section-header { |
| padding: 10px 14px 4px; |
| font: 500 11px/1 'Inter', sans-serif; |
| color: #E8820C; |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| text-transform: uppercase; |
| letter-spacing: 0.03em; |
| } |
| |
| .dropdown-model { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 8px 14px; |
| cursor: pointer; |
| transition: background 0.1s; |
| color: rgba(246,244,239,0.85); |
| font: 400 13px/1 'Inter', sans-serif; |
| } |
| .dropdown-model:hover, .dropdown-model.selected { |
| background: rgba(246,244,239,0.07); |
| } |
| .dropdown-model img { |
| width: 24px; |
| height: 24px; |
| border-radius: 5px; |
| object-fit: cover; |
| background: rgba(246,244,239,0.08); |
| flex-shrink: 0; |
| } |
| .dropdown-avatar-placeholder { |
| width: 24px; |
| height: 24px; |
| border-radius: 5px; |
| background: rgba(246,244,239,0.06); |
| flex-shrink: 0; |
| } |
| |
| .dropdown-loading { |
| padding: 24px; |
| text-align: center; |
| } |
| .dropdown-spinner { |
| display: inline-block; |
| width: 16px; |
| height: 16px; |
| border: 2px solid rgba(246,244,239,0.12); |
| border-top-color: #E8820C; |
| border-radius: 50%; |
| animation: pillSpin 0.6s linear infinite; |
| } |
| .dropdown-empty { |
| padding: 24px 14px; |
| color: rgba(246,244,239,0.35); |
| text-align: center; |
| font: 400 13px/1 'Inter', sans-serif; |
| } |
| </style> |
| </head> |
| <body> |
| <canvas id="c"></canvas> |
| <div id="tooltip"></div> |
| <div id="pinnedLabel"></div> |
| <div id="addModelBtn"> |
| <div id="addPill"> |
| <span class="label-text">Add a model on <img class="hf-logo" src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo-pirate.svg" alt="HF"></span> |
| <div class="input-wrap"> |
| <input type="text" placeholder="owner/model-name" /> |
| <button class="add-btn">Add</button> |
| <span class="spinner"></span> |
| <span class="error-msg"></span> |
| </div> |
| <div class="active-wrap"> |
| <span class="orange-dot"></span> |
| <span class="model-name"></span> |
| <button class="remove-btn">×</button> |
| </div> |
| </div> |
| <div id="modelDropdown"> |
| <div class="dropdown-search"> |
| <svg viewBox="0 0 18 18" fill="none" stroke="rgba(246,244,239,0.5)" stroke-width="2" stroke-linecap="round"><circle cx="7.5" cy="7.5" r="5.5"/><line x1="11.5" y1="11.5" x2="16" y2="16"/></svg> |
| <input type="text" id="dropdownSearchInput" placeholder="Search models..." autocomplete="off" /> |
| </div> |
| <div class="dropdown-content" id="dropdownContent"></div> |
| </div> |
| </div> |
| <script> |
| (async function () { |
| |
| |
| const PARTICLE_COUNT = 4000; |
| const G = 1.5; |
| const BASE_ANGULAR = 0.02; |
| const COLOR = '#161513'; |
| const BG = '#F6F4EF'; |
| const ORANGE = '#E8820C'; |
| const CORE_RADIUS = 5; |
| const PARTICLE_SIZE = 1.2; |
| const MAX_OMEGA = 0.02; |
| const MAX_OMEGA_ABSORB = 0.04; |
| const TARGET_CAPTURE_OMEGA = 0.015; |
| |
| |
| const DRAG = 0.9998; |
| const SPEED_CAP = 6; |
| const NEAR_BOOST_RADIUS = 300; |
| const NEAR_BOOST_FACTOR = 1.2; |
| |
| |
| const SPIRAL_FRICTION = 0.0002; |
| const SPIRAL_FRICTION_VAR = 0.0001; |
| const OUTER_ORBIT_DURATION_BASE = 200; |
| const OUTER_ORBIT_DURATION_MASS = 400; |
| |
| |
| const CONSUMPTION_SIZE_RATE = 0.03; |
| |
| |
| const ABSORB_CHANCE_BASE = 0.04; |
| const ABSORB_CHANCE_MASS = 0.06; |
| const ABSORB_RADIAL_DRAIN = 0.01; |
| const ABSORB_INWARD_FORCE = 0.03; |
| const ABSORB_MIN_RADIUS = 8; |
| |
| |
| const CAPTURE_BLEND_FRAMES = 30; |
| |
| |
| const PRECESSION_RATE = 0.003; |
| const SLINGSHOT_BOOST = 1.15; |
| const SUBSTEP_SPEED_THRESHOLD = 3; |
| |
| |
| const PRE_SIM_FRAMES = 300; |
| |
| |
| const SEPARATION_PAD = 60; |
| const EDGE_MARGIN = 30; |
| |
| |
| const ELASTIC_ZONE_MULT = 1.8; |
| const ELASTIC_STIFFNESS = 0.06; |
| const ELASTIC_DAMPING = 0.79; |
| const ELASTIC_MAX_DISP = 30; |
| const ELASTIC_PULL_STRENGTH = 0.35; |
| const ELASTIC_FALLOFF_POW = 2; |
| const ELASTIC_SECONDARY = 0.3; |
| |
| |
| const canvas = document.getElementById('c'); |
| const ctx = canvas.getContext('2d'); |
| |
| const DPR = window.devicePixelRatio || 1; |
| |
| function resize() { |
| const w = window.innerWidth; |
| const h = window.innerHeight; |
| canvas.width = w * DPR; |
| canvas.height = h * DPR; |
| canvas.style.width = w + 'px'; |
| canvas.style.height = h + 'px'; |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); |
| } |
| window.addEventListener('resize', resize); |
| resize(); |
| |
| |
| const FALLBACK = [ |
| { id: 'hexgrad/Kokoro-82M', downloads: 8427252, task: 'text-to-speech' }, |
| { id: 'openai/gpt-oss-20b', downloads: 5541163, task: 'text-generation' }, |
| { id: 'openai/gpt-oss-120b', downloads: 3477982, task: 'text-generation' }, |
| { id: 'Lightricks/LTX-2', downloads: 2021784, task: 'image-to-video' }, |
| { id: 'zai-org/GLM-4.7-Flash', downloads: 1751035, task: 'text-generation' }, |
| { id: 'zai-org/GLM-OCR', downloads: 1240960, task: 'image-to-text' }, |
| { id: 'moonshotai/Kimi-K2.5', downloads: 1006690, task: 'image-text-to-text' }, |
| { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', downloads: 877971, task: 'text-to-speech' }, |
| { id: 'nvidia/personaplex-7b-v1', downloads: 509647, task: 'audio-to-audio' }, |
| { id: 'unsloth/GLM-4.7-Flash-GGUF', downloads: 395137, task: 'text-generation' } |
| ]; |
| |
| let models; |
| let allTrendingModels = []; |
| try { |
| |
| const res = await fetch('https://huggingface.co/api/models?sort=trendingScore&limit=50'); |
| if (!res.ok) throw new Error(res.status); |
| const data = await res.json(); |
| const all = data.map(m => { |
| const fullId = m.modelId || m.id; |
| const author = fullId.includes('/') ? fullId.split('/')[0] : ''; |
| return { |
| id: fullId, |
| downloads: m.downloads || 1, |
| task: m.pipeline_tag || 'unknown', |
| author: author, |
| avatarUrl: '' |
| }; |
| }); |
| |
| all.sort((a, b) => b.downloads - a.downloads); |
| allTrendingModels = all.slice(); |
| models = all.slice(0, 10); |
| } catch (e) { |
| models = FALLBACK.map(m => { |
| const author = m.id.includes('/') ? m.id.split('/')[0] : ''; |
| return { ...m, author: author, avatarUrl: '' }; |
| }); |
| allTrendingModels = models.slice(); |
| } |
| |
| |
| const uniqueAuthors = [...new Set(allTrendingModels.map(m => m.author).filter(Boolean))]; |
| const avatarMap = {}; |
| await Promise.all(uniqueAuthors.map(async (author) => { |
| try { |
| const r = await fetch('https://huggingface.co/api/organizations/' + author + '/avatar'); |
| if (r.ok) { |
| const json = await r.json(); |
| if (json.avatarUrl) avatarMap[author] = json.avatarUrl; |
| } |
| } catch (_) {} |
| })); |
| for (const m of models) { |
| if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author]; |
| } |
| for (const m of allTrendingModels) { |
| if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author]; |
| } |
| |
| |
| const D = models.map(m => m.downloads); |
| const D1 = D[0]; |
| const D2 = D[1]; |
| const dominanceRatio = D1 / D2; |
| |
| const massBase = D.map(d => Math.log(d)); |
| const exponent = dominanceRatio >= 1.25 ? 1.15 : 0.95; |
| const massArr = massBase.map(mb => Math.pow(mb, exponent)); |
| |
| const massMin = Math.min(...massArr); |
| const massMax = Math.max(...massArr); |
| const massRange = massMax - massMin || 1; |
| let massNorm = massArr.map(m => (m - massMin) / massRange); |
| |
| |
| const BASE_MODEL_COUNT = models.length; |
| let activeModelCount = BASE_MODEL_COUNT; |
| const CUSTOM_MODEL_IDX = BASE_MODEL_COUNT; |
| let customModelActive = false; |
| |
| const modelOrbitBase = new Float64Array(BASE_MODEL_COUNT + 1); |
| const modelCaptureRadius = new Float64Array(BASE_MODEL_COUNT + 1); |
| for (let i = 0; i < activeModelCount; i++) { |
| modelOrbitBase[i] = 30 + 180 * massNorm[i]; |
| modelCaptureRadius[i] = modelOrbitBase[i] * 1.2; |
| } |
| |
| |
| let totalMassNorm = massNorm.reduce((a, b) => a + b, 0); |
| const modelOrbitCount = new Float64Array(BASE_MODEL_COUNT + 1); |
| const modelTargetPop = new Float64Array(BASE_MODEL_COUNT + 1); |
| const spawnWeight = new Float64Array(BASE_MODEL_COUNT + 1); |
| let totalSpawnWeight = totalMassNorm; |
| let rebalanceTimer = 0; |
| |
| const TARGET_ORBIT_FRACTION = 0.7; |
| const totalTarget = PARTICLE_COUNT * TARGET_ORBIT_FRACTION; |
| for (let j = 0; j < activeModelCount; j++) { |
| modelTargetPop[j] = totalTarget * (massNorm[j] / totalMassNorm); |
| spawnWeight[j] = massNorm[j]; |
| } |
| |
| |
| function computePositions() { |
| const cx = (canvas.width / DPR) / 2; |
| const cy = (canvas.height / DPR) / 2; |
| const maxR = Math.min(cx, cy) * 0.72; |
| const positions = []; |
| |
| |
| const goldenAngle = Math.PI * (3 - Math.sqrt(5)); |
| |
| for (let i = 0; i < activeModelCount; i++) { |
| |
| |
| |
| const radialT = 1 - massNorm[i]; |
| const r = maxR * (0.05 + 0.95 * Math.sqrt(radialT)); |
| |
| const angle = goldenAngle * i; |
| |
| positions.push({ |
| x: cx + r * Math.cos(angle), |
| y: cy + r * Math.sin(angle) |
| }); |
| } |
| |
| |
| for (let iter = 0; iter < 200; iter++) { |
| let moved = false; |
| for (let a = 0; a < activeModelCount; a++) { |
| for (let b = a + 1; b < activeModelCount; b++) { |
| const dx = positions[b].x - positions[a].x; |
| const dy = positions[b].y - positions[a].y; |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; |
| const minDist = modelCaptureRadius[a] + modelCaptureRadius[b] + SEPARATION_PAD; |
| if (dist < minDist) { |
| const overlap = (minDist - dist) / 2; |
| const nx = dx / dist; |
| const ny = dy / dist; |
| |
| const wA = 1 - massNorm[a] * 0.7; |
| const wB = 1 - massNorm[b] * 0.7; |
| const total = wA + wB; |
| positions[a].x -= nx * overlap * (wA / total); |
| positions[a].y -= ny * overlap * (wA / total); |
| positions[b].x += nx * overlap * (wB / total); |
| positions[b].y += ny * overlap * (wB / total); |
| moved = true; |
| } |
| } |
| } |
| |
| |
| |
| for (let i = 0; i < activeModelCount; i++) { |
| const dx = positions[i].x - cx; |
| const dy = positions[i].y - cy; |
| const currentR = Math.sqrt(dx * dx + dy * dy) || 1; |
| const radialT = 1 - massNorm[i]; |
| const idealR = maxR * (0.05 + 0.95 * Math.sqrt(radialT)); |
| const pullStrength = 0.1; |
| const targetR = currentR + (idealR - currentR) * pullStrength; |
| if (currentR > 0.1) { |
| positions[i].x = cx + (dx / currentR) * targetR; |
| positions[i].y = cy + (dy / currentR) * targetR; |
| } |
| } |
| |
| |
| for (let i = 0; i < activeModelCount; i++) { |
| const r = modelCaptureRadius[i]; |
| positions[i].x = Math.max(r + EDGE_MARGIN, Math.min(canvas.width / DPR - r - EDGE_MARGIN, positions[i].x)); |
| positions[i].y = Math.max(r + EDGE_MARGIN, Math.min(canvas.height / DPR - r - EDGE_MARGIN, positions[i].y)); |
| } |
| if (!moved) break; |
| } |
| |
| return positions; |
| } |
| |
| |
| function findOpenPosition(idx) { |
| const w = canvas.width / DPR; |
| const h = canvas.height / DPR; |
| const cx = w / 2, cy = h / 2; |
| const myR = modelCaptureRadius[idx]; |
| const margin = myR + EDGE_MARGIN; |
| const cols = 20, rows = 20; |
| let bestX = cx, bestY = cy, bestScore = -Infinity; |
| const maxDist = Math.sqrt(cx * cx + cy * cy); |
| |
| for (let r = 0; r < rows; r++) { |
| for (let c = 0; c < cols; c++) { |
| const px = margin + (w - 2 * margin) * (c / (cols - 1)); |
| const py = margin + (h - 2 * margin) * (r / (rows - 1)); |
| |
| let minClear = Infinity; |
| for (let j = 0; j < activeModelCount; j++) { |
| if (j === idx) continue; |
| const dx = px - positions[j].x, dy = py - positions[j].y; |
| const dist = Math.sqrt(dx * dx + dy * dy) - modelCaptureRadius[j] - myR; |
| if (dist < minClear) minClear = dist; |
| } |
| |
| const centerScore = 1 - Math.sqrt((px - cx) ** 2 + (py - cy) ** 2) / maxDist; |
| const clearScore = Math.max(0, Math.min(minClear / 200, 1)); |
| const score = centerScore * 0.7 + clearScore * 0.3; |
| if (score > bestScore) { |
| bestScore = score; |
| bestX = px; bestY = py; |
| } |
| } |
| } |
| return { x: bestX, y: bestY }; |
| } |
| |
| |
| |
| |
| function separateInPlace() { |
| const cx = (canvas.width / DPR) / 2; |
| const cy = (canvas.height / DPR) / 2; |
| |
| const result = []; |
| for (let i = 0; i < activeModelCount; i++) { |
| result.push({ x: positions[i].x, y: positions[i].y }); |
| } |
| |
| |
| for (let iter = 0; iter < 200; iter++) { |
| let moved = false; |
| for (let a = 0; a < activeModelCount; a++) { |
| for (let b = a + 1; b < activeModelCount; b++) { |
| const dx = result[b].x - result[a].x; |
| const dy = result[b].y - result[a].y; |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; |
| const minDist = modelCaptureRadius[a] + modelCaptureRadius[b] + SEPARATION_PAD; |
| if (dist < minDist) { |
| const overlap = (minDist - dist) / 2; |
| const nx = dx / dist; |
| const ny = dy / dist; |
| const wA = 1 - massNorm[a] * 0.7; |
| const wB = 1 - massNorm[b] * 0.7; |
| const total = wA + wB; |
| result[a].x -= nx * overlap * (wA / total); |
| result[a].y -= ny * overlap * (wA / total); |
| result[b].x += nx * overlap * (wB / total); |
| result[b].y += ny * overlap * (wB / total); |
| moved = true; |
| } |
| } |
| } |
| |
| for (let i = 0; i < activeModelCount; i++) { |
| const r = modelCaptureRadius[i]; |
| result[i].x = Math.max(r + EDGE_MARGIN, Math.min(canvas.width / DPR - r - EDGE_MARGIN, result[i].x)); |
| result[i].y = Math.max(r + EDGE_MARGIN, Math.min(canvas.height / DPR - r - EDGE_MARGIN, result[i].y)); |
| } |
| if (!moved) break; |
| } |
| return result; |
| } |
| |
| let positions = computePositions(); |
| |
| |
| const md = []; |
| for (let i = 0; i < activeModelCount; i++) { |
| md.push({ |
| x: positions[i].x, |
| y: positions[i].y, |
| massNorm: massNorm[i], |
| captureRadius: modelCaptureRadius[i] |
| }); |
| } |
| |
| function syncModelData() { |
| for (let i = 0; i < activeModelCount; i++) { |
| md[i].x = positions[i].x; |
| md[i].y = positions[i].y; |
| } |
| } |
| |
| |
| const layoutTargetX = new Float64Array(BASE_MODEL_COUNT + 1); |
| const layoutTargetY = new Float64Array(BASE_MODEL_COUNT + 1); |
| const layoutActive = new Uint8Array(BASE_MODEL_COUNT + 1); |
| const LAYOUT_LERP = 0.07; |
| |
| |
| const preInsertX = new Float64Array(BASE_MODEL_COUNT); |
| const preInsertY = new Float64Array(BASE_MODEL_COUNT); |
| |
| |
| let orbitScale = 1.0; |
| let orbitScaleTarget = 1.0; |
| const ORBIT_SCALE_LERP = 0.05; |
| |
| |
| const orbitBaseOriginal = new Float64Array(BASE_MODEL_COUNT + 1); |
| for (let i = 0; i < BASE_MODEL_COUNT; i++) { |
| orbitBaseOriginal[i] = modelOrbitBase[i]; |
| } |
| |
| function applyOrbitScale(scale) { |
| for (let j = 0; j < activeModelCount; j++) { |
| modelOrbitBase[j] = orbitBaseOriginal[j] * scale; |
| modelCaptureRadius[j] = modelOrbitBase[j] * 1.2; |
| md[j].captureRadius = modelCaptureRadius[j]; |
| } |
| } |
| |
| function updateLayoutAnimation() { |
| |
| if (Math.abs(orbitScale - orbitScaleTarget) > 0.001) { |
| orbitScale += (orbitScaleTarget - orbitScale) * ORBIT_SCALE_LERP; |
| applyOrbitScale(orbitScale); |
| } |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| if (!layoutActive[j]) continue; |
| const dx = layoutTargetX[j] - positions[j].x; |
| const dy = layoutTargetY[j] - positions[j].y; |
| if (dx * dx + dy * dy < 1) { |
| positions[j].x = layoutTargetX[j]; |
| positions[j].y = layoutTargetY[j]; |
| layoutActive[j] = 0; |
| } else { |
| positions[j].x += dx * LAYOUT_LERP; |
| positions[j].y += dy * LAYOUT_LERP; |
| } |
| } |
| } |
| |
| |
| let customPopScale = 0; |
| let customPopTarget = 0; |
| let customPopVelocity = 0; |
| const POP_STIFFNESS = 0.08; |
| const POP_DAMPING = 0.72; |
| |
| function updatePopAnimation() { |
| if (!customModelActive && customPopScale <= 0.001) return; |
| const force = (customPopTarget - customPopScale) * POP_STIFFNESS; |
| customPopVelocity = customPopVelocity * POP_DAMPING + force; |
| customPopScale += customPopVelocity; |
| if (customPopScale < 0) { customPopScale = 0; customPopVelocity = 0; } |
| |
| if (customModelActive && customPopTarget === 1) { |
| const idx = CUSTOM_MODEL_IDX; |
| const baseR = orbitBaseOriginal[idx] * orbitScale; |
| modelOrbitBase[idx] = baseR; |
| modelCaptureRadius[idx] = baseR * 1.2 * customPopScale; |
| md[idx].captureRadius = modelCaptureRadius[idx]; |
| } |
| |
| if (customPopTarget === 0 && customPopScale < 0.001 && Math.abs(customPopVelocity) < 0.001) { |
| customPopScale = 0; |
| customPopVelocity = 0; |
| finalizeRemoval(); |
| } |
| } |
| |
| function finalizeRemoval() { |
| |
| const cidx = CUSTOM_MODEL_IDX; |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if (p.attractorIdx === cidx) { |
| p.phase = 0; |
| p.attractorIdx = -1; |
| } |
| } |
| activeModelCount = BASE_MODEL_COUNT; |
| customModelActive = false; |
| totalMassNorm = 0; |
| for (let j = 0; j < activeModelCount; j++) totalMassNorm += massNorm[j]; |
| const totalTgt = PARTICLE_COUNT * TARGET_ORBIT_FRACTION; |
| for (let j = 0; j < activeModelCount; j++) { |
| modelTargetPop[j] = totalTgt * (massNorm[j] / totalMassNorm); |
| spawnWeight[j] = massNorm[j]; |
| } |
| totalSpawnWeight = totalMassNorm; |
| |
| orbitScaleTarget = 1.0; |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| const dx = preInsertX[j] - positions[j].x; |
| const dy = preInsertY[j] - positions[j].y; |
| if (dx * dx + dy * dy > 1) { |
| layoutTargetX[j] = preInsertX[j]; |
| layoutTargetY[j] = preInsertY[j]; |
| layoutActive[j] = 1; |
| } else { |
| positions[j].x = preInsertX[j]; |
| positions[j].y = preInsertY[j]; |
| } |
| } |
| computeNeighbors(); |
| } |
| |
| |
| const elasticDx = new Float64Array(BASE_MODEL_COUNT + 1); |
| const elasticDy = new Float64Array(BASE_MODEL_COUNT + 1); |
| const elasticVx = new Float64Array(BASE_MODEL_COUNT + 1); |
| const elasticVy = new Float64Array(BASE_MODEL_COUNT + 1); |
| |
| let cursorX = -9999; |
| let cursorY = -9999; |
| let cursorOnCanvas = false; |
| |
| function updateElasticPull() { |
| for (let j = 0; j < activeModelCount; j++) { |
| let targetDx = 0, targetDy = 0; |
| |
| if (cursorOnCanvas) { |
| const restX = positions[j].x, restY = positions[j].y; |
| const dx = cursorX - restX, dy = cursorY - restY; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| const zone = modelCaptureRadius[j] * ELASTIC_ZONE_MULT; |
| |
| if (dist < zone && dist > 1) { |
| const t = dist / zone; |
| const falloff = 1 - Math.pow(t, ELASTIC_FALLOFF_POW); |
| const nx = dx / dist, ny = dy / dist; |
| let pull = ELASTIC_PULL_STRENGTH * falloff * dist; |
| |
| |
| let isClosest = true; |
| for (let k = 0; k < activeModelCount; k++) { |
| if (k === j) continue; |
| const dxk = cursorX - positions[k].x, dyk = cursorY - positions[k].y; |
| if (dxk * dxk + dyk * dyk < dist * dist) { isClosest = false; break; } |
| } |
| if (!isClosest) pull *= ELASTIC_SECONDARY; |
| |
| pull = Math.min(pull, ELASTIC_MAX_DISP); |
| targetDx = nx * pull; |
| targetDy = ny * pull; |
| } |
| } |
| |
| |
| elasticVx[j] = elasticVx[j] * ELASTIC_DAMPING + (targetDx - elasticDx[j]) * ELASTIC_STIFFNESS; |
| elasticVy[j] = elasticVy[j] * ELASTIC_DAMPING + (targetDy - elasticDy[j]) * ELASTIC_STIFFNESS; |
| elasticDx[j] += elasticVx[j]; |
| elasticDy[j] += elasticVy[j]; |
| |
| |
| if (Math.abs(elasticVx[j]) < 0.01 && Math.abs(elasticDx[j]) < 0.1) { |
| elasticVx[j] = 0; |
| if (targetDx === 0) elasticDx[j] = 0; |
| } |
| if (Math.abs(elasticVy[j]) < 0.01 && Math.abs(elasticDy[j]) < 0.1) { |
| elasticVy[j] = 0; |
| if (targetDy === 0) elasticDy[j] = 0; |
| } |
| |
| |
| md[j].x = positions[j].x + elasticDx[j]; |
| md[j].y = positions[j].y + elasticDy[j]; |
| } |
| } |
| |
| |
| let modelNeighbors = []; |
| function computeNeighbors() { |
| modelNeighbors = []; |
| for (let j = 0; j < activeModelCount; j++) { |
| const neighbors = []; |
| for (let k = 0; k < activeModelCount; k++) { |
| if (k === j) continue; |
| const dx = md[j].x - md[k].x; |
| const dy = md[j].y - md[k].y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist < modelCaptureRadius[j] + modelCaptureRadius[k] + 200) { |
| neighbors.push(k); |
| } |
| } |
| modelNeighbors.push(neighbors); |
| } |
| } |
| computeNeighbors(); |
| |
| |
| const modelRotBias = new Float64Array(BASE_MODEL_COUNT + 1); |
| |
| const modelRotDir = new Int8Array(BASE_MODEL_COUNT + 1); |
| |
| |
| window.addEventListener('resize', () => { |
| positions = computePositions(); |
| syncModelData(); |
| computeNeighbors(); |
| |
| elasticDx.fill(0); elasticDy.fill(0); |
| elasticVx.fill(0); elasticVy.fill(0); |
| }); |
| |
| |
| const particles = new Array(PARTICLE_COUNT); |
| |
| function spawnAtEdge(p) { |
| const w = canvas.width / DPR; |
| const h = canvas.height / DPR; |
| const edge = Math.random() * 4 | 0; |
| |
| if (edge === 0) { p.x = Math.random() * w; p.y = 0; } |
| else if (edge === 1) { p.x = Math.random() * w; p.y = h; } |
| else if (edge === 2) { p.x = 0; p.y = Math.random() * h; } |
| else { p.x = w; p.y = Math.random() * h; } |
| |
| |
| let r = Math.random() * totalSpawnWeight; |
| let target = 0; |
| for (let j = 0; j < activeModelCount; j++) { |
| r -= spawnWeight[j]; |
| if (r <= 0) { target = j; break; } |
| } |
| |
| const dx = md[target].x - p.x; |
| const dy = md[target].y - p.y; |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; |
| const speed = 1.0 + Math.random() * 1.0; |
| |
| const spread = (Math.random() - 0.5) * 0.28; |
| const cosS = Math.cos(spread); |
| const sinS = Math.sin(spread); |
| const ndx = dx / dist; |
| const ndy = dy / dist; |
| p.vx = (ndx * cosS - ndy * sinS) * speed; |
| p.vy = (ndx * sinS + ndy * cosS) * speed; |
| |
| p.size = PARTICLE_SIZE; |
| p.phase = 0; |
| p.attractorIdx = -1; |
| p.orbitRadius = 0; |
| p.angle = 0; |
| p.angularMomentum = 0; |
| p.spiralFriction = 0; |
| p.orbitTimer = 0; |
| p.orbitDuration = 0; |
| p.radialVel = 0; |
| p.blendTimer = 0; |
| p.orangeBlend = 0; |
| } |
| |
| |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| particles[i] = { |
| x: 0, y: 0, vx: 0, vy: 0, size: PARTICLE_SIZE, |
| phase: 0, attractorIdx: -1, |
| orbitRadius: 0, angle: 0, |
| angularMomentum: 0, spiralFriction: 0, |
| orbitTimer: 0, orbitDuration: 0, |
| radialVel: 0, blendTimer: 0, |
| orangeBlend: 0, |
| _spawnFrame: Math.floor(Math.random() * 200) |
| }; |
| spawnAtEdge(particles[i]); |
| } |
| |
| |
| function captureParticle(p, j, dist) { |
| const m = md[j]; |
| p.phase = 1; |
| p.attractorIdx = j; |
| p.orbitRadius = dist; |
| p.angle = Math.atan2(p.y - m.y, p.x - m.x); |
| |
| |
| const radX = (p.x - m.x) / dist; |
| const radY = (p.y - m.y) / dist; |
| const vRad = p.vx * radX + p.vy * radY; |
| const vTanX = p.vx - vRad * radX; |
| const vTanY = p.vy - vRad * radY; |
| const vTan = Math.sqrt(vTanX * vTanX + vTanY * vTanY); |
| |
| |
| const sign = modelRotDir[j] || 1; |
| |
| |
| |
| const targetL = TARGET_CAPTURE_OMEGA * dist * dist; |
| const incomingL = dist * vTan; |
| const blendedL = targetL * 0.7 + Math.min(incomingL, targetL * 1.5) * 0.3; |
| p.angularMomentum = blendedL * sign; |
| |
| |
| const minL = dist * BASE_ANGULAR * 0.5; |
| if (Math.abs(p.angularMomentum) < minL) { |
| p.angularMomentum = minL * sign; |
| } |
| |
| |
| p.radialVel = vRad; |
| p.blendTimer = CAPTURE_BLEND_FRAMES; |
| |
| p.spiralFriction = SPIRAL_FRICTION + (Math.random() - 0.5) * SPIRAL_FRICTION_VAR * 2; |
| |
| |
| p.orbitTimer = 0; |
| p.orbitDuration = OUTER_ORBIT_DURATION_BASE + OUTER_ORBIT_DURATION_MASS * m.massNorm; |
| } |
| |
| |
| const BOUNCE_RESTITUTION = 0.85; |
| const BOUNCE_MIN_KICK = 2.25; |
| const BOUNCE_ORBIT_PUNCH = 3.375; |
| |
| function deflectFromLabel(p) { |
| if (!labelBounceRect || labelBounceAlpha <= 0) return; |
| const b = labelBounceRect; |
| const alpha = labelBounceAlpha; |
| |
| |
| const ehw = b.hw * alpha; |
| const ehh = b.hh * alpha; |
| |
| |
| const dx = p.x - b.cx; |
| const dy = p.y - b.cy; |
| |
| |
| const ax = Math.abs(dx); |
| const ay = Math.abs(dy); |
| if (ax > ehw || ay > ehh) return; |
| |
| |
| const overlapX = ehw - ax; |
| const overlapY = ehh - ay; |
| |
| |
| let nx = 0, ny = 0; |
| if (overlapX < overlapY) { |
| nx = dx >= 0 ? 1 : -1; |
| p.x += nx * (overlapX + 1); |
| } else { |
| ny = dy >= 0 ? 1 : -1; |
| p.y += ny * (overlapY + 1); |
| } |
| |
| if (p.phase === 0) { |
| |
| const vDotN = p.vx * nx + p.vy * ny; |
| if (vDotN < 0) { |
| p.vx -= 2 * vDotN * nx; |
| p.vy -= 2 * vDotN * ny; |
| p.vx *= BOUNCE_RESTITUTION; |
| p.vy *= BOUNCE_RESTITUTION; |
| } |
| |
| const outV = p.vx * nx + p.vy * ny; |
| if (outV < BOUNCE_MIN_KICK) { |
| p.vx += (BOUNCE_MIN_KICK - outV) * nx; |
| p.vy += (BOUNCE_MIN_KICK - outV) * ny; |
| } |
| } else if ((p.phase === 1 || p.phase === 3 || p.phase === 4) && p.attractorIdx >= 0) { |
| |
| const m = md[p.attractorIdx]; |
| const r = p.orbitRadius || 1; |
| const rawOmega = p.angularMomentum / (r * r); |
| const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA); |
| |
| const cosA = Math.cos(p.angle); |
| const sinA = Math.sin(p.angle); |
| const vTang = omega * r; |
| const vRad = p.radialVel || 0; |
| let ovx = -sinA * vTang + cosA * vRad; |
| let ovy = cosA * vTang + sinA * vRad; |
| |
| |
| const vDotN = ovx * nx + ovy * ny; |
| if (vDotN < 0) { |
| ovx -= 2 * vDotN * nx; |
| ovy -= 2 * vDotN * ny; |
| ovx *= BOUNCE_RESTITUTION; |
| ovy *= BOUNCE_RESTITUTION; |
| } |
| |
| |
| ovx += nx * BOUNCE_ORBIT_PUNCH; |
| ovy += ny * BOUNCE_ORBIT_PUNCH; |
| |
| |
| const ndx = p.x - m.x; |
| const ndy = p.y - m.y; |
| const newR = Math.sqrt(ndx * ndx + ndy * ndy) || 1; |
| const newAngle = Math.atan2(ndy, ndx); |
| const newCosA = Math.cos(newAngle); |
| const newSinA = Math.sin(newAngle); |
| |
| |
| const newVRad = ovx * newCosA + ovy * newSinA; |
| const newVTan = -ovx * newSinA + ovy * newCosA; |
| |
| |
| p.orbitRadius = newR; |
| p.angle = newAngle; |
| p.angularMomentum = newVTan * newR; |
| p.radialVel = newVRad; |
| |
| |
| if (p.phase === 1 && p.blendTimer <= 0) { |
| p.blendTimer = 30; |
| } |
| } |
| } |
| |
| |
| function updateParticle(p, w, h) { |
| |
| if (p.phase === 0) { |
| let captured = false; |
| |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| const m = md[j]; |
| const dx = m.x - p.x; |
| const dy = m.y - p.y; |
| const distSq = dx * dx + dy * dy; |
| const dist = Math.sqrt(distSq); |
| if (dist < 2) continue; |
| |
| |
| |
| const softDist = Math.max(dist, 200); |
| let force = m.massNorm * G / (dist * softDist); |
| if (dist < NEAR_BOOST_RADIUS) { |
| force *= 1 + NEAR_BOOST_FACTOR * (1 - dist / NEAR_BOOST_RADIUS); |
| } |
| |
| p.vx += (dx / dist) * force; |
| p.vy += (dy / dist) * force; |
| } |
| |
| |
| p.vx *= DRAG; |
| p.vy *= DRAG; |
| const speedSq = p.vx * p.vx + p.vy * p.vy; |
| if (speedSq > SPEED_CAP * SPEED_CAP) { |
| const s = Math.sqrt(speedSq); |
| p.vx = (p.vx / s) * SPEED_CAP; |
| p.vy = (p.vy / s) * SPEED_CAP; |
| } |
| |
| |
| const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); |
| const steps = speed > SUBSTEP_SPEED_THRESHOLD ? 2 : 1; |
| const svx = p.vx / steps; |
| const svy = p.vy / steps; |
| |
| for (let s = 0; s < steps; s++) { |
| p.x += svx; |
| p.y += svy; |
| |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| const m = md[j]; |
| const dx = m.x - p.x; |
| const dy = m.y - p.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
| if (dist <= m.captureRadius) { |
| captureParticle(p, j, dist); |
| captured = true; |
| break; |
| } |
| } |
| if (captured) break; |
| } |
| |
| if (!captured) { |
| |
| let nearCount = 0; |
| for (let j = 0; j < activeModelCount; j++) { |
| const dx = md[j].x - p.x; |
| const dy = md[j].y - p.y; |
| if (dx * dx + dy * dy < md[j].captureRadius * md[j].captureRadius * 2.25) { |
| nearCount++; |
| } |
| } |
| if (nearCount >= 2) { |
| const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1; |
| const boost = Math.min(SLINGSHOT_BOOST, SPEED_CAP / spd); |
| p.vx *= boost; |
| p.vy *= boost; |
| } |
| |
| |
| if (p.x < -50 || p.x > w + 50 || p.y < -50 || p.y > h + 50) { |
| spawnAtEdge(p); |
| } |
| } |
| return; |
| } |
| |
| |
| if (p.phase === 1) { |
| const m = md[p.attractorIdx]; |
| const r = p.orbitRadius; |
| |
| |
| const rawOmega = p.angularMomentum / (r * r); |
| const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA); |
| |
| |
| p.angle += omega + PRECESSION_RATE * Math.sign(omega); |
| |
| |
| |
| |
| if (p.blendTimer > 0) { |
| p.blendTimer--; |
| |
| p.radialVel *= 0.88; |
| |
| p.orbitRadius += p.radialVel; |
| } else { |
| |
| p.orbitRadius -= r * p.spiralFriction; |
| } |
| |
| |
| p.x = m.x + p.orbitRadius * Math.cos(p.angle); |
| p.y = m.y + p.orbitRadius * Math.sin(p.angle); |
| |
| |
| const neighbors = modelNeighbors[p.attractorIdx]; |
| for (let ni = 0; ni < neighbors.length; ni++) { |
| const k = neighbors[ni]; |
| const mk = md[k]; |
| const dx = mk.x - p.x; |
| const dy = mk.y - p.y; |
| const distSq = dx * dx + dy * dy; |
| const dist = Math.sqrt(distSq); |
| if (dist < 2) continue; |
| |
| const pertForce = mk.massNorm * G * 0.3 / distSq; |
| |
| const cr = p.orbitRadius || 1; |
| const radX = (p.x - m.x) / cr; |
| const radY = (p.y - m.y) / cr; |
| const forceX = (dx / dist) * pertForce; |
| const forceY = (dy / dist) * pertForce; |
| const tangForce = -forceX * radY + forceY * radX; |
| |
| p.angularMomentum += tangForce * cr * 0.5; |
| } |
| |
| |
| if (p.orbitRadius > modelCaptureRadius[p.attractorIdx]) { |
| const rawEOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius); |
| const eOmega = Math.sign(rawEOmega) * Math.min(Math.abs(rawEOmega), MAX_OMEGA); |
| p.vx = -Math.sin(p.angle) * eOmega * p.orbitRadius; |
| p.vy = Math.cos(p.angle) * eOmega * p.orbitRadius; |
| p.phase = 0; |
| p.attractorIdx = -1; |
| return; |
| } |
| |
| |
| p.orbitTimer++; |
| if (p.orbitTimer >= p.orbitDuration) { |
| const absorbChance = ABSORB_CHANCE_BASE + ABSORB_CHANCE_MASS * md[p.attractorIdx].massNorm; |
| if (Math.random() < absorbChance) { |
| |
| p.phase = 4; |
| p.radialVel = 0; |
| } else { |
| p.phase = 3; |
| } |
| } |
| return; |
| } |
| |
| |
| if (p.phase === 3) { |
| const m = md[p.attractorIdx]; |
| |
| |
| const rawOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius); |
| const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA); |
| p.angle += omega + PRECESSION_RATE * Math.sign(omega); |
| |
| p.x = m.x + p.orbitRadius * Math.cos(p.angle); |
| p.y = m.y + p.orbitRadius * Math.sin(p.angle); |
| |
| |
| const fadeRate = CONSUMPTION_SIZE_RATE * (1 + 0.5 * (1 - m.massNorm)); |
| p.size -= fadeRate; |
| |
| if (p.size <= 0) { |
| spawnAtEdge(p); |
| } |
| return; |
| } |
| |
| |
| |
| { |
| const m = md[p.attractorIdx]; |
| |
| |
| p.angularMomentum *= (1 - ABSORB_RADIAL_DRAIN); |
| |
| |
| p.radialVel -= ABSORB_INWARD_FORCE; |
| p.radialVel *= 0.96; |
| |
| p.orbitRadius += p.radialVel; |
| |
| |
| if (p.orbitRadius < 1) p.orbitRadius = 1; |
| |
| |
| const rawOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius); |
| const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA_ABSORB); |
| p.angle += omega + PRECESSION_RATE * Math.sign(omega); |
| |
| |
| p.x = m.x + p.orbitRadius * Math.cos(p.angle); |
| p.y = m.y + p.orbitRadius * Math.sin(p.angle); |
| |
| |
| if (p.orbitRadius < ABSORB_MIN_RADIUS * 4) { |
| p.size -= 0.05; |
| } |
| |
| |
| if (p.orbitRadius < ABSORB_MIN_RADIUS || p.size <= 0) { |
| spawnAtEdge(p); |
| } |
| } |
| |
| } |
| |
| |
| for (let pre = 0; pre < PRE_SIM_FRAMES; pre++) { |
| modelRotBias.fill(0); |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if ((p.phase === 1 || p.phase === 4) && p.attractorIdx >= 0) { |
| modelRotBias[p.attractorIdx] += Math.sign(p.angularMomentum); |
| } |
| } |
| for (let j = 0; j < activeModelCount; j++) { |
| modelRotBias[j] = Math.sign(modelRotBias[j]); |
| } |
| |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| if (pre >= particles[i]._spawnFrame) { |
| updateParticle(particles[i], canvas.width / DPR, canvas.height / DPR); |
| } |
| } |
| } |
| |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| modelRotDir[j] = modelRotBias[j] !== 0 ? modelRotBias[j] : (Math.random() < 0.5 ? 1 : -1); |
| } |
| |
| |
| const ICON_SIZE = 11.5; |
| const ICON_PAD = 7; |
| const ICON_CORNER = 6; |
| const ICON_DRAW_SIZE = ICON_SIZE * 2; |
| const ICON_RENDER_SIZE = Math.ceil(ICON_DRAW_SIZE * Math.max(window.devicePixelRatio || 1, 2) * 2); |
| |
| |
| const TASK_ICON_FILES = { |
| 'audio-classification': 'IconAudioClassification.svg', |
| 'audio-text-to-text': 'IconAudioTextToText.svg', |
| 'audio-to-audio': 'IconAudioToAudio.svg', |
| 'automatic-speech-recognition': 'IconAutomaticSpeechRecognition.svg', |
| 'conversational': 'IconConversational.svg', |
| 'depth-estimation': 'IconDepthEstimation.svg', |
| 'document-question-answering': 'IconDocumentQuestionAnswering.svg', |
| 'fill-mask': 'IconFillMask.svg', |
| 'graph-ml': 'IconGraphML.svg', |
| 'image-text-to-text': 'IconImageAndTextToText.svg', |
| 'image-classification': 'IconImageClassification.svg', |
| 'image-feature-extraction': 'IconImageFeatureExtraction.svg', |
| 'image-segmentation': 'IconImageSegmentation.svg', |
| 'image-to-3d': 'IconImageTo3D.svg', |
| 'image-to-image': 'IconImageToImage.svg', |
| 'image-to-text': 'IconImageToText.svg', |
| 'image-to-video': 'IconImageToVideo.svg', |
| 'keypoint-detection': 'IconKeypointDetection.svg', |
| 'mask-generation': 'IconMaskGeneration.svg', |
| 'object-detection': 'IconObjectDetection.svg', |
| 'question-answering': 'IconQuestionAnswering.svg', |
| 'ranking': 'IconRanking.svg', |
| 'reinforcement-learning': 'IconReinforcementLearning.svg', |
| 'robotics': 'IconRobotics.svg', |
| 'sentence-similarity': 'IconSentenceSimilarity.svg', |
| 'summarization': 'IconSummarization.svg', |
| 'table-question-answering': 'IconTabeQuestionAnswering.svg', |
| 'tabular-classification': 'IconTabularClassification.svg', |
| 'tabular-regression': 'IconTabularRegression.svg', |
| 'text2text-generation': 'IconText2textGeneration.svg', |
| 'text-classification': 'IconTextClassification.svg', |
| 'text-generation': 'IconTextGeneration.svg', |
| 'text-to-3d': 'IconTextTo3D.svg', |
| 'text-to-audio': 'IconTextToAudio.svg', |
| 'text-to-image': 'IconTextToImage.svg', |
| 'text-to-speech': 'IconTextToSpeech.svg', |
| 'text-to-video': 'IconTextToVideo.svg', |
| 'time-series-forecasting': 'IconTimeSeriesForecasting.svg', |
| 'token-classification': 'IconTokenClassification.svg', |
| 'translation': 'IconTranslation.svg', |
| 'unconditional-image-generation': 'IconUnconditionalImageGeneration.svg', |
| 'video-classification': 'IconVideoClassification.svg', |
| 'video-text-to-text': 'IconVideoTextToText.svg', |
| 'visual-question-answering': 'IconVisualQuestionAnswering.svg', |
| 'voice-activity-detection': 'IconVoiceActivityDetection.svg', |
| 'zero-shot-classification': 'IconZeroShotClassification.svg', |
| 'zero-shot-object-detection': 'IconZeroShotObjectDetection.svg', |
| 'any-to-any': 'iconAnyToAny.svg', |
| 'feature-extraction': 'img feature extraction.svg', |
| }; |
| |
| |
| const taskIconImgs = {}; |
| const ICON_ASSET_DIR = 'model task icon assets/'; |
| |
| function buildColoredSVGImage(svgText, hexColor, renderSize) { |
| |
| let colored = svgText |
| .replace(/fill="black"/g, 'fill="' + hexColor + '"') |
| .replace(/stroke="black"/g, 'stroke="' + hexColor + '"') |
| .replace(/fill="#000000"/g, 'fill="' + hexColor + '"') |
| .replace(/stroke="#000000"/g,'stroke="' + hexColor + '"') |
| .replace(/fill="#000"/g, 'fill="' + hexColor + '"') |
| .replace(/stroke="#000"/g, 'stroke="' + hexColor + '"'); |
| |
| colored = colored |
| .replace(/width="\d+"/, 'width="' + renderSize + '"') |
| .replace(/height="\d+"/, 'height="' + renderSize + '"'); |
| const blob = new Blob([colored], { type: 'image/svg+xml' }); |
| const url = URL.createObjectURL(blob); |
| const img = new Image(); |
| img.src = url; |
| return img; |
| } |
| |
| |
| function loadSVGText(url) { |
| return new Promise((resolve, reject) => { |
| const xhr = new XMLHttpRequest(); |
| xhr.open('GET', url, true); |
| xhr.onload = () => xhr.status === 200 || xhr.status === 0 ? resolve(xhr.responseText) : reject(); |
| xhr.onerror = reject; |
| xhr.send(); |
| }); |
| } |
| |
| |
| async function preloadTaskIcons() { |
| const colors = [COLOR, ORANGE]; |
| const entries = Object.entries(TASK_ICON_FILES); |
| await Promise.all(entries.map(async ([task, file]) => { |
| try { |
| const svgText = await loadSVGText(ICON_ASSET_DIR + file); |
| if (!svgText) return; |
| taskIconImgs[task] = {}; |
| for (const c of colors) { |
| const img = buildColoredSVGImage(svgText, c, ICON_RENDER_SIZE); |
| taskIconImgs[task][c] = img; |
| |
| await img.decode().catch(() => {}); |
| } |
| } catch (_) {} |
| })); |
| } |
| |
| |
| preloadTaskIcons(); |
| |
| function drawTaskIcon(ctx, x, y, task, iconColor) { |
| const s = ICON_SIZE; |
| const pad = ICON_PAD; |
| const ic = iconColor || COLOR; |
| |
| |
| ctx.save(); |
| ctx.fillStyle = BG; |
| const pillW = (s + pad) * 2; |
| const pillH = (s + pad) * 2; |
| const px = x - s - pad; |
| const py = y - s - pad; |
| ctx.beginPath(); |
| ctx.roundRect(px, py, pillW, pillH, ICON_CORNER); |
| ctx.fill(); |
| ctx.restore(); |
| |
| |
| const iconEntry = taskIconImgs[task]; |
| const img = iconEntry && iconEntry[ic]; |
| if (img && img.complete && img.naturalWidth > 0) { |
| ctx.drawImage(img, x - s, y - s, ICON_DRAW_SIZE, ICON_DRAW_SIZE); |
| } |
| } |
| |
| |
| const PS2 = PARTICLE_SIZE * 2; |
| |
| function frame() { |
| const w = canvas.width / DPR; |
| const h = canvas.height / DPR; |
| |
| |
| ctx.fillStyle = BG; |
| ctx.fillRect(0, 0, w, h); |
| |
| |
| modelRotBias.fill(0); |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if ((p.phase === 1 || p.phase === 4) && p.attractorIdx >= 0) { |
| modelRotBias[p.attractorIdx] += Math.sign(p.angularMomentum); |
| } |
| } |
| for (let j = 0; j < activeModelCount; j++) { |
| modelRotBias[j] = Math.sign(modelRotBias[j]); |
| } |
| |
| |
| rebalanceTimer++; |
| if (rebalanceTimer >= 60) { |
| rebalanceTimer = 0; |
| modelOrbitCount.fill(0); |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if (p.attractorIdx >= 0 && (p.phase === 1 || p.phase === 3 || p.phase === 4)) { |
| modelOrbitCount[p.attractorIdx]++; |
| } |
| } |
| let tw = 0; |
| for (let j = 0; j < activeModelCount; j++) { |
| const deficit = modelTargetPop[j] - modelOrbitCount[j]; |
| spawnWeight[j] = Math.max(0.01, massNorm[j] + 0.3 * (deficit / totalTarget)); |
| tw += spawnWeight[j]; |
| } |
| totalSpawnWeight = tw; |
| } |
| |
| |
| |
| if (hoveredModel >= 0 || pinnedLabelBounceActive) { |
| labelBounceAlpha = Math.min(1, labelBounceAlpha + 0.08); |
| } else { |
| labelBounceAlpha = Math.max(0, labelBounceAlpha - 0.06); |
| if (labelBounceAlpha <= 0) labelBounceRect = null; |
| } |
| |
| |
| updatePopAnimation(); |
| |
| |
| updateLayoutAnimation(); |
| |
| |
| updateElasticPull(); |
| |
| |
| const doDeflect = labelBounceAlpha > 0; |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| updateParticle(p, w, h); |
| if (doDeflect) deflectFromLabel(p); |
| |
| if (customModelActive && p.attractorIdx === CUSTOM_MODEL_IDX) { |
| p.orangeBlend = Math.min(1, p.orangeBlend + 0.02); |
| } else if (p.orangeBlend > 0) { |
| p.orangeBlend = Math.max(0, p.orangeBlend - 0.02); |
| } |
| } |
| |
| |
| ctx.fillStyle = COLOR; |
| ctx.strokeStyle = COLOR; |
| ctx.lineWidth = 1; |
| |
| |
| ctx.beginPath(); |
| let hasStreaks = false; |
| |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if (p.size <= 0) continue; |
| |
| |
| if (p.phase === 0) { |
| const speedSq = p.vx * p.vx + p.vy * p.vy; |
| if (speedSq > 9) { |
| const s = Math.sqrt(speedSq); |
| const len = Math.min(s * 1.5, 6); |
| ctx.moveTo(p.x, p.y); |
| ctx.lineTo(p.x - (p.vx / s) * len, p.y - (p.vy / s) * len); |
| hasStreaks = true; |
| continue; |
| } |
| } |
| |
| |
| if ((p.phase === 3 || p.phase === 4) && p.size < PARTICLE_SIZE) { |
| const s2 = p.size * 2; |
| ctx.fillRect(p.x - p.size, p.y - p.size, s2, s2); |
| } else { |
| ctx.fillRect(p.x - PARTICLE_SIZE, p.y - PARTICLE_SIZE, PS2, PS2); |
| } |
| } |
| |
| if (hasStreaks) ctx.stroke(); |
| |
| |
| |
| |
| |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if (p.orangeBlend <= 0 || p.size <= 0) continue; |
| const t = p.orangeBlend; |
| const r = Math.round(22 + (255 - 22) * t); |
| const g = Math.round(21 + (159 - 21) * t); |
| const b = Math.round(19 + (28 - 19) * t); |
| const col = 'rgb(' + r + ',' + g + ',' + b + ')'; |
| |
| if (p.phase === 0) { |
| const speedSq = p.vx * p.vx + p.vy * p.vy; |
| if (speedSq > 9) { |
| const sp = Math.sqrt(speedSq); |
| const len = Math.min(sp * 1.5, 6); |
| ctx.strokeStyle = col; |
| ctx.beginPath(); |
| ctx.moveTo(p.x, p.y); |
| ctx.lineTo(p.x - (p.vx / sp) * len, p.y - (p.vy / sp) * len); |
| ctx.stroke(); |
| continue; |
| } |
| } |
| |
| ctx.fillStyle = col; |
| if ((p.phase === 3 || p.phase === 4) && p.size < PARTICLE_SIZE) { |
| const s2 = p.size * 2; |
| ctx.fillRect(p.x - p.size, p.y - p.size, s2, s2); |
| } else { |
| ctx.fillRect(p.x - PARTICLE_SIZE, p.y - PARTICLE_SIZE, PS2, PS2); |
| } |
| } |
| |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| const iconCol = (customModelActive && j === CUSTOM_MODEL_IDX) ? ORANGE : COLOR; |
| if (customModelActive && j === CUSTOM_MODEL_IDX && customPopScale < 1) { |
| const sc = customPopScale; |
| if (sc > 0.01) { |
| ctx.save(); |
| ctx.translate(md[j].x, md[j].y); |
| ctx.scale(sc, sc); |
| drawTaskIcon(ctx, 0, 0, models[j].task, iconCol); |
| ctx.restore(); |
| } |
| } else { |
| drawTaskIcon(ctx, md[j].x, md[j].y, models[j].task, iconCol); |
| } |
| } |
| |
| |
| pinnedLabel.style.left = md[topModelIdx].x + 'px'; |
| pinnedLabel.style.top = (md[topModelIdx].y + CORE_RADIUS + 12) + 'px'; |
| pinnedLabel.style.transform = 'translateX(-50%)'; |
| |
| |
| if (hoveredModel < 0 || hoveredModel === topModelIdx) { |
| const pRect = pinnedLabel.getBoundingClientRect(); |
| const pMpos = md[topModelIdx]; |
| const pPad = 14; |
| labelBounceRect = { |
| cx: pMpos.x, |
| cy: pMpos.y + CORE_RADIUS + 12 + pRect.height / 2, |
| hw: pRect.width / 2 + pPad, |
| hh: pRect.height / 2 + pPad + 8, |
| }; |
| } |
| |
| requestAnimationFrame(frame); |
| } |
| |
| requestAnimationFrame(frame); |
| |
| |
| function fmtTask(tag) { |
| const names = { |
| 'sentence-similarity': 'Sentence Similarity', |
| 'fill-mask': 'Fill-Mask', |
| 'image-classification': 'Image Classification', |
| 'image-text-to-text': 'Image-Text-to-Text', |
| 'audio-classification': 'Audio Classification', |
| 'text-generation': 'Text Generation', |
| 'text-classification': 'Text Classification', |
| 'feature-extraction': 'Feature Extraction', |
| 'token-classification': 'Token Classification', |
| 'question-answering': 'Question Answering', |
| 'text2text-generation': 'Text-to-Text', |
| 'automatic-speech-recognition': 'Speech Recognition', |
| 'translation': 'Translation', |
| 'summarization': 'Summarization', |
| 'object-detection': 'Object Detection', |
| 'zero-shot-classification': 'Zero-Shot Classification', |
| 'text-to-speech': 'Text-to-Speech', |
| 'audio-to-audio': 'Audio-to-Audio', |
| 'image-to-image': 'Image-to-Image', |
| 'image-to-video': 'Image-to-Video', |
| 'image-to-text': 'Image-to-Text', |
| 'text-to-image': 'Text-to-Image', |
| 'any-to-any': 'Any-to-Any', |
| }; |
| return names[tag] || tag.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); |
| } |
| |
| function fmtDownloads(n) { |
| 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 n.toString(); |
| } |
| |
| const tooltip = document.getElementById('tooltip'); |
| let hoveredModel = -1; |
| let labelBounceRect = null; |
| let labelBounceAlpha = 0; |
| |
| |
| const topModelIdx = 0; |
| const pinnedLabel = document.getElementById('pinnedLabel'); |
| { |
| const m = models[topModelIdx]; |
| const url = 'https://huggingface.co/' + m.id; |
| const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id; |
| const authorUrl = 'https://huggingface.co/' + m.author; |
| const avatarHtml = m.avatarUrl |
| ? '<img src="' + m.avatarUrl + '" width="16" height="16" style="border-radius:3px;vertical-align:-2px;margin-right:4px">' |
| : ''; |
| const authorLine = m.author |
| ? avatarHtml + '<a href="' + authorUrl + '" target="_blank" style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.55;color:#161513;text-decoration:none">' |
| + m.author + '</a><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.35;margin:0 2px">/</span>' |
| : ''; |
| pinnedLabel.innerHTML = authorLine + '<a href="' + url + '" target="_blank">' |
| + shortName + '</a><br><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.7">' + fmtTask(m.task) + '</span><br><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#161513" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' + fmtDownloads(m.downloads); |
| } |
| let pinnedLabelBounceActive = true; |
| |
| canvas.addEventListener('mousemove', (e) => { |
| |
| cursorX = e.clientX; |
| cursorY = e.clientY; |
| cursorOnCanvas = true; |
| |
| const mx = e.clientX; |
| const my = e.clientY; |
| let found = -1; |
| |
| for (let j = 0; j < activeModelCount; j++) { |
| const dx = mx - md[j].x; |
| const dy = my - md[j].y; |
| if (dx * dx + dy * dy <= md[j].captureRadius * md[j].captureRadius) { |
| found = j; |
| break; |
| } |
| } |
| |
| if (found !== hoveredModel) { |
| hoveredModel = found; |
| if (found === topModelIdx) { |
| |
| tooltip.classList.remove('visible'); |
| tooltip.style.opacity = '0'; |
| tooltip.innerHTML = ''; |
| } else if (found >= 0) { |
| const m = models[found]; |
| const mpos = md[found]; |
| const url = 'https://huggingface.co/' + m.id; |
| const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id; |
| const authorUrl = 'https://huggingface.co/' + m.author; |
| const isCustom = customModelActive && found === CUSTOM_MODEL_IDX; |
| const tColor = isCustom ? '#E8820C' : '#161513'; |
| const avatarHtml = m.avatarUrl |
| ? '<img src="' + m.avatarUrl + '" width="16" height="16" style="border-radius:3px;vertical-align:-2px;margin-right:4px">' |
| : ''; |
| const authorLine = m.author |
| ? avatarHtml + '<a href="' + authorUrl + '" target="_blank" style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.55;color:' + tColor + ';text-decoration:none">' |
| + m.author + '</a><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.35;margin:0 2px">/</span>' |
| : ''; |
| tooltip.style.opacity = ''; |
| tooltip.style.color = tColor; |
| tooltip.innerHTML = authorLine + '<a href="' + url + '" target="_blank" style="color:' + tColor + '">' |
| + shortName + '</a><br><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.7">' + fmtTask(m.task) + '</span><br><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="' + tColor + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' + fmtDownloads(m.downloads); |
| tooltip.classList.add('visible'); |
| |
| |
| tooltip.style.left = mpos.x + 'px'; |
| tooltip.style.top = (mpos.y + CORE_RADIUS + 12) + 'px'; |
| tooltip.style.transform = 'translateX(-50%)'; |
| |
| |
| const rect = tooltip.getBoundingClientRect(); |
| const pad = 14; |
| labelBounceRect = { |
| cx: mpos.x, |
| cy: mpos.y + CORE_RADIUS + 12 + rect.height / 2, |
| hw: rect.width / 2 + pad, |
| hh: rect.height / 2 + pad + 8, |
| }; |
| } else { |
| tooltip.classList.remove('visible'); |
| |
| } |
| } |
| |
| if (hoveredModel >= 0 && hoveredModel !== topModelIdx) { |
| const model = md[hoveredModel]; |
| tooltip.style.left = model.x + 'px'; |
| tooltip.style.top = (model.y + CORE_RADIUS + 12) + 'px'; |
| tooltip.style.transform = 'translateX(-50%)'; |
| } |
| |
| canvas.style.cursor = hoveredModel >= 0 ? 'pointer' : 'default'; |
| }); |
| |
| canvas.addEventListener('click', (e) => { |
| const mx = e.clientX; |
| const my = e.clientY; |
| for (let j = 0; j < activeModelCount; j++) { |
| const dx = mx - md[j].x; |
| const dy = my - md[j].y; |
| if (dx * dx + dy * dy <= md[j].captureRadius * md[j].captureRadius) { |
| window.open('https://huggingface.co/' + models[j].id, '_blank'); |
| break; |
| } |
| } |
| }); |
| |
| canvas.addEventListener('mouseleave', () => { |
| hoveredModel = -1; |
| tooltip.classList.remove('visible'); |
| canvas.style.cursor = 'default'; |
| cursorOnCanvas = false; |
| }); |
| |
| |
| const addPill = document.getElementById('addPill'); |
| const pillInput = addPill.querySelector('input[type="text"]'); |
| const pillAddBtn = addPill.querySelector('.add-btn'); |
| const pillError = addPill.querySelector('.error-msg'); |
| const pillModelName = addPill.querySelector('.model-name'); |
| const pillRemoveBtn = addPill.querySelector('.remove-btn'); |
| const dropdown = document.getElementById('modelDropdown'); |
| const dropdownInput = document.getElementById('dropdownSearchInput'); |
| const dropdownContent = document.getElementById('dropdownContent'); |
| |
| |
| let pillState = 'idle'; |
| let pillAnim = null; |
| let dropdownOpen = false; |
| let searchTimer = null; |
| let selectedIdx = -1; |
| let currentItems = []; |
| |
| function animatePill(toState) { |
| const fromW = addPill.offsetWidth; |
| addPill.classList.remove('expanded', 'active', 'loading'); |
| pillError.textContent = ''; |
| pillError.style.display = 'none'; |
| if (toState === 'expanded') { |
| addPill.classList.add('expanded'); |
| } else if (toState === 'loading') { |
| addPill.classList.add('expanded', 'loading'); |
| } else if (toState === 'active') { |
| addPill.classList.add('active'); |
| const m = models[CUSTOM_MODEL_IDX]; |
| const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id; |
| pillModelName.textContent = shortName; |
| } |
| const toW = addPill.offsetWidth; |
| if (pillAnim) pillAnim.cancel(); |
| if (fromW !== toW) { |
| pillAnim = addPill.animate( |
| [{ width: fromW + 'px' }, { width: toW + 'px' }], |
| { duration: 350, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'none' } |
| ); |
| pillAnim.onfinish = () => { addPill.style.width = ''; pillAnim = null; }; |
| } |
| } |
| |
| function setPillState(state) { |
| pillState = state; |
| animatePill(state); |
| if (state === 'expanded') { |
| setTimeout(() => pillInput.focus(), 80); |
| openDropdown(); |
| } else { |
| closeDropdown(); |
| } |
| } |
| |
| |
| function openDropdown() { |
| dropdownOpen = true; |
| dropdown.classList.add('open'); |
| dropdownInput.value = ''; |
| selectedIdx = -1; |
| renderTrending(); |
| } |
| |
| function closeDropdown() { |
| dropdownOpen = false; |
| dropdown.classList.remove('open'); |
| selectedIdx = -1; |
| if (searchTimer) clearTimeout(searchTimer); |
| } |
| |
| |
| function modelCardHTML(m, idx) { |
| const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id; |
| const avatarSrc = m.avatarUrl || avatarMap[m.author] || ''; |
| const imgTag = avatarSrc |
| ? '<img src="' + avatarSrc + '" alt="" />' |
| : '<div class="dropdown-avatar-placeholder"></div>'; |
| return '<div class="dropdown-model" data-model-id="' + m.id + '" data-idx="' + idx + '">' + imgTag + '<span>' + shortName + '</span></div>'; |
| } |
| |
| function attachCardListeners() { |
| dropdownContent.querySelectorAll('.dropdown-model').forEach(function(el) { |
| el.addEventListener('click', function(e) { |
| e.stopPropagation(); |
| const modelId = el.dataset.modelId; |
| setPillState('idle'); |
| submitCustomModel(modelId); |
| }); |
| }); |
| } |
| |
| function updateSelection() { |
| const cards = dropdownContent.querySelectorAll('.dropdown-model'); |
| cards.forEach(function(el, i) { el.classList.toggle('selected', i === selectedIdx); }); |
| if (selectedIdx >= 0 && selectedIdx < cards.length) { |
| cards[selectedIdx].scrollIntoView({ block: 'nearest' }); |
| } |
| } |
| |
| function renderTrending() { |
| currentItems = allTrendingModels.slice(0, 20); |
| let html = '<div class="dropdown-section-header">\uD83D\uDD25 Trending</div>'; |
| currentItems.forEach(function(m, i) { html += modelCardHTML(m, i); }); |
| dropdownContent.innerHTML = html; |
| selectedIdx = -1; |
| attachCardListeners(); |
| } |
| |
| function renderSearchResults(query, items) { |
| if (!items.length) { |
| dropdownContent.innerHTML = '<div class="dropdown-empty">No models found</div>'; |
| currentItems = []; |
| return; |
| } |
| const trendingIds = new Set(allTrendingModels.map(function(m) { return m.id; })); |
| const q = query.toLowerCase(); |
| const trendingMatches = allTrendingModels.filter(function(m) { return m.id.toLowerCase().includes(q); }); |
| const others = items.filter(function(m) { return !trendingIds.has(m.id); }); |
| |
| let html = ''; |
| let idx = 0; |
| if (trendingMatches.length) { |
| html += '<div class="dropdown-section-header">\uD83D\uDD25 Trending</div>'; |
| trendingMatches.forEach(function(m) { html += modelCardHTML(m, idx++); }); |
| } |
| if (others.length) { |
| html += '<div class="dropdown-section-header" style="color:rgba(246,244,239,0.35)">Other models</div>'; |
| others.forEach(function(m) { html += modelCardHTML(m, idx++); }); |
| } |
| currentItems = trendingMatches.concat(others); |
| dropdownContent.innerHTML = html; |
| selectedIdx = -1; |
| attachCardListeners(); |
| } |
| |
| async function searchModels(query) { |
| if (!query.trim()) { renderTrending(); return; } |
| dropdownContent.innerHTML = '<div class="dropdown-loading"><div class="dropdown-spinner"></div></div>'; |
| try { |
| const res = await fetch('https://huggingface.co/api/models?search=' + encodeURIComponent(query) + '&sort=downloads&direction=-1&limit=20'); |
| if (!res.ok) throw new Error(res.status); |
| const data = await res.json(); |
| const results = data.map(function(m) { |
| const fullId = m.modelId || m.id; |
| return { |
| id: fullId, |
| downloads: m.downloads || 0, |
| task: m.pipeline_tag || 'unknown', |
| author: fullId.includes('/') ? fullId.split('/')[0] : '', |
| avatarUrl: '' |
| }; |
| }); |
| const newAuthors = [...new Set(results.map(function(m) { return m.author; }).filter(function(a) { return a && !avatarMap[a]; }))]; |
| await Promise.all(newAuthors.map(async function(author) { |
| try { |
| const r = await fetch('https://huggingface.co/api/organizations/' + author + '/avatar'); |
| if (r.ok) { const j = await r.json(); if (j.avatarUrl) avatarMap[author] = j.avatarUrl; } |
| } catch (_) {} |
| })); |
| results.forEach(function(m) { if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author]; }); |
| renderSearchResults(query, results); |
| } catch (e) { |
| dropdownContent.innerHTML = '<div class="dropdown-empty">Search failed. Try again.</div>'; |
| } |
| } |
| |
| |
| |
| |
| addPill.addEventListener('click', (e) => { |
| if (pillState === 'idle') { |
| setPillState('expanded'); |
| e.stopPropagation(); |
| } else if (pillState === 'active') { |
| if (!e.target.classList.contains('remove-btn')) { |
| removeCustomModel(); |
| setPillState('expanded'); |
| pillInput.value = ''; |
| e.stopPropagation(); |
| } |
| } |
| }); |
| |
| |
| pillRemoveBtn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| removeCustomModel(); |
| setPillState('idle'); |
| }); |
| |
| |
| pillAddBtn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const val = pillInput.value.trim(); |
| if (val) submitCustomModel(val); |
| }); |
| |
| |
| pillInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| const val = pillInput.value.trim(); |
| if (val) submitCustomModel(val); |
| } else if (e.key === 'Escape') { |
| setPillState(customModelActive ? 'active' : 'idle'); |
| } |
| }); |
| |
| pillInput.addEventListener('click', (e) => e.stopPropagation()); |
| |
| |
| dropdownInput.addEventListener('input', function() { |
| if (searchTimer) clearTimeout(searchTimer); |
| searchTimer = setTimeout(function() { |
| searchModels(dropdownInput.value); |
| }, 300); |
| }); |
| |
| dropdownInput.addEventListener('click', (e) => e.stopPropagation()); |
| |
| |
| dropdownInput.addEventListener('keydown', function(e) { |
| const cards = dropdownContent.querySelectorAll('.dropdown-model'); |
| if (e.key === 'ArrowDown') { |
| e.preventDefault(); |
| selectedIdx = Math.min(selectedIdx + 1, cards.length - 1); |
| updateSelection(); |
| } else if (e.key === 'ArrowUp') { |
| e.preventDefault(); |
| selectedIdx = Math.max(selectedIdx - 1, -1); |
| updateSelection(); |
| } else if (e.key === 'Enter') { |
| e.preventDefault(); |
| if (selectedIdx >= 0 && selectedIdx < cards.length) { |
| const modelId = cards[selectedIdx].dataset.modelId; |
| setPillState('idle'); |
| submitCustomModel(modelId); |
| } |
| } else if (e.key === 'Escape') { |
| setPillState(customModelActive ? 'active' : 'idle'); |
| } |
| }); |
| |
| |
| document.addEventListener('click', (e) => { |
| if (pillState === 'expanded' && !document.getElementById('addModelBtn').contains(e.target)) { |
| setPillState(customModelActive ? 'active' : 'idle'); |
| } |
| }); |
| |
| |
| async function submitCustomModel(modelId) { |
| modelId = modelId.replace(/^https?:\/\/huggingface\.co\//, '').replace(/\/$/, ''); |
| setPillState('loading'); |
| |
| try { |
| const res = await fetch('https://huggingface.co/api/models/' + modelId); |
| if (!res.ok) { |
| pillError.textContent = (res.status === 404 || res.status === 401) ? 'Model not found' : 'Error ' + res.status; |
| pillError.style.display = 'block'; |
| addPill.classList.remove('loading'); |
| addPill.classList.add('expanded'); |
| pillState = 'expanded'; |
| return; |
| } |
| const data = await res.json(); |
| const fullId = data.modelId || data.id || modelId; |
| const author = fullId.includes('/') ? fullId.split('/')[0] : ''; |
| const task = data.pipeline_tag || 'unknown'; |
| const downloads = data.downloads || 1; |
| |
| let avatarUrl = ''; |
| if (author && avatarMap[author]) { |
| avatarUrl = avatarMap[author]; |
| } else if (author) { |
| try { |
| const ar = await fetch('https://huggingface.co/api/organizations/' + author + '/avatar'); |
| if (ar.ok) { |
| const aj = await ar.json(); |
| if (aj.avatarUrl) { avatarUrl = aj.avatarUrl; avatarMap[author] = aj.avatarUrl; } |
| } |
| } catch (_) {} |
| } |
| |
| if (customModelActive) removeCustomModel(); |
| |
| insertCustomModel({ |
| id: fullId, |
| downloads: downloads, |
| task: task, |
| author: author, |
| avatarUrl: avatarUrl |
| }); |
| |
| setPillState('active'); |
| } catch (err) { |
| pillError.textContent = 'Network error'; |
| pillError.style.display = 'block'; |
| addPill.classList.remove('loading'); |
| addPill.classList.add('expanded'); |
| pillState = 'expanded'; |
| } |
| } |
| |
| |
| function insertCustomModel(customModel) { |
| const idx = CUSTOM_MODEL_IDX; |
| models[idx] = customModel; |
| |
| |
| for (let j = 0; j < BASE_MODEL_COUNT; j++) { |
| preInsertX[j] = positions[j].x; |
| preInsertY[j] = positions[j].y; |
| } |
| |
| |
| const dl = Math.log(customModel.downloads); |
| const rawMass = Math.pow(dl, exponent); |
| const normVal = Math.max(0, Math.min(1, (rawMass - massMin) / massRange)); |
| if (massNorm.length <= idx) massNorm.push(normVal); |
| else massNorm[idx] = normVal; |
| |
| |
| orbitBaseOriginal[idx] = 30 + 180 * normVal; |
| |
| |
| activeModelCount = BASE_MODEL_COUNT + 1; |
| customModelActive = true; |
| |
| |
| md[idx] = { |
| x: 0, y: 0, |
| massNorm: normVal, |
| captureRadius: 0 |
| }; |
| |
| |
| const newScale = BASE_MODEL_COUNT / activeModelCount; |
| orbitScaleTarget = newScale; |
| applyOrbitScale(newScale); |
| |
| |
| totalMassNorm = 0; |
| for (let j = 0; j < activeModelCount; j++) totalMassNorm += massNorm[j]; |
| const totalTgt = PARTICLE_COUNT * TARGET_ORBIT_FRACTION; |
| for (let j = 0; j < activeModelCount; j++) { |
| modelTargetPop[j] = totalTgt * (massNorm[j] / totalMassNorm); |
| spawnWeight[j] = massNorm[j]; |
| } |
| totalSpawnWeight = totalMassNorm; |
| |
| |
| const openPos = findOpenPosition(idx); |
| md[idx].x = openPos.x; |
| md[idx].y = openPos.y; |
| if (positions.length <= idx) positions.push({ x: openPos.x, y: openPos.y }); |
| else { positions[idx].x = openPos.x; positions[idx].y = openPos.y; } |
| |
| |
| const separated = separateInPlace(); |
| for (let j = 0; j < BASE_MODEL_COUNT; j++) { |
| const dx = separated[j].x - positions[j].x; |
| const dy = separated[j].y - positions[j].y; |
| if (dx * dx + dy * dy > 4) { |
| layoutTargetX[j] = separated[j].x; |
| layoutTargetY[j] = separated[j].y; |
| layoutActive[j] = 1; |
| } |
| } |
| |
| positions[idx].x = separated[idx].x; |
| positions[idx].y = separated[idx].y; |
| md[idx].x = separated[idx].x; |
| md[idx].y = separated[idx].y; |
| |
| computeNeighbors(); |
| |
| |
| elasticDx[idx] = 0; elasticDy[idx] = 0; |
| elasticVx[idx] = 0; elasticVy[idx] = 0; |
| |
| |
| customPopScale = 0; |
| customPopVelocity = 0; |
| customPopTarget = 1; |
| |
| |
| modelRotDir[idx] = Math.random() < 0.5 ? 1 : -1; |
| } |
| |
| |
| function removeCustomModel() { |
| if (!customModelActive) return; |
| const idx = CUSTOM_MODEL_IDX; |
| |
| |
| for (let i = 0; i < PARTICLE_COUNT; i++) { |
| const p = particles[i]; |
| if (p.attractorIdx === idx) { |
| const m = md[idx]; |
| const r = p.orbitRadius || 1; |
| const rawOmega = p.angularMomentum / (r * r); |
| const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA); |
| p.vx = -Math.sin(p.angle) * omega * r; |
| p.vy = Math.cos(p.angle) * omega * r; |
| p.phase = 0; |
| p.attractorIdx = -1; |
| } |
| } |
| |
| |
| modelCaptureRadius[idx] = 0; |
| md[idx].captureRadius = 0; |
| |
| |
| customPopTarget = 0; |
| } |
| |
| })(); |
| </script> |
| </body> |
| </html> |
|
|