Files
cs249r_book/tinytorch/site/extra/community/tests/index.html
2025-12-19 19:30:39 -05:00

924 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Tiny Torch Journey</title>
<link rel="icon" href="../assets/flame.svg" type="image/svg+xml">
<style>
body {
margin: 0;
padding: 0;
background-color: #fdfcf8; /* Warm paper off-white */
font-family: 'Georgia', 'Times New Roman', Times, serif;
color: #333;
/* Default to hidden for desktop app-feel, changed via JS for mobile */
overflow: hidden;
}
canvas {
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 1;
cursor: default;
}
#ui-layer {
position: fixed; /* Fixed so it stays visible on scroll */
top: 40px;
left: 40px;
z-index: 2;
pointer-events: none;
background: rgba(253, 252, 248, 0.8);
padding: 10px;
border-radius: 8px;
backdrop-filter: blur(2px);
}
h1 {
font-size: 2rem;
margin: 0;
font-weight: normal;
letter-spacing: 1px;
color: #222;
opacity: 0;
animation: fadeIn 2s ease-out forwards;
}
p {
font-size: 0.9rem;
color: #666;
margin-top: 8px;
font-style: italic;
opacity: 0;
animation: fadeIn 2s ease-out 0.5s forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
/* Label styling */
.milestone-label {
position: absolute;
padding: 8px;
color: #333;
pointer-events: auto;
cursor: pointer;
opacity: 0;
transition: opacity 0.8s ease, transform 0.3s ease;
width: 140px;
border-radius: 8px;
/* Fix: Start off-screen to prevent bunching at 0,0 */
top: -1000px;
left: -1000px;
}
.milestone-label:hover {
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transform: scale(1.05);
z-index: 10;
}
.milestone-label.visible {
opacity: 1;
}
/* Fix: Only apply opacity to future labels when they are also visible */
.milestone-label.future.visible {
opacity: 0.4;
}
.milestone-label.future:hover {
opacity: 1;
}
.milestone-label h3 {
margin: 0;
font-size: 12px;
font-weight: bold;
color: #000;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.milestone-label span {
display: block;
font-size: 9px;
color: #ff6600;
margin-bottom: 2px;
font-family: 'Courier New', Courier, monospace;
font-weight: bold;
text-transform: uppercase;
}
.milestone-label.future span {
color: #999;
}
.milestone-label .desc {
margin-top: 2px;
font-size: 10px;
color: #555;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* MODAL STYLES */
#modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
z-index: 100;
display: none;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
}
#modal-content {
background: #fff;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
transform: translateY(20px);
transition: transform 0.3s ease;
border: 1px solid #eaeaea;
}
#modal-overlay.active {
display: flex;
opacity: 1;
}
#modal-overlay.active #modal-content {
transform: translateY(0);
}
#modal-close {
position: absolute;
top: 20px;
right: 20px;
font-size: 24px;
cursor: pointer;
color: #999;
border: none;
background: none;
}
#modal-close:hover {
color: #333;
}
.modal-title {
font-size: 1.8rem;
margin-bottom: 5px;
color: #222;
}
.modal-subtitle {
font-size: 1rem;
color: #666;
margin-bottom: 5px;
font-style: italic;
}
.modal-year {
color: #ff6600;
font-family: 'Courier New', monospace;
font-weight: bold;
margin-bottom: 20px;
display: block;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
font-size: 0.9rem;
margin-bottom: 30px;
}
th {
text-align: left;
border-bottom: 2px solid #eee;
padding: 10px;
color: #888;
font-weight: normal;
font-size: 0.8rem;
text-transform: uppercase;
}
td {
padding: 12px 10px;
border-bottom: 1px solid #f5f5f5;
color: #444;
}
/* New Community Section Styles */
.community-section {
border-top: 2px solid #eee;
padding-top: 20px;
}
.community-header {
font-size: 1.1rem;
color: #222;
margin-bottom: 15px;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
}
.community-badge {
background: #ff6600;
color: white;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
font-family: sans-serif;
text-transform: uppercase;
}
.builder-row {
display: flex;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px dashed #f0f0f0;
font-size: 0.9rem;
}
.builder-name {
color: #555;
font-weight: bold;
}
.builder-date {
color: #aaa;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
/* Mobile Specifics */
@media (max-width: 768px) {
#ui-layer {
top: 10px;
left: 10px;
right: 10px;
background: rgba(253, 252, 248, 0.95);
}
h1 { font-size: 1.5rem; }
.milestone-label { width: 120px; }
}
</style>
</head>
<body>
<div id="ui-layer">
<h1>Your Tiny Torch Journey</h1>
<p>Current Module: 09 Convolutions (CNNs)</p>
<p style="font-size: 0.8rem; color: #aaa; margin-top: 4px;">(Click nodes for details)</p>
</div>
<!-- Labels container -->
<div id="labels-container"></div>
<!-- Modal Structure -->
<div id="modal-overlay">
<div id="modal-content">
<button id="modal-close">&times;</button>
<h2 class="modal-title" id="m-title">Title</h2>
<div class="modal-subtitle" id="m-subtitle">Module ID</div>
<span class="modal-year" id="m-year">Approximate Era</span>
<p id="m-desc">Description</p>
<table>
<thead>
<tr>
<th>Pioneer / Creator</th>
<th>Contribution</th>
</tr>
</thead>
<tbody id="m-table-body">
<!-- Rows inserted via JS -->
</tbody>
</table>
<div class="community-section">
<div class="community-header">
Tiny Torch Community
<span class="community-badge">Builders</span>
</div>
<div id="m-community-list">
<!-- Random builders inserted here -->
</div>
</div>
</div>
</div>
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const labelsContainer = document.getElementById('labels-container');
// Modal Elements
const modalOverlay = document.getElementById('modal-overlay');
const modalClose = document.getElementById('modal-close');
const mTitle = document.getElementById('m-title');
const mSubtitle = document.getElementById('m-subtitle');
const mYear = document.getElementById('m-year');
const mDesc = document.getElementById('m-desc');
const mTableBody = document.getElementById('m-table-body');
const mCommunityList = document.getElementById('m-community-list');
let width, height;
let isMobile = false;
let drawingProgress = 0; // 0 to 1
let animationId;
let hoveredNodeIndex = -1;
// Configuration
const config = {
lineColor: '#ff6600',
futureColor: '#cccccc',
baseLineWidth: 1,
maxLineWidth: 6,
speed: 0.003,
padding: 60,
currentStageIndex: 8 // Module 09
};
// Data: 20 Modules
const milestones = [
{
id: "01_tensor", title: "Tensors", year: "1900s", val: 0.05,
desc: "Foundation of N-dimensional arrays.",
people: [{name: "Gregorio Ricci-Curbastro", role: "Tensor Calculus"}, {name: "Bernhard Riemann", role: "Manifolds"}]
},
{
id: "02_activations", title: "Activations", year: "1940s", val: 0.10,
desc: "ReLU, Sigmoid, Softmax.",
people: [{name: "McCulloch & Pitts", role: "Threshold Logic"}, {name: "Vinod Nair", role: "Rectified Linear Units (2010)"}]
},
{
id: "03_layers", title: "Linear Layers", year: "1958", val: 0.15,
desc: "The Perceptron & Dense connections.",
people: [{name: "Frank Rosenblatt", role: "The Perceptron"}]
},
{
id: "04_losses", title: "Loss Functions", year: "1800s", val: 0.18,
desc: "MSE, CrossEntropy, Optimization targets.",
people: [{name: "Adrien-Marie Legendre", role: "Least Squares"}, {name: "C.E. Shannon", role: "Information Entropy"}]
},
{
id: "05_dataloader", title: "Data Pipelines", year: "2010s", val: 0.22,
desc: "Efficient batching and pre-fetching.",
people: [{name: "Jeff Dean", role: "DistBelief/MapReduce"}, {name: "PyTorch Team", role: "DataLoader utility"}]
},
{
id: "06_autograd", title: "Autograd", year: "1970", val: 0.26,
desc: "Automatic Differentiation.",
people: [{name: "Seppo Linnainmaa", role: "Reverse Mode AD"}, {name: "Rumelhart et al.", role: "Applied to NN"}]
},
{
id: "07_optimizers", title: "Optimizers", year: "1951", val: 0.30,
desc: "SGD, Adam, Scheduling.",
people: [{name: "Robbins & Monro", role: "Stochastic Approximation"}, {name: "Kingma & Ba", role: "Adam (2014)"}]
},
{
id: "08_training", title: "Training Loops", year: "1986", val: 0.35,
desc: "The learning cycle logic.",
people: [{name: "Geoffrey Hinton", role: "Backpropagation"}, {name: "Yann LeCun", role: "Early frameworks"}]
},
{
id: "09_convolutions", title: "Convolutions (CNN)", year: "1998", val: 0.40,
desc: "Conv2d, MaxPool, Vision.",
people: [{name: "Yann LeCun", role: "LeNet-5"}, {name: "Alex Krizhevsky", role: "AlexNet (2012)"}]
},
{
id: "10_tokenization", title: "Tokenization", year: "1950s", val: 0.45,
desc: "BPE, WordPiece, Text processing.",
people: [{name: "Noam Chomsky", role: "Formal Grammars"}, {name: "Philip Gage", role: "Byte Pair Encoding (1994)"}]
},
{
id: "11_embeddings", title: "Embeddings", year: "2013", val: 0.50,
desc: "Vector representations of meaning.",
people: [{name: "Tomas Mikolov", role: "Word2Vec"}, {name: "Yoshua Bengio", role: "Neural Probabilistic Language Model"}]
},
{
id: "12_attention", title: "Attention", year: "2014", val: 0.55,
desc: "Focusing on relevant context.",
people: [{name: "Dzmitry Bahdanau", role: "Neural Machine Translation"}, {name: "KyungHyun Cho", role: "Encoder-Decoder"}]
},
{
id: "13_transformers", title: "Transformers", year: "2017", val: 0.60,
desc: "Self-attention blocks.",
people: [{name: "Ashish Vaswani", role: "Attention Is All You Need"}, {name: "Noam Shazeer", role: "Architecture Scaling"}]
},
{
id: "14_profiling", title: "Profiling", year: "2018", val: 0.65,
desc: "Bottleneck analysis.",
people: [{name: "SysML Community", role: "ML Systems"}, {name: "NVIDIA", role: "Nsight Systems"}]
},
{
id: "15_quantization", title: "Quantization", year: "2017", val: 0.70,
desc: "INT8, FP16, precision reduction.",
people: [{name: "Song Han", role: "Deep Compression"}, {name: "Google/Qualcomm", role: "Mobile Inference"}]
},
{
id: "16_compression", title: "Compression", year: "2016", val: 0.75,
desc: "Pruning & Distillation.",
people: [{name: "Geoffrey Hinton", role: "Distillation"}, {name: "Lottery Ticket Hypothesis", role: "Frankle & Carbin"}]
},
{
id: "17_acceleration", title: "Acceleration", year: "2016", val: 0.85,
desc: "KV-Cache for fast inference.",
people: [{name: "Pope et al.", role: "Efficiently Scaling Transformers"}, {name: "vLLM Team", role: "PagedAttention"}]
},
{
id: "18_memoization", title: "Memoization", year: "2022", val: 0.80,
desc: "Hardware optimization (TPU/GPU).",
people: [{name: "NVIDIA", role: "CUDA/Tensor Cores"}, {name: "Google", role: "TPU Architecture"}]
},
{
id: "19_benchmarking", title: "Benchmarking", year: "2018", val: 0.90,
desc: "Performance measurement.",
people: [{name: "MLCommons", role: "MLPerf"}, {name: "Stanford DAWN", role: "DawnBench"}]
},
{
id: "20_capstone", title: "Capstone", year: "2024", val: 1.00,
desc: "Complete End-to-End System.",
people: [{name: "The Student", role: "System Architect"}, {name: "Open Source", role: "Ecosystem"}]
}
];
// Assign pct and currentRadius
milestones.forEach((m, i) => {
m.pct = i / (milestones.length - 1);
m.currentRadius = isMobile ? 8 : 4;
});
// --- RANDOM BUILDER GENERATOR ---
const adjectives = ["Cyber", "Neural", "Torch", "Data", "Gradient", "Tensor", "Hyper", "Deep", "Logic", "Binary"];
const nouns = ["Ninja", "Wizard", "Coder", "Surfer", "Explorer", "Smith", "Hacker", "Architect", "Voyager", "Mind"];
function generateBuilders() {
const count = Math.floor(Math.random() * 4) + 2; // 2 to 5 builders
let html = '';
for(let i=0; i<count; i++) {
const name = adjectives[Math.floor(Math.random()*adjectives.length)] +
nouns[Math.floor(Math.random()*nouns.length)] +
Math.floor(Math.random()*100);
// Random date within last 6 months
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 180));
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
html += `
<div class="builder-row">
<span class="builder-name">@${name}</span>
<span class="builder-date">Completed ${dateStr}</span>
</div>
`;
}
return html;
}
// --- MODAL LOGIC ---
function openModal(data) {
mTitle.textContent = data.title;
mSubtitle.textContent = `Module ${data.id}`;
mYear.textContent = `Concept Era: ${data.year}`;
mDesc.textContent = data.desc;
// Populate Pioneer Table
mTableBody.innerHTML = '';
data.people.forEach(person => {
const row = document.createElement('tr');
row.innerHTML = `<td><strong>${person.name}</strong></td><td>${person.role}</td>`;
mTableBody.appendChild(row);
});
// Populate Community Section
mCommunityList.innerHTML = generateBuilders();
modalOverlay.classList.add('active');
}
function closeModal() {
modalOverlay.classList.remove('active');
}
modalClose.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) closeModal();
});
// --- INTERACTION LOGIC ---
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Geometry check differs for mobile vs desktop
const hit = checkCollision(clickX, clickY);
hoveredNodeIndex = hit;
canvas.style.cursor = hit !== -1 ? 'pointer' : 'default';
});
canvas.addEventListener('click', (e) => {
if (hoveredNodeIndex !== -1) {
openModal(milestones[hoveredNodeIndex]);
}
});
function checkCollision(mx, my) {
// Re-calculate node positions based on current layout
for (let i = milestones.length - 1; i >= 0; i--) {
const m = milestones[i];
const pos = getNodePosition(m, i);
// Distance check
const dist = Math.sqrt(Math.pow(mx - pos.x, 2) + Math.pow(my - pos.y, 2));
if (dist < 20) {
return i;
}
}
return -1;
}
// --- DRAWING & LAYOUT LOGIC ---
function resize() {
// Check mobile breakpoint
isMobile = window.innerWidth < 768;
if (isMobile) {
// Vertical Spiral Mode
width = canvas.width = window.innerWidth;
// Make height tall enough for 20 items spiral
const itemSpacing = 120;
height = canvas.height = Math.max(window.innerHeight, (milestones.length * itemSpacing) + 200);
document.body.style.overflowY = 'auto'; // Enable scroll
} else {
// Horizontal Landscape Mode
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
document.body.style.overflowY = 'hidden'; // Disable scroll
}
createLabels();
if (drawingProgress >= 1) {
draw();
}
}
// Unified function to get node X/Y based on mode
function getNodePosition(m, index) {
if (isMobile) {
// Vertical Sine Wave (Spiral-ish)
const startY = 150; // Padding top
const availableHeight = height - 200;
const y = startY + (m.pct * availableHeight);
// X oscillates
const centerX = width / 2;
const amplitude = (width / 2) - 60; // Keep within edges
const freq = 4; // Number of full waves down the page
const x = centerX + Math.sin(m.pct * Math.PI * freq) * amplitude;
return { x, y };
} else {
// Horizontal Graph (Desktop)
const startX = config.padding;
const totalWidth = width - (config.padding * 2);
const x = startX + (totalWidth * m.pct);
// Y calculation (Complexity Curve)
// Need to replicate getLineY logic here
let progress = (x - startX) / totalWidth;
if (progress < 0) progress = 0;
if (progress > 1) progress = 1;
// Cosine interp logic simplified for node lookup
const baseline = height * 0.9;
const maxHeight = height * 0.15;
const range = baseline - maxHeight;
// Interpolate value
// Since we store val in milestones, we interpolate between them
// But for the exact node, we just map m.val
// Wait, the curve interpolates, so the node sits exactly on the curve
// For the node itself, its 'x' maps exactly to 'm.pct', so its 'val' is exactly 'm.val'
// BUT, my getLineY function does smooth interpolation.
// However, at the exact milestone pct, the value IS m.val.
const y = baseline - (m.val * range);
return { x, y };
}
}
// Helper for drawing the continuous line
function getPathPoint(progress) {
if (isMobile) {
const startY = 150;
const availableHeight = height - 200;
const y = startY + (progress * availableHeight);
const centerX = width / 2;
const amplitude = (width / 2) - 60;
const freq = 4;
const x = centerX + Math.sin(progress * Math.PI * freq) * amplitude;
return {x, y};
} else {
const startX = config.padding;
const totalWidth = width - (config.padding * 2);
const x = startX + (totalWidth * progress);
// Interpolation logic required here for smooth line between nodes
// Find p0 and p1
let p0 = milestones[0];
let p1 = milestones[milestones.length - 1];
for (let i = 0; i < milestones.length - 1; i++) {
if (progress >= milestones[i].pct && progress <= milestones[i+1].pct) {
p0 = milestones[i];
p1 = milestones[i+1];
break;
}
}
const segLen = p1.pct - p0.pct;
const local = (progress - p0.pct) / segLen;
// Cosine interp
const mu2 = (1 - Math.cos(local * Math.PI)) / 2;
const val = (p0.val * (1 - mu2) + p1.val * mu2);
const baseline = height * 0.9;
const maxHeight = height * 0.15;
const range = baseline - maxHeight;
const y = baseline - (val * range);
return {x, y};
}
}
function createLabels() {
labelsContainer.innerHTML = '';
milestones.forEach((m, index) => {
const el = document.createElement('div');
el.className = 'milestone-label';
if (index > config.currentStageIndex) {
el.classList.add('future');
}
el.innerHTML = `
<span>${m.year}</span>
<h3>${m.title}</h3>
<div class="desc">${m.desc}</div>
`;
// Events
el.addEventListener('mouseenter', () => { hoveredNodeIndex = index; });
el.addEventListener('mouseleave', () => { hoveredNodeIndex = -1; });
el.addEventListener('click', (e) => {
e.stopPropagation();
openModal(m);
});
m.el = el;
labelsContainer.appendChild(el);
});
}
function drawGrid() {
// Removed System Complexity Axis as requested
// Keep faint timeline axis/guides if desired, or minimal background
if(!isMobile) {
ctx.strokeStyle = '#f4f4f4';
ctx.lineWidth = 1;
const startX = config.padding;
const totalWidth = width - (config.padding * 2);
const baseline = height * 0.9;
// Simple Time Axis
ctx.beginPath();
ctx.moveTo(startX, baseline + 10);
ctx.lineTo(startX + totalWidth, baseline + 10);
ctx.stroke();
}
}
function draw() {
ctx.clearRect(0, 0, width, height);
drawGrid();
// Calculate progress limits
const totalLen = 1.0;
const endProgress = drawingProgress; // 0 to 1
const splitPct = milestones[config.currentStageIndex].pct;
// Draw Line
// Iteration depends on screen size/resolution.
// We iterate by 't' (0 to 1) instead of pixels to handle both vertical/horizontal uniformly
const step = 0.002;
for (let t = 0; t < endProgress; t += step) {
let nextT = t + step;
if (nextT > endProgress) nextT = endProgress;
const p1 = getPathPoint(t);
const p2 = getPathPoint(nextT);
// Perspective / Thickness
// On Desktop: Thickness grows with progress (Coming closer)
// On Mobile: Uniform thickness or slight growth down
let currentLineWidth = config.baseLineWidth;
if (!isMobile) {
currentLineWidth += (t * config.maxLineWidth);
} else {
currentLineWidth = 3; // Thicker constant for mobile visibility
}
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
if (t < splitPct) {
ctx.strokeStyle = config.lineColor;
ctx.setLineDash([]);
ctx.lineWidth = currentLineWidth;
ctx.globalAlpha = 1.0;
} else {
ctx.strokeStyle = config.futureColor;
ctx.setLineDash([2, 5]);
ctx.lineWidth = currentLineWidth;
ctx.globalAlpha = 0.5;
}
ctx.lineCap = 'round';
ctx.stroke();
}
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Current Stage Pulsing Node
if (endProgress >= splitPct) {
const pos = getNodePosition(milestones[config.currentStageIndex], config.currentStageIndex);
const pulse = Math.sin(Date.now() / 200) * 3;
const isHovered = (config.currentStageIndex === hoveredNodeIndex);
const m = milestones[config.currentStageIndex];
const targetRadius = isHovered ? 14 : 7;
const animSpeed = 0.4;
if (m.currentRadius < targetRadius) {
m.currentRadius = Math.min(m.currentRadius + animSpeed, targetRadius);
} else if (m.currentRadius > targetRadius) {
m.currentRadius = Math.max(m.currentRadius - animSpeed, targetRadius);
}
ctx.fillStyle = config.lineColor;
ctx.beginPath();
ctx.arc(pos.x, pos.y, m.currentRadius + pulse, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = config.lineColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pos.x, pos.y, (m.currentRadius + 5) + pulse, 0, Math.PI*2);
ctx.stroke();
}
// Milestone Nodes & Labels
milestones.forEach((m, i) => {
const pos = getNodePosition(m, i);
// Only draw if line has reached here
if (drawingProgress >= m.pct) {
// Skip current node to avoid overlap
// Simple distance check from split point
// We can check index actually
if (i !== config.currentStageIndex) {
const isHovered = (i === hoveredNodeIndex);
const targetRadius = isHovered ? 12 : (isMobile ? 6 : 4);
const animSpeed = 0.4;
if (m.currentRadius < targetRadius) {
m.currentRadius = Math.min(m.currentRadius + animSpeed, targetRadius);
} else if (m.currentRadius > targetRadius) {
m.currentRadius = Math.max(m.currentRadius - animSpeed, targetRadius);
}
ctx.fillStyle = '#fff';
ctx.strokeStyle = i > config.currentStageIndex ? '#ccc' : '#444';
if (isHovered) ctx.strokeStyle = config.lineColor;
ctx.lineWidth = isHovered ? 2 : 1;
ctx.beginPath();
ctx.arc(pos.x, pos.y, m.currentRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Label Positioning
if (m.el) {
m.el.classList.add('visible');
// Mobile Layout: Labels alternate left/right to save space
if (isMobile) {
const isLeft = i % 2 === 0;
const labelWidth = 120;
const offset = 30;
m.el.style.top = `${pos.y - 20}px`;
if (isLeft) {
m.el.style.left = `${pos.x - labelWidth - offset}px`;
m.el.style.textAlign = 'right';
} else {
m.el.style.left = `${pos.x + offset}px`;
m.el.style.textAlign = 'left';
}
// Guide line
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
ctx.lineTo(isLeft ? pos.x - offset : pos.x + offset, pos.y);
ctx.stroke();
} else {
// Desktop Layout
const isTop = i % 2 === 0;
const stagger = (i % 4 === 0 || i % 4 === 3) ? (isTop ? -40 : 40) : 0;
const offset = isTop ? -80 : 40;
const labelY = pos.y + offset + stagger;
// Guide line
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
ctx.lineTo(pos.x, isTop ? pos.y - 20 : pos.y + 20);
ctx.stroke();
const labelWidth = 140;
let leftPos = pos.x - (labelWidth / 2);
if (leftPos < 5) leftPos = 5;
if (leftPos + labelWidth > width - 5) leftPos = width - labelWidth - 5;
m.el.style.left = `${leftPos}px`;
m.el.style.top = `${labelY}px`;
m.el.style.textAlign = 'center';
}
}
}
});
}
function animateLoop() {
if (drawingProgress < 1) {
drawingProgress += config.speed;
} else {
drawingProgress = 1;
}
draw();
animationId = requestAnimationFrame(animateLoop);
}
window.addEventListener('resize', resize);
resize(); // Initial call
animateLoop();
</script>
</body>
</html>