Upload index.html
Browse files- index.html +275 -40
index.html
CHANGED
|
@@ -476,6 +476,89 @@ function computePositions() {
|
|
| 476 |
return positions;
|
| 477 |
}
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
let positions = computePositions();
|
| 480 |
|
| 481 |
// ββ Model Data Array βββββββββββββββββββββββββββββββββββββββ
|
|
@@ -496,6 +579,124 @@ function syncModelData() {
|
|
| 496 |
}
|
| 497 |
}
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
// ββ Elastic Pull State ββββββββββββββββββββββββββββββββββββββ
|
| 500 |
const elasticDx = new Float64Array(BASE_MODEL_COUNT + 1);
|
| 501 |
const elasticDy = new Float64Array(BASE_MODEL_COUNT + 1);
|
|
@@ -1240,6 +1441,12 @@ function frame() {
|
|
| 1240 |
if (labelBounceAlpha <= 0) labelBounceRect = null;
|
| 1241 |
}
|
| 1242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
// Update elastic pull (before particles read md[j].x/y)
|
| 1244 |
updateElasticPull();
|
| 1245 |
|
|
@@ -1333,7 +1540,18 @@ function frame() {
|
|
| 1333 |
// Draw model cores last β task icons
|
| 1334 |
for (let j = 0; j < activeModelCount; j++) {
|
| 1335 |
const iconCol = (customModelActive && j === CUSTOM_MODEL_IDX) ? ORANGE : COLOR;
|
| 1336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1337 |
}
|
| 1338 |
|
| 1339 |
// Update pinned label position (follows elastic pull)
|
|
@@ -1682,28 +1900,37 @@ function insertCustomModel(customModel) {
|
|
| 1682 |
const idx = CUSTOM_MODEL_IDX;
|
| 1683 |
models[idx] = customModel;
|
| 1684 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1685 |
// Compute mass normalization relative to existing base range
|
| 1686 |
const dl = Math.log(customModel.downloads);
|
| 1687 |
const rawMass = Math.pow(dl, exponent);
|
| 1688 |
const normVal = Math.max(0, Math.min(1, (rawMass - massMin) / massRange));
|
| 1689 |
-
// Extend massNorm array
|
| 1690 |
if (massNorm.length <= idx) massNorm.push(normVal);
|
| 1691 |
else massNorm[idx] = normVal;
|
| 1692 |
|
| 1693 |
-
//
|
| 1694 |
-
|
| 1695 |
-
|
|
|
|
|
|
|
|
|
|
| 1696 |
|
| 1697 |
-
// Create md entry
|
| 1698 |
md[idx] = {
|
| 1699 |
x: 0, y: 0,
|
| 1700 |
massNorm: normVal,
|
| 1701 |
-
captureRadius:
|
| 1702 |
};
|
| 1703 |
|
| 1704 |
-
//
|
| 1705 |
-
|
| 1706 |
-
|
|
|
|
| 1707 |
|
| 1708 |
// Recompute total mass norm and spawn weights
|
| 1709 |
totalMassNorm = 0;
|
|
@@ -1715,20 +1942,46 @@ function insertCustomModel(customModel) {
|
|
| 1715 |
}
|
| 1716 |
totalSpawnWeight = totalMassNorm;
|
| 1717 |
|
| 1718 |
-
//
|
| 1719 |
-
|
| 1720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1721 |
computeNeighbors();
|
| 1722 |
|
| 1723 |
-
// Reset elastic state
|
| 1724 |
-
elasticDx
|
| 1725 |
-
elasticVx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1726 |
|
| 1727 |
// Set rotation direction (random)
|
| 1728 |
modelRotDir[idx] = Math.random() < 0.5 ? 1 : -1;
|
| 1729 |
}
|
| 1730 |
|
| 1731 |
-
// ββ removeCustomModel: eject particles,
|
| 1732 |
function removeCustomModel() {
|
| 1733 |
if (!customModelActive) return;
|
| 1734 |
const idx = CUSTOM_MODEL_IDX;
|
|
@@ -1737,7 +1990,6 @@ function removeCustomModel() {
|
|
| 1737 |
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
| 1738 |
const p = particles[i];
|
| 1739 |
if (p.attractorIdx === idx) {
|
| 1740 |
-
// Convert orbital state to cartesian velocity
|
| 1741 |
const m = md[idx];
|
| 1742 |
const r = p.orbitRadius || 1;
|
| 1743 |
const rawOmega = p.angularMomentum / (r * r);
|
|
@@ -1746,32 +1998,15 @@ function removeCustomModel() {
|
|
| 1746 |
p.vy = Math.cos(p.angle) * omega * r;
|
| 1747 |
p.phase = 0;
|
| 1748 |
p.attractorIdx = -1;
|
| 1749 |
-
// orangeBlend will decay naturally in updateParticle
|
| 1750 |
}
|
| 1751 |
}
|
| 1752 |
|
| 1753 |
-
//
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
|
| 1757 |
-
//
|
| 1758 |
-
|
| 1759 |
-
for (let j = 0; j < activeModelCount; j++) totalMassNorm += massNorm[j];
|
| 1760 |
-
const totalTgt = PARTICLE_COUNT * TARGET_ORBIT_FRACTION;
|
| 1761 |
-
for (let j = 0; j < activeModelCount; j++) {
|
| 1762 |
-
modelTargetPop[j] = totalTgt * (massNorm[j] / totalMassNorm);
|
| 1763 |
-
spawnWeight[j] = massNorm[j];
|
| 1764 |
-
}
|
| 1765 |
-
totalSpawnWeight = totalMassNorm;
|
| 1766 |
-
|
| 1767 |
-
// Recompute layout
|
| 1768 |
-
positions = computePositions();
|
| 1769 |
-
syncModelData();
|
| 1770 |
-
computeNeighbors();
|
| 1771 |
-
|
| 1772 |
-
// Reset elastic state
|
| 1773 |
-
elasticDx.fill(0); elasticDy.fill(0);
|
| 1774 |
-
elasticVx.fill(0); elasticVy.fill(0);
|
| 1775 |
}
|
| 1776 |
|
| 1777 |
})();
|
|
|
|
| 476 |
return positions;
|
| 477 |
}
|
| 478 |
|
| 479 |
+
// ββ Find best open position for custom model (center-biased) β
|
| 480 |
+
function findOpenPosition(idx) {
|
| 481 |
+
const w = canvas.width / DPR;
|
| 482 |
+
const h = canvas.height / DPR;
|
| 483 |
+
const cx = w / 2, cy = h / 2;
|
| 484 |
+
const myR = modelCaptureRadius[idx];
|
| 485 |
+
const margin = myR + EDGE_MARGIN;
|
| 486 |
+
const cols = 20, rows = 20;
|
| 487 |
+
let bestX = cx, bestY = cy, bestScore = -Infinity;
|
| 488 |
+
const maxDist = Math.sqrt(cx * cx + cy * cy);
|
| 489 |
+
|
| 490 |
+
for (let r = 0; r < rows; r++) {
|
| 491 |
+
for (let c = 0; c < cols; c++) {
|
| 492 |
+
const px = margin + (w - 2 * margin) * (c / (cols - 1));
|
| 493 |
+
const py = margin + (h - 2 * margin) * (r / (rows - 1));
|
| 494 |
+
// Minimum clearance from any existing model
|
| 495 |
+
let minClear = Infinity;
|
| 496 |
+
for (let j = 0; j < activeModelCount; j++) {
|
| 497 |
+
if (j === idx) continue;
|
| 498 |
+
const dx = px - positions[j].x, dy = py - positions[j].y;
|
| 499 |
+
const dist = Math.sqrt(dx * dx + dy * dy) - modelCaptureRadius[j] - myR;
|
| 500 |
+
if (dist < minClear) minClear = dist;
|
| 501 |
+
}
|
| 502 |
+
// Score: 70% center proximity, 30% clearance
|
| 503 |
+
const centerScore = 1 - Math.sqrt((px - cx) ** 2 + (py - cy) ** 2) / maxDist;
|
| 504 |
+
const clearScore = Math.max(0, Math.min(minClear / 200, 1));
|
| 505 |
+
const score = centerScore * 0.7 + clearScore * 0.3;
|
| 506 |
+
if (score > bestScore) {
|
| 507 |
+
bestScore = score;
|
| 508 |
+
bestX = px; bestY = py;
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
return { x: bestX, y: bestY };
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// ββ Separate models in-place (no spiral reassignment) ββββββββ
|
| 516 |
+
// Starts from current positions, only resolves overlaps locally.
|
| 517 |
+
// Models move the minimum distance needed β no cross-screen flights.
|
| 518 |
+
function separateInPlace() {
|
| 519 |
+
const cx = (canvas.width / DPR) / 2;
|
| 520 |
+
const cy = (canvas.height / DPR) / 2;
|
| 521 |
+
// Clone current positions as starting points
|
| 522 |
+
const result = [];
|
| 523 |
+
for (let i = 0; i < activeModelCount; i++) {
|
| 524 |
+
result.push({ x: positions[i].x, y: positions[i].y });
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
// Run separation solver (same logic as computePositions but no spiral init)
|
| 528 |
+
for (let iter = 0; iter < 200; iter++) {
|
| 529 |
+
let moved = false;
|
| 530 |
+
for (let a = 0; a < activeModelCount; a++) {
|
| 531 |
+
for (let b = a + 1; b < activeModelCount; b++) {
|
| 532 |
+
const dx = result[b].x - result[a].x;
|
| 533 |
+
const dy = result[b].y - result[a].y;
|
| 534 |
+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
| 535 |
+
const minDist = modelCaptureRadius[a] + modelCaptureRadius[b] + SEPARATION_PAD;
|
| 536 |
+
if (dist < minDist) {
|
| 537 |
+
const overlap = (minDist - dist) / 2;
|
| 538 |
+
const nx = dx / dist;
|
| 539 |
+
const ny = dy / dist;
|
| 540 |
+
const wA = 1 - massNorm[a] * 0.7;
|
| 541 |
+
const wB = 1 - massNorm[b] * 0.7;
|
| 542 |
+
const total = wA + wB;
|
| 543 |
+
result[a].x -= nx * overlap * (wA / total);
|
| 544 |
+
result[a].y -= ny * overlap * (wA / total);
|
| 545 |
+
result[b].x += nx * overlap * (wB / total);
|
| 546 |
+
result[b].y += ny * overlap * (wB / total);
|
| 547 |
+
moved = true;
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
// Edge clamping
|
| 552 |
+
for (let i = 0; i < activeModelCount; i++) {
|
| 553 |
+
const r = modelCaptureRadius[i];
|
| 554 |
+
result[i].x = Math.max(r + EDGE_MARGIN, Math.min(canvas.width / DPR - r - EDGE_MARGIN, result[i].x));
|
| 555 |
+
result[i].y = Math.max(r + EDGE_MARGIN, Math.min(canvas.height / DPR - r - EDGE_MARGIN, result[i].y));
|
| 556 |
+
}
|
| 557 |
+
if (!moved) break;
|
| 558 |
+
}
|
| 559 |
+
return result;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
let positions = computePositions();
|
| 563 |
|
| 564 |
// ββ Model Data Array βββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 579 |
}
|
| 580 |
}
|
| 581 |
|
| 582 |
+
// ββ Smooth Layout Animation State ββββββββββββββββββββββββββββ
|
| 583 |
+
const layoutTargetX = new Float64Array(BASE_MODEL_COUNT + 1);
|
| 584 |
+
const layoutTargetY = new Float64Array(BASE_MODEL_COUNT + 1);
|
| 585 |
+
const layoutActive = new Uint8Array(BASE_MODEL_COUNT + 1);
|
| 586 |
+
const LAYOUT_LERP = 0.07;
|
| 587 |
+
|
| 588 |
+
// Saved positions before custom model insertion (for precise return on removal)
|
| 589 |
+
const preInsertX = new Float64Array(BASE_MODEL_COUNT);
|
| 590 |
+
const preInsertY = new Float64Array(BASE_MODEL_COUNT);
|
| 591 |
+
|
| 592 |
+
// Orbit scale animation (shrinks all models when custom model is added)
|
| 593 |
+
let orbitScale = 1.0;
|
| 594 |
+
let orbitScaleTarget = 1.0;
|
| 595 |
+
const ORBIT_SCALE_LERP = 0.05;
|
| 596 |
+
|
| 597 |
+
// Store original orbit bases (at scale 1.0)
|
| 598 |
+
const orbitBaseOriginal = new Float64Array(BASE_MODEL_COUNT + 1);
|
| 599 |
+
for (let i = 0; i < BASE_MODEL_COUNT; i++) {
|
| 600 |
+
orbitBaseOriginal[i] = modelOrbitBase[i];
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
function applyOrbitScale(scale) {
|
| 604 |
+
for (let j = 0; j < activeModelCount; j++) {
|
| 605 |
+
modelOrbitBase[j] = orbitBaseOriginal[j] * scale;
|
| 606 |
+
modelCaptureRadius[j] = modelOrbitBase[j] * 1.2;
|
| 607 |
+
md[j].captureRadius = modelCaptureRadius[j];
|
| 608 |
+
}
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
function updateLayoutAnimation() {
|
| 612 |
+
// Animate orbit scale
|
| 613 |
+
if (Math.abs(orbitScale - orbitScaleTarget) > 0.001) {
|
| 614 |
+
orbitScale += (orbitScaleTarget - orbitScale) * ORBIT_SCALE_LERP;
|
| 615 |
+
applyOrbitScale(orbitScale);
|
| 616 |
+
}
|
| 617 |
+
// Animate positions toward targets
|
| 618 |
+
for (let j = 0; j < activeModelCount; j++) {
|
| 619 |
+
if (!layoutActive[j]) continue;
|
| 620 |
+
const dx = layoutTargetX[j] - positions[j].x;
|
| 621 |
+
const dy = layoutTargetY[j] - positions[j].y;
|
| 622 |
+
if (dx * dx + dy * dy < 1) {
|
| 623 |
+
positions[j].x = layoutTargetX[j];
|
| 624 |
+
positions[j].y = layoutTargetY[j];
|
| 625 |
+
layoutActive[j] = 0;
|
| 626 |
+
} else {
|
| 627 |
+
positions[j].x += dx * LAYOUT_LERP;
|
| 628 |
+
positions[j].y += dy * LAYOUT_LERP;
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
// ββ Pop Scale Animation (custom model) ββββββββββββββββββββββ
|
| 634 |
+
let customPopScale = 0;
|
| 635 |
+
let customPopTarget = 0;
|
| 636 |
+
let customPopVelocity = 0;
|
| 637 |
+
const POP_STIFFNESS = 0.08;
|
| 638 |
+
const POP_DAMPING = 0.72;
|
| 639 |
+
|
| 640 |
+
function updatePopAnimation() {
|
| 641 |
+
if (!customModelActive && customPopScale <= 0.001) return;
|
| 642 |
+
const force = (customPopTarget - customPopScale) * POP_STIFFNESS;
|
| 643 |
+
customPopVelocity = customPopVelocity * POP_DAMPING + force;
|
| 644 |
+
customPopScale += customPopVelocity;
|
| 645 |
+
if (customPopScale < 0) { customPopScale = 0; customPopVelocity = 0; }
|
| 646 |
+
// Scale custom model's capture radius with pop (on top of orbit scale)
|
| 647 |
+
if (customModelActive && customPopTarget === 1) {
|
| 648 |
+
const idx = CUSTOM_MODEL_IDX;
|
| 649 |
+
const baseR = orbitBaseOriginal[idx] * orbitScale;
|
| 650 |
+
modelOrbitBase[idx] = baseR;
|
| 651 |
+
modelCaptureRadius[idx] = baseR * 1.2 * customPopScale;
|
| 652 |
+
md[idx].captureRadius = modelCaptureRadius[idx];
|
| 653 |
+
}
|
| 654 |
+
// When shrinking and settled, finalize removal
|
| 655 |
+
if (customPopTarget === 0 && customPopScale < 0.001 && Math.abs(customPopVelocity) < 0.001) {
|
| 656 |
+
customPopScale = 0;
|
| 657 |
+
customPopVelocity = 0;
|
| 658 |
+
finalizeRemoval();
|
| 659 |
+
}
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
function finalizeRemoval() {
|
| 663 |
+
// Safety: eject any particles still referencing the custom model
|
| 664 |
+
const cidx = CUSTOM_MODEL_IDX;
|
| 665 |
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
| 666 |
+
const p = particles[i];
|
| 667 |
+
if (p.attractorIdx === cidx) {
|
| 668 |
+
p.phase = 0;
|
| 669 |
+
p.attractorIdx = -1;
|
| 670 |
+
}
|
| 671 |
+
}
|
| 672 |
+
activeModelCount = BASE_MODEL_COUNT;
|
| 673 |
+
customModelActive = false;
|
| 674 |
+
totalMassNorm = 0;
|
| 675 |
+
for (let j = 0; j < activeModelCount; j++) totalMassNorm += massNorm[j];
|
| 676 |
+
const totalTgt = PARTICLE_COUNT * TARGET_ORBIT_FRACTION;
|
| 677 |
+
for (let j = 0; j < activeModelCount; j++) {
|
| 678 |
+
modelTargetPop[j] = totalTgt * (massNorm[j] / totalMassNorm);
|
| 679 |
+
spawnWeight[j] = massNorm[j];
|
| 680 |
+
}
|
| 681 |
+
totalSpawnWeight = totalMassNorm;
|
| 682 |
+
// Scale orbits back to full size
|
| 683 |
+
orbitScaleTarget = 1.0;
|
| 684 |
+
// Return base models to their exact pre-insertion positions
|
| 685 |
+
for (let j = 0; j < activeModelCount; j++) {
|
| 686 |
+
const dx = preInsertX[j] - positions[j].x;
|
| 687 |
+
const dy = preInsertY[j] - positions[j].y;
|
| 688 |
+
if (dx * dx + dy * dy > 1) {
|
| 689 |
+
layoutTargetX[j] = preInsertX[j];
|
| 690 |
+
layoutTargetY[j] = preInsertY[j];
|
| 691 |
+
layoutActive[j] = 1;
|
| 692 |
+
} else {
|
| 693 |
+
positions[j].x = preInsertX[j];
|
| 694 |
+
positions[j].y = preInsertY[j];
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
computeNeighbors();
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
// ββ Elastic Pull State ββββββββββββββββββββββββββββββββββββββ
|
| 701 |
const elasticDx = new Float64Array(BASE_MODEL_COUNT + 1);
|
| 702 |
const elasticDy = new Float64Array(BASE_MODEL_COUNT + 1);
|
|
|
|
| 1441 |
if (labelBounceAlpha <= 0) labelBounceRect = null;
|
| 1442 |
}
|
| 1443 |
|
| 1444 |
+
// Animate pop scale for custom model
|
| 1445 |
+
updatePopAnimation();
|
| 1446 |
+
|
| 1447 |
+
// Animate layout transitions (position + orbit scale)
|
| 1448 |
+
updateLayoutAnimation();
|
| 1449 |
+
|
| 1450 |
// Update elastic pull (before particles read md[j].x/y)
|
| 1451 |
updateElasticPull();
|
| 1452 |
|
|
|
|
| 1540 |
// Draw model cores last β task icons
|
| 1541 |
for (let j = 0; j < activeModelCount; j++) {
|
| 1542 |
const iconCol = (customModelActive && j === CUSTOM_MODEL_IDX) ? ORANGE : COLOR;
|
| 1543 |
+
if (customModelActive && j === CUSTOM_MODEL_IDX && customPopScale < 1) {
|
| 1544 |
+
const sc = customPopScale;
|
| 1545 |
+
if (sc > 0.01) {
|
| 1546 |
+
ctx.save();
|
| 1547 |
+
ctx.translate(md[j].x, md[j].y);
|
| 1548 |
+
ctx.scale(sc, sc);
|
| 1549 |
+
drawTaskIcon(ctx, 0, 0, models[j].task, iconCol);
|
| 1550 |
+
ctx.restore();
|
| 1551 |
+
}
|
| 1552 |
+
} else {
|
| 1553 |
+
drawTaskIcon(ctx, md[j].x, md[j].y, models[j].task, iconCol);
|
| 1554 |
+
}
|
| 1555 |
}
|
| 1556 |
|
| 1557 |
// Update pinned label position (follows elastic pull)
|
|
|
|
| 1900 |
const idx = CUSTOM_MODEL_IDX;
|
| 1901 |
models[idx] = customModel;
|
| 1902 |
|
| 1903 |
+
// Save base model positions BEFORE any changes (for precise return on removal)
|
| 1904 |
+
for (let j = 0; j < BASE_MODEL_COUNT; j++) {
|
| 1905 |
+
preInsertX[j] = positions[j].x;
|
| 1906 |
+
preInsertY[j] = positions[j].y;
|
| 1907 |
+
}
|
| 1908 |
+
|
| 1909 |
// Compute mass normalization relative to existing base range
|
| 1910 |
const dl = Math.log(customModel.downloads);
|
| 1911 |
const rawMass = Math.pow(dl, exponent);
|
| 1912 |
const normVal = Math.max(0, Math.min(1, (rawMass - massMin) / massRange));
|
|
|
|
| 1913 |
if (massNorm.length <= idx) massNorm.push(normVal);
|
| 1914 |
else massNorm[idx] = normVal;
|
| 1915 |
|
| 1916 |
+
// Store original orbit base for the custom model
|
| 1917 |
+
orbitBaseOriginal[idx] = 30 + 180 * normVal;
|
| 1918 |
+
|
| 1919 |
+
// Activate
|
| 1920 |
+
activeModelCount = BASE_MODEL_COUNT + 1;
|
| 1921 |
+
customModelActive = true;
|
| 1922 |
|
| 1923 |
+
// Create md entry BEFORE applyOrbitScale (which reads md[j].captureRadius)
|
| 1924 |
md[idx] = {
|
| 1925 |
x: 0, y: 0,
|
| 1926 |
massNorm: normVal,
|
| 1927 |
+
captureRadius: 0
|
| 1928 |
};
|
| 1929 |
|
| 1930 |
+
// Shrink all orbits to fit the extra model (scale factor)
|
| 1931 |
+
const newScale = BASE_MODEL_COUNT / activeModelCount;
|
| 1932 |
+
orbitScaleTarget = newScale;
|
| 1933 |
+
applyOrbitScale(newScale);
|
| 1934 |
|
| 1935 |
// Recompute total mass norm and spawn weights
|
| 1936 |
totalMassNorm = 0;
|
|
|
|
| 1942 |
}
|
| 1943 |
totalSpawnWeight = totalMassNorm;
|
| 1944 |
|
| 1945 |
+
// Find best open position for custom model (center-biased, clearance-aware)
|
| 1946 |
+
const openPos = findOpenPosition(idx);
|
| 1947 |
+
md[idx].x = openPos.x;
|
| 1948 |
+
md[idx].y = openPos.y;
|
| 1949 |
+
if (positions.length <= idx) positions.push({ x: openPos.x, y: openPos.y });
|
| 1950 |
+
else { positions[idx].x = openPos.x; positions[idx].y = openPos.y; }
|
| 1951 |
+
|
| 1952 |
+
// Resolve any overlaps locally β models only move minimum distance needed
|
| 1953 |
+
const separated = separateInPlace();
|
| 1954 |
+
for (let j = 0; j < BASE_MODEL_COUNT; j++) {
|
| 1955 |
+
const dx = separated[j].x - positions[j].x;
|
| 1956 |
+
const dy = separated[j].y - positions[j].y;
|
| 1957 |
+
if (dx * dx + dy * dy > 4) {
|
| 1958 |
+
layoutTargetX[j] = separated[j].x;
|
| 1959 |
+
layoutTargetY[j] = separated[j].y;
|
| 1960 |
+
layoutActive[j] = 1;
|
| 1961 |
+
}
|
| 1962 |
+
}
|
| 1963 |
+
// Update custom model position from separation too
|
| 1964 |
+
positions[idx].x = separated[idx].x;
|
| 1965 |
+
positions[idx].y = separated[idx].y;
|
| 1966 |
+
md[idx].x = separated[idx].x;
|
| 1967 |
+
md[idx].y = separated[idx].y;
|
| 1968 |
+
|
| 1969 |
computeNeighbors();
|
| 1970 |
|
| 1971 |
+
// Reset elastic state for custom model only
|
| 1972 |
+
elasticDx[idx] = 0; elasticDy[idx] = 0;
|
| 1973 |
+
elasticVx[idx] = 0; elasticVy[idx] = 0;
|
| 1974 |
+
|
| 1975 |
+
// Start pop-in animation (0% β 100% with elastic overshoot)
|
| 1976 |
+
customPopScale = 0;
|
| 1977 |
+
customPopVelocity = 0;
|
| 1978 |
+
customPopTarget = 1;
|
| 1979 |
|
| 1980 |
// Set rotation direction (random)
|
| 1981 |
modelRotDir[idx] = Math.random() < 0.5 ? 1 : -1;
|
| 1982 |
}
|
| 1983 |
|
| 1984 |
+
// ββ removeCustomModel: eject particles, start pop-out ββββββββ
|
| 1985 |
function removeCustomModel() {
|
| 1986 |
if (!customModelActive) return;
|
| 1987 |
const idx = CUSTOM_MODEL_IDX;
|
|
|
|
| 1990 |
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
| 1991 |
const p = particles[i];
|
| 1992 |
if (p.attractorIdx === idx) {
|
|
|
|
| 1993 |
const m = md[idx];
|
| 1994 |
const r = p.orbitRadius || 1;
|
| 1995 |
const rawOmega = p.angularMomentum / (r * r);
|
|
|
|
| 1998 |
p.vy = Math.cos(p.angle) * omega * r;
|
| 1999 |
p.phase = 0;
|
| 2000 |
p.attractorIdx = -1;
|
|
|
|
| 2001 |
}
|
| 2002 |
}
|
| 2003 |
|
| 2004 |
+
// Prevent new captures during pop-out
|
| 2005 |
+
modelCaptureRadius[idx] = 0;
|
| 2006 |
+
md[idx].captureRadius = 0;
|
| 2007 |
|
| 2008 |
+
// Start pop-out animation (finalizeRemoval called when scale reaches 0)
|
| 2009 |
+
customPopTarget = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2010 |
}
|
| 2011 |
|
| 2012 |
})();
|