Chunte's picture
Chunte HF Staff
Upload index.html
d094e2b verified
<!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;
}
/* ── Add Model Pill Button (top-right) ── */
#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;
}
/* Active state: shows model name + remove */
#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);
}
/* Loading spinner */
#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); }
}
/* ── Model Dropdown (below pill) ── */
#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">&times;</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 () {
// ── Constants ──────────────────────────────────────────────
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; // max angular velocity in orbit (rad/frame)
const MAX_OMEGA_ABSORB = 0.04; // max angular velocity during absorption spiral
const TARGET_CAPTURE_OMEGA = 0.015; // desired angular velocity at capture
// Phase 0: Free Fall
const DRAG = 0.9998;
const SPEED_CAP = 6;
const NEAR_BOOST_RADIUS = 300;
const NEAR_BOOST_FACTOR = 1.2;
// Phase 1: Outer Orbit
const SPIRAL_FRICTION = 0.0002;
const SPIRAL_FRICTION_VAR = 0.0001;
const OUTER_ORBIT_DURATION_BASE = 200;
const OUTER_ORBIT_DURATION_MASS = 400;
// Phase 3: Fade at Ring
const CONSUMPTION_SIZE_RATE = 0.03;
// Phase 4: Absorption Spiral (inward to core)
const ABSORB_CHANCE_BASE = 0.04;
const ABSORB_CHANCE_MASS = 0.06;
const ABSORB_RADIAL_DRAIN = 0.01; // angular momentum drain per frame (fraction of L)
const ABSORB_INWARD_FORCE = 0.03; // extra inward radial pull per frame
const ABSORB_MIN_RADIUS = 8;
// Capture blending (Phase 0 β†’ Phase 1 smooth transition)
const CAPTURE_BLEND_FRAMES = 30; // frames to blend cartesian β†’ polar
// Physics effects
const PRECESSION_RATE = 0.003;
const SLINGSHOT_BOOST = 1.15;
const SUBSTEP_SPEED_THRESHOLD = 3;
// Pre-simulation
const PRE_SIM_FRAMES = 300;
// Layout
const SEPARATION_PAD = 60;
const EDGE_MARGIN = 30;
// Elastic pull (iOS-style spring)
const ELASTIC_ZONE_MULT = 1.8; // elastic zone = captureRadius Γ— this
const ELASTIC_STIFFNESS = 0.06; // spring stiffness (0.05-0.12 = iOS feel)
const ELASTIC_DAMPING = 0.79; // velocity damping (lower = bouncier)
const ELASTIC_MAX_DISP = 30; // max px displacement from rest
const ELASTIC_PULL_STRENGTH = 0.35; // how strongly cursor pulls (0-1)
const ELASTIC_FALLOFF_POW = 2; // distance falloff exponent
const ELASTIC_SECONDARY = 0.3; // secondary pull for non-closest models
// ── Canvas ─────────────────────────────────────────────────
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();
// ── Data Source (trending models, sorted by downloads) ─────
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 {
// Fetch trending models, then pick the top 10 by downloads
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: ''
};
});
// Sort by downloads descending and take top 10
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();
}
// Fetch author avatars in parallel (non-blocking) β€” covers all 50 trending models
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];
}
// ── Mass Mapping ───────────────────────────────────────────
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);
// ── Per-Model Derived Values ───────────────────────────────
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;
}
// Spawn weighting β€” dynamic rebalancing
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];
}
// ── Layout: Download-Weighted Central Placement ─────────────
function computePositions() {
const cx = (canvas.width / DPR) / 2;
const cy = (canvas.height / DPR) / 2;
const maxR = Math.min(cx, cy) * 0.72;
const positions = [];
// Golden angle for even angular spread
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // ~137.5Β°
for (let i = 0; i < activeModelCount; i++) {
// massNorm[i] ranges 0..1 β€” higher = more downloads
// Radius: heaviest near center, lightest near edge
// Use sqrt to spread inner models out (avoid central clumping)
const radialT = 1 - massNorm[i]; // 0 for heaviest, 1 for lightest
const r = maxR * (0.05 + 0.95 * Math.sqrt(radialT));
// Golden angle spiral for angular spread
const angle = goldenAngle * i;
positions.push({
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
});
}
// Iterative separation pass β€” prevent overlapping capture zones
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;
// Heavier models resist displacement more
const wA = 1 - massNorm[a] * 0.7; // heavy=0.3, light=1.0
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;
}
}
}
// Pull models back toward their ideal radius from center
// Prevents separation pass from pushing heavy models to the edge
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; // gentle pull back toward ideal radius
const targetR = currentR + (idealR - currentR) * pullStrength;
if (currentR > 0.1) {
positions[i].x = cx + (dx / currentR) * targetR;
positions[i].y = cy + (dy / currentR) * targetR;
}
}
// Edge clamping
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;
}
// ── Find best open position for custom model (center-biased) ─
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));
// Minimum clearance from any existing model
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;
}
// Score: 70% center proximity, 30% clearance
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 };
}
// ── Separate models in-place (no spiral reassignment) ────────
// Starts from current positions, only resolves overlaps locally.
// Models move the minimum distance needed β€” no cross-screen flights.
function separateInPlace() {
const cx = (canvas.width / DPR) / 2;
const cy = (canvas.height / DPR) / 2;
// Clone current positions as starting points
const result = [];
for (let i = 0; i < activeModelCount; i++) {
result.push({ x: positions[i].x, y: positions[i].y });
}
// Run separation solver (same logic as computePositions but no spiral init)
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;
}
}
}
// Edge clamping
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();
// ── Model Data Array ───────────────────────────────────────
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;
}
}
// ── Smooth Layout Animation State ────────────────────────────
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;
// Saved positions before custom model insertion (for precise return on removal)
const preInsertX = new Float64Array(BASE_MODEL_COUNT);
const preInsertY = new Float64Array(BASE_MODEL_COUNT);
// Orbit scale animation (shrinks all models when custom model is added)
let orbitScale = 1.0;
let orbitScaleTarget = 1.0;
const ORBIT_SCALE_LERP = 0.05;
// Store original orbit bases (at scale 1.0)
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() {
// Animate orbit scale
if (Math.abs(orbitScale - orbitScaleTarget) > 0.001) {
orbitScale += (orbitScaleTarget - orbitScale) * ORBIT_SCALE_LERP;
applyOrbitScale(orbitScale);
}
// Animate positions toward targets
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;
}
}
}
// ── Pop Scale Animation (custom model) ──────────────────────
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; }
// Scale custom model's capture radius with pop (on top of orbit scale)
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];
}
// When shrinking and settled, finalize removal
if (customPopTarget === 0 && customPopScale < 0.001 && Math.abs(customPopVelocity) < 0.001) {
customPopScale = 0;
customPopVelocity = 0;
finalizeRemoval();
}
}
function finalizeRemoval() {
// Safety: eject any particles still referencing the custom model
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;
// Scale orbits back to full size
orbitScaleTarget = 1.0;
// Return base models to their exact pre-insertion positions
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();
}
// ── Elastic Pull State ──────────────────────────────────────
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;
// iOS dock effect: only closest model gets full strength
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;
}
}
// Damped spring physics
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];
// Settle threshold β€” kill micro-oscillations
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;
}
// Apply displacement to model position
md[j].x = positions[j].x + elasticDx[j];
md[j].y = positions[j].y + elasticDy[j];
}
}
// ── Model Neighbors (for perturbation) ─────────────────────
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();
// Frame-dragging: per-model rotation bias
const modelRotBias = new Float64Array(BASE_MODEL_COUNT + 1);
// Fixed rotation direction per model (set after pre-sim, Β±1)
const modelRotDir = new Int8Array(BASE_MODEL_COUNT + 1);
// ── Resize Handler ─────────────────────────────────────────
window.addEventListener('resize', () => {
positions = computePositions();
syncModelData();
computeNeighbors();
// Reset elastic state β€” rest positions have changed
elasticDx.fill(0); elasticDy.fill(0);
elasticVx.fill(0); elasticVy.fill(0);
});
// ── Particles ──────────────────────────────────────────────
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; }
// Aim at a dynamically-weighted random model with Β±8Β° spread
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;
}
// Initialize all particles at edges with staggered spawn frames
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]);
}
// ── Capture Helper ─────────────────────────────────────────
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);
// Angular momentum from incoming velocity
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);
// Force orbit direction to match model's locked rotation
const sign = modelRotDir[j] || 1;
// Compute L that produces TARGET_CAPTURE_OMEGA at this radius
// Blend with incoming velocity for organic variation
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;
// Minimum angular momentum floor
const minL = dist * BASE_ANGULAR * 0.5;
if (Math.abs(p.angularMomentum) < minL) {
p.angularMomentum = minL * sign;
}
// Radial velocity preserved for smooth blending (will decay during blend)
p.radialVel = vRad;
p.blendTimer = CAPTURE_BLEND_FRAMES;
p.spiralFriction = SPIRAL_FRICTION + (Math.random() - 0.5) * SPIRAL_FRICTION_VAR * 2;
// Orbit duration: heavier models hold particles longer β†’ denser rings
p.orbitTimer = 0;
p.orbitDuration = OUTER_ORBIT_DURATION_BASE + OUTER_ORBIT_DURATION_MASS * m.massNorm;
}
// ── Label Bounce Deflection ────────────────────────────────
const BOUNCE_RESTITUTION = 0.85; // energy retained β€” snappy cartoon bounce
const BOUNCE_MIN_KICK = 2.25; // outward kick on bounce (75%)
const BOUNCE_ORBIT_PUNCH = 3.375; // radial punch for orbiting particles (75%)
function deflectFromLabel(p) {
if (!labelBounceRect || labelBounceAlpha <= 0) return;
const b = labelBounceRect;
const alpha = labelBounceAlpha;
// Effective zone scales with animation alpha
const ehw = b.hw * alpha;
const ehh = b.hh * alpha;
// Signed distance from particle to rect center
const dx = p.x - b.cx;
const dy = p.y - b.cy;
// Quick bounding-box rejection
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (ax > ehw || ay > ehh) return;
// Inside the bounding box β€” find shortest exit wall
const overlapX = ehw - ax;
const overlapY = ehh - ay;
// Wall normal direction (points outward from label center)
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) {
// Free-fall: reflect velocity off the wall normal with cartoon punch
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;
}
// Strong outward kick
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) {
// Orbiting particle: convert orbital state to cartesian, reflect, convert back
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;
// Reflect velocity off wall normal
const vDotN = ovx * nx + ovy * ny;
if (vDotN < 0) {
ovx -= 2 * vDotN * nx;
ovy -= 2 * vDotN * ny;
ovx *= BOUNCE_RESTITUTION;
ovy *= BOUNCE_RESTITUTION;
}
// Cartoon punch: strong outward kick along the wall normal
ovx += nx * BOUNCE_ORBIT_PUNCH;
ovy += ny * BOUNCE_ORBIT_PUNCH;
// Recompute polar coordinates from new position
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);
// Decompose reflected velocity back to tangential + radial
const newVRad = ovx * newCosA + ovy * newSinA;
const newVTan = -ovx * newSinA + ovy * newCosA;
// Update polar state β€” the big radial kick creates a visible outward arc
p.orbitRadius = newR;
p.angle = newAngle;
p.angularMomentum = newVTan * newR;
p.radialVel = newVRad;
// Longer blend so the bounce arc plays out before orbit reasserts
if (p.phase === 1 && p.blendTimer <= 0) {
p.blendTimer = 30;
}
}
}
// ── Particle Update ────────────────────────────────────────
function updateParticle(p, w, h) {
// Phase 0: Free Fall
if (p.phase === 0) {
let captured = false;
// Accumulate gravity from all models
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;
// Gravity: inverse-square near, softened at range
// At long range, use 1/(dist*softDist) instead of 1/distΒ² to prevent stalling
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;
}
// Drag + speed cap
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;
}
// Sub-stepping for fast particles
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;
// Check capture at each sub-step
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) {
// Slingshot: if near 2+ models, boost velocity
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;
}
// Out of bounds respawn
if (p.x < -50 || p.x > w + 50 || p.y < -50 || p.y > h + 50) {
spawnAtEdge(p);
}
}
return;
}
// Phase 1: Outer Orbit
if (p.phase === 1) {
const m = md[p.attractorIdx];
const r = p.orbitRadius;
// Angular velocity from conserved angular momentum
const rawOmega = p.angularMomentum / (r * r);
const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA);
// Advance angle with precession
p.angle += omega + PRECESSION_RATE * Math.sign(omega);
// ── Capture blending: smooth cartesianβ†’polar transition ──
// During blend, the particle still has residual radial velocity
// from its free-fall trajectory. Decay it over CAPTURE_BLEND_FRAMES.
if (p.blendTimer > 0) {
p.blendTimer--;
// Radial velocity decays smoothly toward zero
p.radialVel *= 0.88;
// Apply residual radial motion to orbit radius
p.orbitRadius += p.radialVel;
} else {
// Very slow inward drift (ring thickness, not spiral to core)
p.orbitRadius -= r * p.spiralFriction;
}
// Position from polar coordinates
p.x = m.x + p.orbitRadius * Math.cos(p.angle);
p.y = m.y + p.orbitRadius * Math.sin(p.angle);
// Gravitational perturbation from neighboring models
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;
}
// Ejection check
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;
}
// Timer: transition to Phase 3 (fade at ring) or Phase 4 (absorption spiral)
p.orbitTimer++;
if (p.orbitTimer >= p.orbitDuration) {
const absorbChance = ABSORB_CHANCE_BASE + ABSORB_CHANCE_MASS * md[p.attractorIdx].massNorm;
if (Math.random() < absorbChance) {
// Phase 4: begin absorption β€” continuous from current orbital state
p.phase = 4;
p.radialVel = 0; // will build naturally from inward force
} else {
p.phase = 3;
}
}
return;
}
// Phase 3: Fade at Ring (particles stay near outer edge)
if (p.phase === 3) {
const m = md[p.attractorIdx];
// Continue orbiting at current radius (no inward spiral)
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);
// Mass-dependent fade: lighter models shed particles faster
const fadeRate = CONSUMPTION_SIZE_RATE * (1 + 0.5 * (1 - m.massNorm));
p.size -= fadeRate;
if (p.size <= 0) {
spawnAtEdge(p);
}
return;
}
// Phase 4: Absorption Spiral (inward to core β€” black hole consumption)
// Continuous from Phase 1: same orbital physics but with energy drain
{
const m = md[p.attractorIdx];
// Drain angular momentum β€” the particle loses orbital energy
p.angularMomentum *= (1 - ABSORB_RADIAL_DRAIN);
// Radial inward velocity builds up from gravitational pull
p.radialVel -= ABSORB_INWARD_FORCE;
p.radialVel *= 0.96; // friction prevents runaway plunge
// Apply radial motion β€” orbit radius shrinks naturally
p.orbitRadius += p.radialVel;
// Clamp minimum radius
if (p.orbitRadius < 1) p.orbitRadius = 1;
// Angular velocity from (draining) angular momentum β€” higher cap for absorption spiral
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);
// Position from polar coordinates (continuous from Phase 1)
p.x = m.x + p.orbitRadius * Math.cos(p.angle);
p.y = m.y + p.orbitRadius * Math.sin(p.angle);
// Size stays full until near core, then shrinks rapidly
if (p.orbitRadius < ABSORB_MIN_RADIUS * 4) {
p.size -= 0.05;
}
// Die when reaching core or size gone
if (p.orbitRadius < ABSORB_MIN_RADIUS || p.size <= 0) {
spawnAtEdge(p);
}
}
}
// ── Pre-Simulation ─────────────────────────────────────────
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);
}
}
}
// Lock each model's rotation direction based on pre-sim consensus
for (let j = 0; j < activeModelCount; j++) {
modelRotDir[j] = modelRotBias[j] !== 0 ? modelRotBias[j] : (Math.random() < 0.5 ? 1 : -1);
}
// ── Task Icons (SVG assets, colored to match theme) ─────────
const ICON_SIZE = 11.5; // icon half-size (+15%)
const ICON_PAD = 7; // padding around icon for BG pill (+15%)
const ICON_CORNER = 6; // rounded corner radius for BG pill (+15%)
const ICON_DRAW_SIZE = ICON_SIZE * 2; // full icon draw dimension
const ICON_RENDER_SIZE = Math.ceil(ICON_DRAW_SIZE * Math.max(window.devicePixelRatio || 1, 2) * 2); // 4x supersampled raster
// Map HuggingFace pipeline_tag β†’ SVG asset filename
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',
};
// Pre-loaded icon images: taskIconImgs[task][colorHex] = Image
const taskIconImgs = {};
const ICON_ASSET_DIR = 'model task icon assets/';
function buildColoredSVGImage(svgText, hexColor, renderSize) {
// Replace fill="black" and stroke="black" with the target color
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 + '"');
// Override SVG width/height so the browser rasterizes at hi-DPI resolution
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;
}
// Load SVG text via XHR (works on file:// unlike fetch)
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();
});
}
// Preload all icon SVGs in both COLOR and ORANGE variants
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;
// Wait for decode
await img.decode().catch(() => {});
}
} catch (_) {}
}));
}
// Start preloading immediately
preloadTaskIcons();
function drawTaskIcon(ctx, x, y, task, iconColor) {
const s = ICON_SIZE;
const pad = ICON_PAD;
const ic = iconColor || COLOR;
// Draw BG pill (same color as screen background) to mask particles behind icon
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();
// Draw SVG icon image
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);
}
}
// ── Render Loop ────────────────────────────────────────────
const PS2 = PARTICLE_SIZE * 2;
function frame() {
const w = canvas.width / DPR;
const h = canvas.height / DPR;
// Clear
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
// Compute per-model rotation bias
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]);
}
// Rebalance spawn weights every 60 frames (~1 second)
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;
}
// Animate label bounce zone
// Pinned label always keeps bounce active; hover tooltip fades in/out
if (hoveredModel >= 0 || pinnedLabelBounceActive) {
labelBounceAlpha = Math.min(1, labelBounceAlpha + 0.08);
} else {
labelBounceAlpha = Math.max(0, labelBounceAlpha - 0.06);
if (labelBounceAlpha <= 0) labelBounceRect = null;
}
// Animate pop scale for custom model
updatePopAnimation();
// Animate layout transitions (position + orbit scale)
updateLayoutAnimation();
// Update elastic pull (before particles read md[j].x/y)
updateElasticPull();
// Update all particles
const doDeflect = labelBounceAlpha > 0;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = particles[i];
updateParticle(p, w, h);
if (doDeflect) deflectFromLabel(p);
// Orange blend: ramp toward 1 if orbiting custom model, toward 0 otherwise
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);
}
}
// ── Draw ──
ctx.fillStyle = COLOR;
ctx.strokeStyle = COLOR;
ctx.lineWidth = 1;
// Batch velocity streaks into one path
ctx.beginPath();
let hasStreaks = false;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = particles[i];
if (p.size <= 0) continue;
// Velocity streaking for fast free-fall particles
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;
}
}
// Shrinking particles during fade (Phase 3) or final absorption (Phase 4 near core)
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();
// ── Orange particle pass: redraw particles with orangeBlend > 0 ──
// COLOR=#161513 β†’ bright orange #FF9F1C interpolation
// Parse once (constants)
// COLOR rgb: 22, 21, 19 BRIGHT ORANGE rgb: 255, 159, 28
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);
}
}
// Draw model cores last β€” task icons
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);
}
}
// Update pinned label position (follows elastic pull)
pinnedLabel.style.left = md[topModelIdx].x + 'px';
pinnedLabel.style.top = (md[topModelIdx].y + CORE_RADIUS + 12) + 'px';
pinnedLabel.style.transform = 'translateX(-50%)';
// Keep pinned label bounce zone updated (only when no other hover is active)
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);
// ── Hover Tooltip ──────────────────────────────────────────
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; // {cx, cy, hw, hh} β€” center + half-dims of bounce pill
let labelBounceAlpha = 0; // 0..1 animation progress (0=off, 1=full bounce)
// ── Pinned label for top model (always visible) ─────────
const topModelIdx = 0; // models already sorted by downloads desc
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) => {
// Global cursor state for elastic pull
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) {
// Top model: pinned label already visible β€” kill hover tooltip instantly
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 = ''; // clear inline override, let CSS class control
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');
// Position tooltip so we can measure it
tooltip.style.left = mpos.x + 'px';
tooltip.style.top = (mpos.y + CORE_RADIUS + 12) + 'px';
tooltip.style.transform = 'translateX(-50%)';
// Compute bounce zone from tooltip dimensions
const rect = tooltip.getBoundingClientRect();
const pad = 14; // breathing room around text
labelBounceRect = {
cx: mpos.x,
cy: mpos.y + CORE_RADIUS + 12 + rect.height / 2,
hw: rect.width / 2 + pad,
hh: rect.height / 2 + pad + 8, // extra top padding to cover core-tooltip gap
};
} else {
tooltip.classList.remove('visible');
// labelBounceRect stays set β€” will animate out via labelBounceAlpha
}
}
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; // triggers spring snapback
});
// ── Custom Model: Pill Button + Dropdown Logic ──────────────
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');
// Pill states: 'idle' | 'expanded' | 'loading' | 'active'
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();
}
}
// ── Dropdown open/close ──
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);
}
// ── Dropdown rendering ──
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>';
}
}
// ── Pill event listeners ──
// Idle β†’ click β†’ expand
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();
}
}
});
// Remove button
pillRemoveBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeCustomModel();
setPillState('idle');
});
// Add button
pillAddBtn.addEventListener('click', (e) => {
e.stopPropagation();
const val = pillInput.value.trim();
if (val) submitCustomModel(val);
});
// Enter key in pill input β†’ submit typed model
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());
// Dropdown search input
dropdownInput.addEventListener('input', function() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
searchModels(dropdownInput.value);
}, 300);
});
dropdownInput.addEventListener('click', (e) => e.stopPropagation());
// Keyboard navigation in dropdown search
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');
}
});
// Click outside β†’ collapse pill + close dropdown
document.addEventListener('click', (e) => {
if (pillState === 'expanded' && !document.getElementById('addModelBtn').contains(e.target)) {
setPillState(customModelActive ? 'active' : 'idle');
}
});
// ── submitCustomModel: fetch model data from HF API ─────────
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';
}
}
// ── insertCustomModel: add to physics world ─────────────────
function insertCustomModel(customModel) {
const idx = CUSTOM_MODEL_IDX;
models[idx] = customModel;
// Save base model positions BEFORE any changes (for precise return on removal)
for (let j = 0; j < BASE_MODEL_COUNT; j++) {
preInsertX[j] = positions[j].x;
preInsertY[j] = positions[j].y;
}
// Compute mass normalization relative to existing base range
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;
// Store original orbit base for the custom model
orbitBaseOriginal[idx] = 30 + 180 * normVal;
// Activate
activeModelCount = BASE_MODEL_COUNT + 1;
customModelActive = true;
// Create md entry BEFORE applyOrbitScale (which reads md[j].captureRadius)
md[idx] = {
x: 0, y: 0,
massNorm: normVal,
captureRadius: 0
};
// Shrink all orbits to fit the extra model (scale factor)
const newScale = BASE_MODEL_COUNT / activeModelCount;
orbitScaleTarget = newScale;
applyOrbitScale(newScale);
// Recompute total mass norm and spawn weights
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;
// Find best open position for custom model (center-biased, clearance-aware)
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; }
// Resolve any overlaps locally β€” models only move minimum distance needed
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;
}
}
// Update custom model position from separation too
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();
// Reset elastic state for custom model only
elasticDx[idx] = 0; elasticDy[idx] = 0;
elasticVx[idx] = 0; elasticVy[idx] = 0;
// Start pop-in animation (0% β†’ 100% with elastic overshoot)
customPopScale = 0;
customPopVelocity = 0;
customPopTarget = 1;
// Set rotation direction (random)
modelRotDir[idx] = Math.random() < 0.5 ? 1 : -1;
}
// ── removeCustomModel: eject particles, start pop-out ────────
function removeCustomModel() {
if (!customModelActive) return;
const idx = CUSTOM_MODEL_IDX;
// Eject all particles orbiting the custom model back to free-fall
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;
}
}
// Prevent new captures during pop-out
modelCaptureRadius[idx] = 0;
md[idx].captureRadius = 0;
// Start pop-out animation (finalizeRemoval called when scale reaches 0)
customPopTarget = 0;
}
})();
</script>
</body>
</html>