mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-03 16:18:49 -05:00
1449 lines
57 KiB
HTML
1449 lines
57 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<script type="module" src="modules/guard.js"></script>
|
|
<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: 80px;
|
|
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;
|
|
}
|
|
|
|
/* Styling for module completers list to enable scrolling */
|
|
#m-community-list {
|
|
max-height: 250px; /* Adjust as needed */
|
|
overflow-y: auto;
|
|
border: 1px solid #eee; /* Optional: visually separate the scrollable area */
|
|
border-radius: 6px;
|
|
padding: 5px;
|
|
}
|
|
|
|
/* Status Box Styles */
|
|
.status-box {
|
|
position: fixed;
|
|
top: 220px; /* Moved down to sit below header */
|
|
left: 40px; /* Moved to left */
|
|
width: 220px;
|
|
padding: 15px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border: 1px solid #333;
|
|
box-shadow: 4px 4px 0px rgba(0,0,0,0.1);
|
|
z-index: 10;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
display: none; /* Hidden until loaded */
|
|
}
|
|
|
|
.cli-promo {
|
|
margin-top: 10px;
|
|
font-size: 10px;
|
|
color: #666;
|
|
background: #f0f0f0;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
border: 1px dashed #ccc;
|
|
line-height: 1.3;
|
|
}
|
|
.cli-promo code {
|
|
display: block;
|
|
margin-top: 4px;
|
|
background: #333;
|
|
color: #fff;
|
|
padding: 2px 4px;
|
|
border-radius: 2px;
|
|
text-align: center;
|
|
}
|
|
|
|
.status-header {
|
|
font-weight: bold;
|
|
border-bottom: 1px dashed #ccc;
|
|
padding-bottom: 8px;
|
|
margin-bottom: 10px;
|
|
font-size: 14px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.progress-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.progress-circle-svg {
|
|
width: 40px;
|
|
height: 40px;
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.progress-bg {
|
|
fill: none;
|
|
stroke: #eee;
|
|
stroke-width: 4;
|
|
}
|
|
|
|
.progress-bar {
|
|
fill: none;
|
|
stroke: #ff6600;
|
|
stroke-width: 4;
|
|
stroke-dasharray: 100;
|
|
stroke-dashoffset: 100; /* Start empty */
|
|
transition: stroke-dashoffset 1.5s ease-out;
|
|
}
|
|
|
|
.progress-text {
|
|
font-size: 12px;
|
|
color: #555;
|
|
}
|
|
|
|
.badge-container {
|
|
background: #fffcf5;
|
|
border: 1px solid #eee;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.badge-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: #ff6600;
|
|
}
|
|
|
|
.badge-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.badge-label {
|
|
font-size: 9px;
|
|
color: #888;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.badge-name {
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
|
|
/* Mobile Specifics */
|
|
@media (max-width: 768px) {
|
|
#ui-layer {
|
|
top: 10px;
|
|
left: 10px;
|
|
right: 10px;
|
|
background: rgba(253, 252, 248, 0.95);
|
|
}
|
|
.status-box {
|
|
top: auto;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
width: auto;
|
|
max-width: 200px;
|
|
}
|
|
h1 { font-size: 1.5rem; }
|
|
.milestone-label { width: 120px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="status-box" id="status-box">
|
|
<div class="status-header">
|
|
YOUR JOURNEY
|
|
</div>
|
|
<div class="progress-container">
|
|
<svg class="progress-circle-svg" viewBox="0 0 36 36">
|
|
<path class="progress-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
<path class="progress-bar" id="progress-ring" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
</svg>
|
|
<div class="progress-text">
|
|
<span id="progress-pct">0</span>% Complete
|
|
</div>
|
|
</div>
|
|
|
|
<div class="badge-container" id="badge-display" style="display:none;">
|
|
<svg class="badge-icon" viewBox="0 0 24 24">
|
|
<path d="M12,2C12,2 6,8 6,13C6,16.31 8.69,19 12,19C15.31,19 18,16.31 18,13C18,8 12,2 12,2M12,17C9.79,17 8,15.21 8,13C8,10.5 10.5,6.5 12,4.5C13.5,6.5 16,10.5 16,13C16,15.21 14.21,17 12,17Z" />
|
|
</svg>
|
|
<div class="badge-info">
|
|
<span class="badge-label">Latest Badge</span>
|
|
<span class="badge-name" id="badge-name">Beginner</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ui-layer">
|
|
<h1>Your Tiny Torch Journey</h1>
|
|
<p>Current Module: <a id="current-module-link" href="#" style="cursor: pointer; text-decoration: none; color: inherit; pointer-events: auto;">09 Convolutions (CNNs)</a></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>
|
|
|
|
<div class="community-section" style="border-top: none; padding-top: 20px;">
|
|
<div class="community-header" style="justify-content: space-between; align-items: center;">
|
|
<div style="display:flex; align-items:center; gap:10px;">
|
|
Module
|
|
<span class="community-badge" style="background:#666;">Preview</span>
|
|
</div>
|
|
<a id="m-link-btn" href="#" target="_blank" style="background:#ff6600; color:white; text-decoration:none; font-size:0.75rem; padding:5px 10px; border-radius:4px; font-weight:bold; font-family: sans-serif; text-transform: uppercase;">Go to Module →</a>
|
|
</div>
|
|
<div id="m-iframe-container" style="width:100%; height:500px; border:1px solid #eee; border-radius:8px; overflow:hidden; background:#f9f9f9;">
|
|
<iframe id="m-iframe" src="" style="width:100%; height:100%; border:none;"></iframe>
|
|
</div>
|
|
</div>
|
|
|
|
<details style="margin-top: 20px; border: 1px solid #eee; border-radius: 8px; padding: 10px;">
|
|
<summary style="cursor: pointer; font-size: 0.8rem; color: #888; font-weight: bold; text-transform: uppercase;">Pioneers & History</summary>
|
|
<table style="margin-top: 10px; margin-bottom: 0;">
|
|
<thead>
|
|
<tr>
|
|
<th>Pioneer / Creator</th>
|
|
<th>Contribution</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="m-table-body">
|
|
<!-- Rows inserted via JS -->
|
|
</tbody>
|
|
</table>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<canvas id="canvas"></canvas>
|
|
|
|
<script>
|
|
window.USER_EMAIL = "kaik@harvard.edu";
|
|
</script>
|
|
<script type="module" src="app.js"></script>
|
|
|
|
<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 mIframe = document.getElementById('m-iframe');
|
|
const mLinkBtn = document.getElementById('m-link-btn');
|
|
|
|
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: 0 // Initial state, updated by API
|
|
};
|
|
|
|
// Load flame icon for current stage marker
|
|
const flameIcon = new Image();
|
|
flameIcon.src = 'assets/flame.svg';
|
|
let flameLoaded = false;
|
|
flameIcon.onload = () => { flameLoaded = true; };
|
|
|
|
const MODULE_PAGE_SUFFIX = "_ABOUT.html";
|
|
const SVG_ICON_STRING = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px; vertical-align:middle; opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`;
|
|
|
|
// Data: 20 Modules
|
|
const milestones = [
|
|
{
|
|
id: "01_tensor", title: "Tensors", year: "1890", val: 0.05,
|
|
desc: "Foundation of N-dimensional arrays.",
|
|
people: [{name: "Gregorio Ricci-Curbastro", role: "Tensor Calculus (1890)"}, {name: "Bernhard Riemann", role: "Manifolds"}]
|
|
},
|
|
{
|
|
id: "02_activations", title: "Activations", year: "1943", val: 0.10,
|
|
desc: "ReLU, Sigmoid, Softmax.",
|
|
people: [{name: "McCulloch & Pitts", role: "Threshold Logic (1943)"}, {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 (1958)"}]
|
|
},
|
|
{
|
|
id: "04_losses", title: "Loss Functions", year: "1805", val: 0.18,
|
|
desc: "MSE, CrossEntropy, Optimization targets.",
|
|
people: [{name: "Adrien-Marie Legendre", role: "Least Squares (1805)"}, {name: "C.E. Shannon", role: "Information Entropy (1948)"}]
|
|
},
|
|
{
|
|
id: "05_dataloader", title: "Data Pipelines", year: "2012", val: 0.22,
|
|
desc: "Efficient batching and pre-fetching.",
|
|
people: [{name: "Jeff Dean", role: "DistBelief (2012)"}, {name: "PyTorch Team", role: "DataLoader (2016)"}]
|
|
},
|
|
{
|
|
id: "06_autograd", title: "Autograd", year: "1970", val: 0.26,
|
|
desc: "Automatic Differentiation.",
|
|
people: [{name: "Seppo Linnainmaa", role: "Reverse Mode AD (1970)"}, {name: "Rumelhart et al.", role: "Applied to NN (1986)"}]
|
|
},
|
|
{
|
|
id: "07_optimizers", title: "Optimizers", year: "1951", val: 0.30,
|
|
desc: "SGD, Adam, Scheduling.",
|
|
people: [{name: "Robbins & Monro", role: "Stochastic Approximation (1951)"}, {name: "Kingma & Ba", role: "Adam (2014)"}]
|
|
},
|
|
{
|
|
id: "08_training", title: "Training Loops", year: "1986", val: 0.35,
|
|
desc: "The learning cycle logic.",
|
|
people: [{name: "Rumelhart, Hinton & Williams", role: "Backpropagation (1986)"}, {name: "Yann LeCun", role: "Early frameworks"}]
|
|
},
|
|
{
|
|
id: "09_convolutions", title: "Convolutions (CNN)", year: "1989", val: 0.40,
|
|
desc: "Conv2d, MaxPool, Vision.",
|
|
people: [{name: "Yann LeCun", role: "LeNet (1989)"}, {name: "Alex Krizhevsky", role: "AlexNet (2012)"}]
|
|
},
|
|
{
|
|
id: "10_tokenization", title: "Tokenization", year: "1994", val: 0.45,
|
|
desc: "BPE, WordPiece, Text processing.",
|
|
people: [{name: "Philip Gage", role: "Byte Pair Encoding (1994)"}, {name: "Schuster & Nakajima", role: "WordPiece (2012)"}]
|
|
},
|
|
{
|
|
id: "11_embeddings", title: "Embeddings", year: "2013", val: 0.50,
|
|
desc: "Vector representations of meaning.",
|
|
people: [{name: "Tomas Mikolov", role: "Word2Vec (2013)"}, {name: "Yoshua Bengio", role: "Neural Language Model (2003)"}]
|
|
},
|
|
{
|
|
id: "12_attention", title: "Attention", year: "2014", val: 0.55,
|
|
desc: "Focusing on relevant context.",
|
|
people: [{name: "Dzmitry Bahdanau", role: "Neural Machine Translation (2014)"}, {name: "KyungHyun Cho", role: "Encoder-Decoder (2014)"}]
|
|
},
|
|
{
|
|
id: "13_transformers", title: "Transformers", year: "2017", val: 0.60,
|
|
desc: "Self-attention blocks.",
|
|
people: [{name: "Vaswani et al.", role: "Attention Is All You Need (2017)"}, {name: "Noam Shazeer", role: "Architecture Scaling"}]
|
|
},
|
|
{
|
|
id: "14_profiling", title: "Profiling", year: "2018", val: 0.65,
|
|
desc: "Bottleneck analysis.",
|
|
people: [{name: "MLCommons", role: "MLPerf (2018)"}, {name: "NVIDIA", role: "Nsight Systems"}]
|
|
},
|
|
{
|
|
id: "15_quantization", title: "Quantization", year: "2015", val: 0.70,
|
|
desc: "INT8, FP16, precision reduction.",
|
|
people: [{name: "Song Han", role: "Deep Compression (2015)"}, {name: "Google/Qualcomm", role: "Mobile Inference"}]
|
|
},
|
|
{
|
|
id: "16_compression", title: "Compression", year: "2015", val: 0.75,
|
|
desc: "Pruning & Distillation.",
|
|
people: [{name: "Hinton et al.", role: "Knowledge Distillation (2015)"}, {name: "Frankle & Carbin", role: "Lottery Ticket (2018)"}]
|
|
},
|
|
{
|
|
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 (2022)"}, {name: "vLLM Team", role: "PagedAttention (2023)"}]
|
|
},
|
|
{
|
|
id: "18_memoization", title: "Memoization", year: "2022", val: 0.80,
|
|
desc: "Hardware optimization (TPU/GPU).",
|
|
people: [{name: "NVIDIA", role: "CUDA (2007) / Tensor Cores (2017)"}, {name: "Google", role: "TPU v1 (2016)"}]
|
|
},
|
|
{
|
|
id: "19_benchmarking", title: "Benchmarking", year: "2018", val: 0.90,
|
|
desc: "Performance measurement.",
|
|
people: [{name: "MLCommons", role: "MLPerf (2018)"}, {name: "Stanford DAWN", role: "DawnBench (2017)"}]
|
|
},
|
|
{
|
|
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;
|
|
});
|
|
|
|
// --- MODAL LOGIC ---
|
|
|
|
function openModal(data) {
|
|
mTitle.textContent = data.title;
|
|
// Make subtitle a link
|
|
const linkUrl = `${TINYTORCH_AI_URL}/modules/${data.id}${MODULE_PAGE_SUFFIX}`;
|
|
mSubtitle.innerHTML = `<a href="${linkUrl}" target="_blank" style="color:inherit; text-decoration:none; display:inline-flex; align-items:center;">Module ${data.id}${SVG_ICON_STRING}</a>`;
|
|
|
|
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);
|
|
});
|
|
|
|
// Update Iframe & Button
|
|
mIframe.src = linkUrl;
|
|
mLinkBtn.href = linkUrl;
|
|
mLinkBtn.innerHTML = `Go to ${data.title} →`;
|
|
|
|
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, plus extra bottom padding
|
|
const itemSpacing = 120;
|
|
height = canvas.height = Math.max(window.innerHeight, (milestones.length * itemSpacing) + 200 + 100);
|
|
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 Flame Icon
|
|
if (endProgress >= splitPct) {
|
|
const pos = getNodePosition(milestones[config.currentStageIndex], config.currentStageIndex);
|
|
const pulse = Math.sin(Date.now() / 200) * 2;
|
|
|
|
const isHovered = (config.currentStageIndex === hoveredNodeIndex);
|
|
const baseSize = isHovered ? 36 : 28;
|
|
const flameSize = baseSize + pulse;
|
|
|
|
if (flameLoaded) {
|
|
// Draw flame icon centered at position
|
|
const flameWidth = flameSize * 0.77; // Maintain aspect ratio (100/130)
|
|
const flameHeight = flameSize;
|
|
ctx.drawImage(
|
|
flameIcon,
|
|
pos.x - flameWidth / 2,
|
|
pos.y - flameHeight + 4, // Offset so flame tip points to the node
|
|
flameWidth,
|
|
flameHeight
|
|
);
|
|
} else {
|
|
// Fallback to circle if image not loaded
|
|
ctx.fillStyle = config.lineColor;
|
|
ctx.beginPath();
|
|
ctx.arc(pos.x, pos.y, flameSize / 3, 0, Math.PI*2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// --- USER PROGRESS INTEGRATION ---
|
|
const SUPABASE_URL = "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1";
|
|
const NETLIFY_URL = "https://tinytorch.netlify.app";
|
|
const API_BASE_URL = SUPABASE_URL; // Backwards compatibility
|
|
const TINYTORCH_AI_URL = "https://mlsysbook.ai/tinytorch";
|
|
|
|
const MODULE_MAP = {
|
|
"01": { id: "01", title: "Tensor Basics", dir: "01_tensor" },
|
|
"02": { id: "02", title: "Activations (ReLU, Softmax)", dir: "02_activations" },
|
|
"03": { id: "03", title: "Linear Layers", dir: "03_layers" },
|
|
"04": { id: "04", title: "Loss Functions", dir: "04_losses" },
|
|
"05": { id: "05", title: "Data Pipelines", dir: "05_dataloader" },
|
|
"06": { id: "06", title: "Automatic Differentiation", dir: "06_autograd" },
|
|
"07": { id: "07", title: "Optimizers (SGD, Adam)", dir: "07_optimizers" },
|
|
"08": { id: "08", title: "Training Loops", dir: "08_training" },
|
|
"09": { id: "09", title: "Conv2d & CNNs", dir: "09_convolutions" },
|
|
"10": { id: "10", title: "Text Processing", dir: "10_tokenization" },
|
|
"11": { id: "11", title: "Embeddings", dir: "11_embeddings" },
|
|
"12": { id: "12", title: "Multi-head Attention", dir: "12_attention" },
|
|
"13": { id: "13", title: "Transformer Blocks", dir: "13_transformers" },
|
|
"14": { id: "14", title: "Profiling", dir: "14_profiling" },
|
|
"15": { id: "15", title: "Quantization", dir: "15_quantization" },
|
|
"16": { id: "16", title: "Compression", dir: "16_compression" },
|
|
"17": { id: "17", title: "Acceleration", dir: "17_acceleration" },
|
|
"18": { id: "18", title: "Memoization", dir: "18_memoization" },
|
|
"19": { id: "19", title: "Benchmarking", dir: "19_benchmarking" },
|
|
"20": { id: "20", title: "Capstone Project", dir: "20_capstone" }
|
|
};
|
|
|
|
async function fetchUserProgress() {
|
|
let token = localStorage.getItem("tinytorch_token");
|
|
|
|
const forceLogin = () => {
|
|
console.warn("Session expired or invalid. Redirecting to login...");
|
|
localStorage.removeItem("tinytorch_token");
|
|
localStorage.removeItem("tinytorch_refresh_token");
|
|
localStorage.removeItem("tinytorch_user");
|
|
|
|
const isCommunitySite = window.location.hostname === 'mlsysbook.ai' || window.location.hostname === 'tinytorch.ai' || (window.location.hostname === 'localhost' && window.location.port === '8000');
|
|
const basePath = isCommunitySite ? '/community' : '';
|
|
window.location.href = basePath + '/index.html?action=login&next=dashboard.html';
|
|
};
|
|
|
|
if (!token) {
|
|
forceLogin();
|
|
return null;
|
|
}
|
|
|
|
let detailsData = null;
|
|
let retryCount = 0;
|
|
const MAX_RETRIES = 1;
|
|
|
|
do {
|
|
try {
|
|
// 1. Direct Fetch using Token (Edge Function resolves User ID)
|
|
const profileRes = await fetch(`${SUPABASE_URL}/get-profile-details`, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
// Check for 401 OR 400 with specific "Invalid Token" error
|
|
let needsRefresh = profileRes.status === 401;
|
|
let errorData = null;
|
|
|
|
if (profileRes.status === 400) {
|
|
// Clone response to safely check body for specific error message
|
|
try {
|
|
errorData = await profileRes.clone().json();
|
|
if (errorData && errorData.error && errorData.error.includes("Invalid Token")) {
|
|
needsRefresh = true;
|
|
}
|
|
} catch(e) { /* ignore parse error */ }
|
|
}
|
|
|
|
if (needsRefresh && retryCount === 0) {
|
|
console.log("Token expired or invalid (400/401). Attempting refresh...");
|
|
const refreshToken = localStorage.getItem("tinytorch_refresh_token");
|
|
if (!refreshToken) { forceLogin(); return null; }
|
|
|
|
const refreshRes = await fetch(`${NETLIFY_URL}/api/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refreshToken })
|
|
});
|
|
|
|
if (!refreshRes.ok) { forceLogin(); return null; }
|
|
|
|
const refreshData = await refreshRes.json();
|
|
const session = refreshData.session || refreshData; // Handle nested 'session' or direct response
|
|
|
|
if (session && session.access_token) {
|
|
token = session.access_token;
|
|
localStorage.setItem("tinytorch_token", token);
|
|
if (session.refresh_token) {
|
|
localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
|
|
}
|
|
retryCount++;
|
|
continue;
|
|
} else {
|
|
console.warn("Refresh failed: No access token in response", refreshData);
|
|
forceLogin();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!profileRes.ok) {
|
|
// If errorData wasn't parsed yet
|
|
if (!errorData) {
|
|
try { errorData = await profileRes.json(); } catch(e) {}
|
|
}
|
|
throw new Error(errorData?.error || `Failed to fetch profile from Edge Function: ${profileRes.status}`);
|
|
}
|
|
|
|
detailsData = await profileRes.json();
|
|
break; // Success
|
|
|
|
} catch (error) {
|
|
console.error("Error fetching progress:", error);
|
|
if (retryCount >= MAX_RETRIES || (error.message && !error.message.includes("Invalid Token"))) {
|
|
return null;
|
|
}
|
|
}
|
|
} while (retryCount < MAX_RETRIES);
|
|
|
|
if (!detailsData) return null;
|
|
|
|
const completedIds = detailsData.completed_modules || [];
|
|
const username = detailsData.profile?.username;
|
|
|
|
// Optional: Update local storage if username was missing/different
|
|
if (username) {
|
|
// We can treat this as a sync opportunity if desired, but not strictly required for flow
|
|
}
|
|
|
|
// 4. Map to Module Strings
|
|
const passedModules = new Set();
|
|
completedIds.forEach(numId => {
|
|
const idStr = String(numId).padStart(2, '0');
|
|
if (MODULE_MAP[idStr]) {
|
|
passedModules.add(idStr);
|
|
}
|
|
});
|
|
|
|
// 5. Update CLI Promo if Empty
|
|
const statusBox = document.getElementById('status-box');
|
|
if (statusBox && passedModules.size === 0) {
|
|
const existingPromo = statusBox.querySelector('.cli-promo');
|
|
if (!existingPromo) {
|
|
const promo = document.createElement('div');
|
|
promo.className = 'cli-promo';
|
|
promo.innerHTML = `
|
|
Start your journey! <br>
|
|
Submit modules via CLI:
|
|
<code>tito module complete 01</code>
|
|
`;
|
|
statusBox.appendChild(promo);
|
|
}
|
|
}
|
|
|
|
return {
|
|
passed_count: passedModules.size,
|
|
passed_ids: Array.from(passedModules).sort()
|
|
};
|
|
}
|
|
|
|
// Initialize Progress
|
|
fetchUserProgress().then(stats => {
|
|
if (stats) {
|
|
// Update config
|
|
// Ensure we don't exceed bounds
|
|
let nextIndex = stats.passed_count;
|
|
if (nextIndex >= milestones.length) nextIndex = milestones.length - 1;
|
|
|
|
config.currentStageIndex = nextIndex;
|
|
|
|
const currentModuleLink = document.getElementById('current-module-link');
|
|
|
|
// Update UI Text (Main Header)
|
|
const m = milestones[nextIndex];
|
|
if (m) {
|
|
const idNum = m.id.split('_')[0];
|
|
if (currentModuleLink) {
|
|
// Update content with SVG
|
|
currentModuleLink.innerHTML = `${idNum} ${m.title}${SVG_ICON_STRING}`;
|
|
currentModuleLink.href = `${TINYTORCH_AI_URL}/modules/${m.id}${MODULE_PAGE_SUFFIX}`; // Set the href
|
|
currentModuleLink.target = "_blank"; // Open in new tab
|
|
currentModuleLink.style.pointerEvents = "auto"; // Ensure clickable over canvas
|
|
}
|
|
}
|
|
|
|
// Refresh labels to apply 'future' class correctly
|
|
createLabels();
|
|
|
|
// --- UPDATE STATUS BOX ---
|
|
const statusBox = document.getElementById('status-box');
|
|
const progressRing = document.getElementById('progress-ring');
|
|
const progressPct = document.getElementById('progress-pct');
|
|
const badgeDisplay = document.getElementById('badge-display');
|
|
const badgeName = document.getElementById('badge-name');
|
|
|
|
if (statusBox) {
|
|
statusBox.style.display = 'block';
|
|
|
|
// 1. Percentage
|
|
const pct = Math.min((stats.passed_count / milestones.length) * 100, 100);
|
|
progressPct.textContent = Math.round(pct);
|
|
|
|
// SVG Circle Animation (circumference ~100)
|
|
// stroke-dashoffset = 100 - pct
|
|
setTimeout(() => {
|
|
progressRing.style.strokeDashoffset = 100 - pct;
|
|
}, 100);
|
|
|
|
// 2. Latest Badge
|
|
if (stats.passed_count > 0 && stats.passed_ids.length > 0) {
|
|
// Get last passed ID
|
|
const lastId = stats.passed_ids[stats.passed_ids.length - 1];
|
|
// Find corresponding module name
|
|
// The ID in passed_ids is "01", "02" etc.
|
|
// milestones.id is "01_tensor", "02_activations" etc.
|
|
const lastModule = milestones.find(mod => mod.id.startsWith(lastId));
|
|
|
|
if (lastModule) {
|
|
badgeDisplay.style.display = 'flex';
|
|
badgeName.textContent = lastModule.title;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', resize);
|
|
resize(); // Initial call
|
|
animateLoop();
|
|
|
|
</script>
|
|
<!-- Profile Modal (Added via CLI) -->
|
|
<div id="profile-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.9); backdrop-filter:blur(5px); z-index:200; justify-content:center; align-items:center; opacity:0; transition:opacity 0.3s ease;">
|
|
<div style="background:#fff; padding:40px; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,0.1); max-width:500px; width:90%; position:relative; border:1px solid #eaeaea;">
|
|
<button id="profile-close" style="position:absolute; top:20px; right:20px; border:none; background:none; font-size:24px; cursor:pointer; color:#999;">×</button>
|
|
|
|
<div id="p-loading" style="display:block; text-align:center; color:#888;">Loading profile...</div>
|
|
|
|
<div id="p-content" style="display:none;">
|
|
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 20px;">
|
|
<img id="p-avatar" src="" alt="Avatar" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover; background: #eee; border: 2px solid #ff6600;">
|
|
<div>
|
|
<h2 id="p-name" style="margin:0; color:#222; font-size: 1.5rem;">User Name</h2>
|
|
<div id="p-meta" style="color:#666; font-family:'Courier New', monospace;">@username</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p id="p-bio" style="color:#555; font-style:italic; margin-bottom:25px; border-left: 3px solid #ffefe0; padding-left: 15px; font-size: 0.95rem;">Bio goes here...</p>
|
|
|
|
<div style="background:#f9f9f9; padding:15px; border-radius:8px; font-size: 0.9rem;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Institution</span>
|
|
<span id="p-inst" style="font-weight:bold; color:#333; text-align:right;">-</span>
|
|
</div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Location</span>
|
|
<span id="p-loc" style="font-weight:bold; color:#333; text-align:right;">-</span>
|
|
</div>
|
|
<div id="p-links-row" style="display:none; flex-direction:column; gap:6px; margin-bottom:8px; padding-bottom: 8px; border-bottom: 1px dashed #eee;">
|
|
<span style="color:#888;">Links</span>
|
|
<div id="p-links" style="display:flex; flex-wrap:wrap; gap:8px;"></div>
|
|
</div>
|
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
<span style="color:#888;">Modules Completed</span>
|
|
<span id="p-modules-count" style="font-weight:bold; color:#333;">0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Profile Modal Logic
|
|
const pModal = document.getElementById('profile-modal');
|
|
const pClose = document.getElementById('profile-close');
|
|
const pContent = document.getElementById('p-content');
|
|
const pLoading = document.getElementById('p-loading');
|
|
|
|
const elName = document.getElementById('p-name');
|
|
const elMeta = document.getElementById('p-meta');
|
|
const elBio = document.getElementById('p-bio');
|
|
const elInst = document.getElementById('p-inst');
|
|
const elLoc = document.getElementById('p-loc');
|
|
const elCount = document.getElementById('p-modules-count');
|
|
const elAvatar = document.getElementById('p-avatar');
|
|
const elLinksRow = document.getElementById('p-links-row');
|
|
const elLinks = document.getElementById('p-links');
|
|
|
|
function closeProfileModal() {
|
|
pModal.style.opacity = '0';
|
|
setTimeout(() => { pModal.style.display = 'none'; }, 300);
|
|
}
|
|
|
|
if(pClose) pClose.addEventListener('click', closeProfileModal);
|
|
if(pModal) pModal.addEventListener('click', (e) => {
|
|
if (e.target === pModal) closeProfileModal();
|
|
});
|
|
|
|
window.openProfileModal = async function(userId) {
|
|
if(!pModal) return;
|
|
|
|
// Reset UI
|
|
pModal.style.display = 'flex';
|
|
void pModal.offsetWidth; // Force reflow
|
|
pModal.style.opacity = '1';
|
|
|
|
pLoading.style.display = 'block';
|
|
pContent.style.display = 'none';
|
|
|
|
try {
|
|
// Ensure API_BASE_URL is available (it is defined in the main script)
|
|
const baseUrl = typeof API_BASE_URL !== 'undefined' ? API_BASE_URL : "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1";
|
|
const res = await fetch(`${baseUrl}/get-profile-details?id=${userId}`);
|
|
if(!res.ok) throw new Error("Failed to fetch");
|
|
|
|
const data = await res.json();
|
|
const p = data.profile || {};
|
|
const modules = data.completed_modules || [];
|
|
|
|
// Fill Data
|
|
if(elAvatar) elAvatar.src = p.avatar || p.avatar_url || 'assets/flame.svg';
|
|
|
|
elName.textContent = p.display_name || p.full_name || (p.username ? p.username.split('@')[0].trim() : "Unknown User");
|
|
elMeta.textContent = `@${p.username ? p.username.split('@')[0].trim() : 'N/A'}`;
|
|
elBio.textContent = p.bio || p.summary || "No bio available.";
|
|
|
|
let inst = "N/A";
|
|
if (p.institution) {
|
|
inst = Array.isArray(p.institution) ? p.institution.join(", ") : p.institution;
|
|
}
|
|
elInst.textContent = inst;
|
|
|
|
elLoc.textContent = p.location || "N/A";
|
|
elCount.textContent = modules.length;
|
|
|
|
// Handle Links
|
|
if(elLinksRow && elLinks) {
|
|
elLinks.innerHTML = '';
|
|
const sites = p.websites || p.website || [];
|
|
const sitesArray = Array.isArray(sites) ? sites : (sites ? [sites] : []);
|
|
|
|
if(sitesArray.length > 0) {
|
|
elLinksRow.style.display = 'flex';
|
|
sitesArray.forEach(site => {
|
|
if(!site) return;
|
|
const a = document.createElement('a');
|
|
a.href = site.startsWith('http') ? site : `https://${site}`;
|
|
a.target = '_blank';
|
|
a.textContent = site.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
a.style.color = '#ff6600';
|
|
a.style.textDecoration = 'none';
|
|
a.style.fontSize = '0.8rem';
|
|
a.style.border = '1px solid #ffefe0';
|
|
a.style.padding = '2px 6px';
|
|
a.style.borderRadius = '4px';
|
|
elLinks.appendChild(a);
|
|
});
|
|
} else {
|
|
elLinksRow.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
pLoading.style.display = 'none';
|
|
pContent.style.display = 'block';
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
pLoading.textContent = "Failed to load profile.";
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|