| | <!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> |
| |
|