mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-04 16:48:48 -05:00
924 lines
33 KiB
HTML
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">×</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>
|