Files
SREBOT/web/views/timeline.ejs
T
2026-06-05 04:57:39 +00:00

947 lines
43 KiB
Plaintext

<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>SREBOT History</title>
<meta name="description" content="The history of SREBOT, starting from an idea to being the standard for SQB in War Thunder.">
<meta property="og:title" content="SREBOT History">
<meta property="og:description" content="The history of SREBOT, starting from an idea to being the standard for SQB in War Thunder.">
<meta property="og:type" content="website">
<meta name="theme-color" content="#90EE90">
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preload" href="/Fonts/symbols_skyquake.ttf" as="font" type="font/ttf" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/css/output.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" media="print" onload="this.media='all'">
<style>
@font-face {
font-family: 'skyquakesymbols';
src: url('/Fonts/symbols_skyquake.ttf') format('truetype');
font-display: swap;
}
* { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
body {
background: #1b1b1b;
min-height: 100vh;
}
.gradient-text {
background: linear-gradient(135deg, #F5F5DC 0%, #90EE90 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-accent { color: #F5F5DC; }
.text-muted { color: #90EE90; }
/* Serpentine timeline */
.timeline {
position: relative;
}
/* SVG that holds the flowing connector line, drawn behind the cards */
.timeline-line {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: visible;
}
.timeline-line .track {
fill: none;
stroke: rgba(144, 238, 144, 0.10);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.timeline-line .progress {
fill: none;
stroke: url(#timelineGradient);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
/* no filter, no CSS transition — GSAP owns stroke-dashoffset each frame */
}
.timeline-line .comet {
fill: #F5F5DC;
filter: drop-shadow(0 0 8px rgba(144, 238, 144, 0.7));
}
.timeline-line .end-marker {
fill: #1b1b1b;
stroke: rgba(144, 238, 144, 0.35);
stroke-width: 1.5;
}
.timeline-card-footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(245, 245, 220, 0.07);
display: flex;
align-items: center;
gap: 0.45rem;
color: rgba(144, 238, 144, 0.65);
font-size: 0.75rem;
letter-spacing: 0.06em;
}
/* The grid of milestone nodes. Default = single column (mobile). */
.timeline-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 3rem 2rem;
}
@media (min-width: 1024px) {
.timeline-grid {
grid-template-columns: repeat(3, 1fr);
gap: 6rem 2.5rem;
align-items: start;
}
/* The line snakes 3 by 3: row 1 left to right, row 2 right to left,
row 3 left to right, so the connector never crosses itself. */
.timeline-node:nth-child(1) { grid-area: 1 / 1; }
.timeline-node:nth-child(2) { grid-area: 1 / 2; }
.timeline-node:nth-child(3) { grid-area: 1 / 3; }
.timeline-node:nth-child(4) { grid-area: 2 / 3; }
.timeline-node:nth-child(5) { grid-area: 2 / 2; }
.timeline-node:nth-child(6) { grid-area: 2 / 1; }
.timeline-node:nth-child(7) { grid-area: 3 / 1; }
.timeline-node:nth-child(8) { grid-area: 3 / 2; }
.timeline-node:nth-child(9) { grid-area: 3 / 3; }
.timeline-node:nth-child(10) { grid-area: 4 / 3; }
.timeline-node:nth-child(11) { grid-area: 4 / 2; }
.timeline-node:nth-child(12) { grid-area: 4 / 1; }
.timeline-node:nth-child(13) { grid-area: 5 / 1; }
}
.timeline-card {
position: relative;
background: linear-gradient(135deg, rgba(55, 70, 55, 0.55) 0%, rgba(38, 38, 38, 0.55) 100%);
border: 1px solid rgba(245, 245, 220, 0.08);
border-radius: 1rem;
padding: 1.75rem;
}
/* Node marker: the dot that the line threads through */
.timeline-marker {
position: absolute;
top: -1.4rem;
left: 1.75rem;
width: 2.6rem;
height: 2.6rem;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #1b1b1b;
background: linear-gradient(135deg, #F5F5DC 0%, #90EE90 100%);
box-shadow: 0 0 0 5px rgba(27, 27, 27, 1), 0 0 18px rgba(144, 238, 144, 0.45);
}
.timeline-index {
font-family: 'skyquakesymbols', 'Inter', monospace;
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(144, 238, 144, 0.7);
}
.timeline-date {
color: #90EE90;
font-weight: 700;
font-size: 0.95rem;
}
.timeline-title {
color: #F5F5DC;
font-weight: 700;
font-size: 1.35rem;
line-height: 1.25;
}
.timeline-desc {
color: rgba(255, 255, 255, 0.78);
line-height: 1.65;
font-size: 0.95rem;
}
/* Reveal animation: node is always opaque (solid background blocks the SVG
line); only the inner card fades in. This prevents the line from being
visible through the semi-transparent card during the fade-in. */
#scroll-hint {
display: none;
position: fixed;
bottom: 3.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 50;
flex-direction: column;
align-items: center;
gap: 0.6rem;
color: rgba(245, 245, 220, 0.4);
font-size: 0.68rem;
letter-spacing: 0.22em;
text-transform: uppercase;
pointer-events: none;
transition: opacity 0.5s ease;
white-space: nowrap;
}
.hint-line {
width: 1px;
height: 52px;
background: rgba(245, 245, 220, 0.12);
position: relative;
overflow: hidden;
}
.hint-line::after {
content: '';
position: absolute;
left: 0;
width: 100%;
height: 45%;
background: linear-gradient(to bottom, transparent, #90EE90, transparent);
animation: hintScroll 1.5s ease-in-out infinite;
}
@keyframes hintScroll {
0% { top: -45%; }
100% { top: 100%; }
}
.timeline-node {
position: relative;
z-index: 2;
border-radius: 1rem;
/* background is set dynamically in JS only when a card starts
revealing, so unrevealed cards never block the SVG track line */
}
/* Non-GSAP / CSS fallback: hide card until node is revealed */
.timeline-node:not(.is-visible) .timeline-card { opacity: 0; }
.timeline-node.is-visible .timeline-card {
opacity: 1;
transition: opacity 0.6s ease;
}
@media (prefers-reduced-motion: reduce) {
.timeline-node .timeline-card { opacity: 1 !important; transition: none; }
.timeline-line .progress { transition: none; }
}
</style>
</head>
<body class="text-white antialiased">
<%- include('partials/nav', { activePage: 'timeline' }) %>
<!-- Header -->
<section class="pt-32 pb-10 lg:pt-40 lg:pb-14">
<div class="max-w-[1400px] mx-auto px-6 lg:px-8 text-center">
<p class="text-muted text-sm font-semibold tracking-[0.25em] uppercase mb-4">Our Story</p>
<h1 class="text-4xl lg:text-6xl font-extrabold mb-5 gradient-text">The Timeline</h1>
<p class="text-lg text-white/70 max-w-2xl mx-auto">
The history of SREBOT, starting from an idea to being the standard for SQB in War Thunder.
</p>
</div>
</section>
<!-- Timeline -->
<section class="pb-28">
<div class="max-w-[1200px] mx-auto px-6 lg:px-12">
<div class="timeline" id="timeline">
<!-- Flowing connector line (drawn by JS through the node markers) -->
<svg class="timeline-line" id="timelineLine" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="timelineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#90EE90"/>
<stop offset="100%" stop-color="#F5F5DC"/>
</linearGradient>
</defs>
<path class="track" id="timelineTrack" d=""></path>
<path class="progress" id="timelineProgress" d=""></path>
<circle class="end-marker" id="timelineEndMarker" r="5" cx="0" cy="0"></circle>
<circle class="comet" id="timelineComet" r="6" cx="0" cy="0" style="opacity:0"></circle>
</svg>
<div class="timeline-grid">
<!--
EDITABLE STARTER CONTENT: swap the dates / copy below for the real
project history. The serpentine line auto-routes through every
.timeline-node in order, so you can add or remove milestones freely.
-->
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-seedling"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">01 · Origin</span>
<span class="timeline-date">June 2024</span>
</div>
<h3 class="timeline-title mb-3">Born as SNLK SQB BOT</h3>
<p class="timeline-desc">
Commissioned by ImApollo and first launched as
<strong class="text-accent">SNLK SQB BOT</strong>. The earliest build is running
within a week, with match data still entered by hand. Within weeks, the prototype
is already being shared with other servers.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>2 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-terminal"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">02 · Scoreboards</span>
<span class="timeline-date">August 2024</span>
</div>
<h3 class="timeline-title mb-3">The First Logs</h3>
<p class="timeline-desc">
The original use for the bot was automating codeblock (diff) scoreboards
with red and green lines. You'd run a command, manually input how many of
each type there were, and it would update the message above. Zero tracking,
zero automation. The style was heavily influenced by TheH0G (Cade) and
progavman (Prog), who I met in SNLK.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>3 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-tag"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">03 · Rebrand</span>
<span class="timeline-date">October 2024</span>
</div>
<h3 class="timeline-title mb-3">Becoming SREBOT</h3>
<p class="timeline-desc">
The project outgrows its original name. SNLK SQB BOT becomes
<strong class="text-accent">SREBOT</strong>, the identity it still carries today.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>4 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-users"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">04 · Alliance</span>
<span class="timeline-date">January 2025</span>
</div>
<h3 class="timeline-title mb-3">Lux_ and Our Contributions to Each Other</h3>
<p class="timeline-desc">
Lux_ was working on his own bot at the time. We traded knowledge on War Thunder's
APIs and community management, back when neither of us knew how to read a winner
from a replay. His bot later became Spectra, and the cooperation never stopped.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>8 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-file-code"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">05 · Parser</span>
<span class="timeline-date">February 2025</span>
</div>
<h3 class="timeline-title mb-3">Frovy Opens the Door</h3>
<p class="timeline-desc">
Frovy shows us the first parser and gives SREBOT a real path toward automatic
scoreboards. He had also figured out how to request data from the game and receive
immediate point updates, even if he kept the method from us for months. :)
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>15 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-robot"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">06 · Logs</span>
<span class="timeline-date">March 2025</span>
</div>
<h3 class="timeline-title mb-3">LivingTheDagor and the New Parser</h3>
<p class="timeline-desc">
Dagor is introduced and the second parser is integrated, a much more robust one
that SREBOT still uses today. The logging pipeline matures around the parser work,
making scoreboards more informative.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>20 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-fire"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">07 · Momentum</span>
<span class="timeline-date">July 2025</span>
</div>
<h3 class="timeline-title mb-3">Word Gets Around</h3>
<p class="timeline-desc">
Squadrons start to take notice. The bot picks up momentum and grows
in popularity as more communities bring it into their servers.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>60 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-laptop-code"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">08 · Website</span>
<span class="timeline-date">August 2025</span>
</div>
<h3 class="timeline-title mb-3">Clippi Builds the Website</h3>
<p class="timeline-desc">
Clippi (Sophie) joins, mostly fueled by her hatred of Boris Bot, and takes
the lead on the website. She also pushes us off of Replit and onto proper
infrastructure, forcing me to actually learn how this stuff works. The site
goes live and keeps growing from there.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>100 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-route"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">09 · Paths</span>
<span class="timeline-date">November 2025</span>
</div>
<h3 class="timeline-title mb-3">Flexcoral's Heatmaps</h3>
<p class="timeline-desc">
While attempting to revive the WT Heatmaps project, Flexcoral (with help from a
developer-operated research group) figured out rendering of the minimaps and
player paths from replay data, later publishing his own parsing and rendering
implementations that we based our current methods off of.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>340 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-handshake"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">10 · Partnership</span>
<span class="timeline-date">January 2026</span>
</div>
<h3 class="timeline-title mb-3">Teaming Up with Spectra</h3>
<p class="timeline-desc">
The sheer volume of requests to Gaijin forces a change of approach. SREBOT
consolidates and partners with Spectra to receive games directly, instead of
downloading and parsing every match on its own.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>450 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-coins"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">11 · Monetization</span>
<span class="timeline-date">April 2026</span>
</div>
<h3 class="timeline-title mb-3">Fueling the Future</h3>
<p class="timeline-desc">
Monetization brings in real income for the first time, funding better
servers and helping new features ship faster.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>580 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-plug"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">12 · Clients</span>
<span class="timeline-date">May 2026</span>
</div>
<h3 class="timeline-title mb-3">Supporting Client Ports</h3>
<p class="timeline-desc">
We start supporting a client port of our project, letting other bots
build on top of what we made. First up is AXBot, which serves the
Chinese portion of the SQB playerbase.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span>600 Servers</span>
</div>
</div>
</div>
</article>
<article class="timeline-node">
<div class="timeline-card">
<div class="timeline-marker"><i class="fas fa-location-dot"></i></div>
<div class="pt-3">
<div class="flex items-center justify-between mb-2">
<span class="timeline-index">13 · Today</span>
<span class="timeline-date">Now</span>
</div>
<h3 class="timeline-title mb-3">Where We Are Now</h3>
<p class="timeline-desc">
Automated parsing through Spectra, funded development, and a growing
community of squadrons define the project today. We're already working on
our next product, <a href="https://tss.pawjob.us" target="_blank" rel="noopener" class="text-accent font-semibold hover:underline">TSSBOT</a>.
</p>
<div class="timeline-card-footer">
<i class="fab fa-discord"></i><span><%= serverCount %> Servers</span>
</div>
</div>
</div>
</article>
</div>
</div>
<div class="text-center mt-20">
<a href="/" class="btn-primary px-8 py-4 rounded-xl text-base font-bold inline-flex items-center"
style="background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%); color:#1E1E1E;">
<i class="fas fa-home mr-3"></i><%= t('common.backToHome') %>
</a>
</div>
</div>
</section>
<!-- Scroll hint — fades out on first scroll -->
<div id="scroll-hint">
<span>SCROLL</span>
<div class="hint-line"></div>
</div>
<!-- Footer -->
<%- include('partials/footer') %>
<script>
window.__lang = '<%= lang %>';
window.__i18n = <%- localeJson %>;
window.__t = function(key) {
var parts = key.split('.'), obj = window.__i18n;
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
return obj !== undefined ? obj : key;
};
window.switchLanguage = function(lang) {
var next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
if (next === document.documentElement.lang) return;
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
window.location.reload();
};
</script>
<script src="/js/main.js?v=3"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.1.14/dist/lenis.min.js"></script>
<!-- Serpentine timeline renderer -->
<script>
(function () {
var timeline = document.getElementById('timeline');
var svg = document.getElementById('timelineLine');
var track = document.getElementById('timelineTrack');
var progress = document.getElementById('timelineProgress');
var comet = document.getElementById('timelineComet');
if (!timeline || !svg) return;
var nodes = Array.prototype.slice.call(timeline.querySelectorAll('.timeline-node'));
var endMarker = document.getElementById('timelineEndMarker');
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var useGsap = !!(window.gsap && window.ScrollTrigger);
var drawProgress = 0; // 0 to 1, how much of the line is drawn
var targetDrawProgress = 0;
var pathLength = 0;
var nodeProgresses = [];
var nodeRevealed = [];
var state = { progress: 0 };
var lineTrigger = null;
var nodeTriggers = [];
var nextRevealIndex = 0;
var preRevealProgress = 0;
// Build a rounded-corner path that threads through a list of points.
function roundedPath(pts, radius) {
if (pts.length < 2) return '';
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length - 1; i++) {
var p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1];
var v1 = unit(p1, p0), v2 = unit(p1, p2);
var r1 = Math.min(radius, dist(p0, p1) / 2);
var r2 = Math.min(radius, dist(p2, p1) / 2);
var a = { x: p1.x + v1.x * r1, y: p1.y + v1.y * r1 };
var b = { x: p1.x + v2.x * r2, y: p1.y + v2.y * r2 };
d += ' L ' + a.x.toFixed(1) + ' ' + a.y.toFixed(1);
d += ' Q ' + p1.x.toFixed(1) + ' ' + p1.y.toFixed(1) + ' ' + b.x.toFixed(1) + ' ' + b.y.toFixed(1);
}
var last = pts[pts.length - 1];
d += ' L ' + last.x.toFixed(1) + ' ' + last.y.toFixed(1);
return d;
}
function unit(from, to) {
var dx = to.x - from.x, dy = to.y - from.y, len = Math.hypot(dx, dy) || 1;
return { x: dx / len, y: dy / len };
}
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
// Returns the marker's centre relative to the timeline container using
// offsetTop/offsetLeft traversal — unaffected by CSS/GSAP transforms.
function getMarkerCenter(node) {
var marker = node.querySelector('.timeline-marker') || node;
var x = marker.offsetLeft + marker.offsetWidth / 2;
var y = marker.offsetTop + marker.offsetHeight / 2;
var el = marker.offsetParent;
while (el && el !== timeline) {
x += el.offsetLeft;
y += el.offsetTop;
el = el.offsetParent;
}
return { x: x, y: y };
}
function build() {
_samples = null;
// Use layout dimensions (not getBoundingClientRect) so the SVG
// coordinate system matches the transform-independent getMarkerCenter.
svg.setAttribute('viewBox', '0 0 ' + timeline.offsetWidth + ' ' + timeline.offsetHeight);
var pts = nodes.map(getMarkerCenter);
var d = roundedPath(pts, 36);
track.setAttribute('d', d);
progress.setAttribute('d', d);
pathLength = progress.getTotalLength();
progress.style.strokeDasharray = pathLength;
progress.style.strokeDashoffset = pathLength * (1 - drawProgress);
nodeProgresses = nodes.map(function (node, index) {
return nodePathProgress(node, index, pathLength);
});
if (endMarker && pts.length) {
var ep = pts[pts.length - 1];
endMarker.setAttribute('cx', ep.x.toFixed(1));
endMarker.setAttribute('cy', ep.y.toFixed(1));
}
updateComet(pathLength);
return { pts: pts, len: pathLength };
}
function updateComet(len) {
if (reduceMotion || drawProgress <= 0 || drawProgress >= 1 || !_samples || !_samples.length) {
comet.style.opacity = 0;
return;
}
// Binary search then interpolate — smooth position without getPointAtLength()
var target = len * drawProgress;
var lo = 0, hi = _samples.length - 1;
while (lo < hi) {
var mid = (lo + hi) >> 1;
if (_samples[mid].l < target) lo = mid + 1; else hi = mid;
}
var cx, cy;
if (lo > 0) {
var a = _samples[lo - 1], b = _samples[lo];
var t = (b.l > a.l) ? (target - a.l) / (b.l - a.l) : 0;
cx = (a.x + (b.x - a.x) * t).toFixed(1);
cy = (a.y + (b.y - a.y) * t).toFixed(1);
} else {
cx = _samples[0].x.toFixed(1);
cy = _samples[0].y.toFixed(1);
}
comet.setAttribute('cx', cx);
comet.setAttribute('cy', cy);
comet.style.opacity = 1;
}
// Find roughly how far along the path a point sits (coarse sampling).
var _samples = null;
function lengthAtPoint(target, len) {
if (!_samples) {
_samples = [];
var steps = 120;
for (var i = 0; i <= steps; i++) {
var l = len * (i / steps);
var p = progress.getPointAtLength(l);
_samples.push({ l: l, x: p.x, y: p.y });
}
}
var best = 0, bestD = Infinity;
for (var j = 0; j < _samples.length; j++) {
var dd = Math.hypot(_samples[j].x - target.x, _samples[j].y - target.y);
if (dd < bestD) { bestD = dd; best = _samples[j].l; }
}
return best;
}
function nodePathProgress(node, index, len) {
if (index === nodes.length - 1) return 1;
return lengthAtPoint(getMarkerCenter(node), len) / len;
}
function initPreReveal() {
var vh = window.innerHeight;
var sp = 0;
nodes.forEach(function (node, idx) {
if (node.getBoundingClientRect().top < vh * 0.95) {
sp = Math.max(sp, nodeProgresses[idx] || 0);
}
});
if (sp > state.progress) {
preRevealProgress = sp;
drawProgress = sp;
state.progress = sp;
targetDrawProgress = sp;
renderProgress();
}
}
function revealReachedNodes() {
// Scan forward only — nodes are in path order so we never need to re-check
while (nextRevealIndex < nodes.length) {
if (nodeRevealed[nextRevealIndex]) { nextRevealIndex++; continue; }
if (drawProgress < (nodeProgresses[nextRevealIndex] || 0)) break;
var node = nodes[nextRevealIndex];
nodeRevealed[nextRevealIndex] = true;
node.classList.add('is-visible');
if (useGsap) {
node.style.background = 'rgba(27,27,27,0)';
gsap.fromTo(node,
{ y: -16, scale: 0.97, backgroundColor: 'rgba(27,27,27,0)' },
{ y: 0, scale: 1, backgroundColor: 'rgba(27,27,27,1)', duration: 0.55, ease: 'power2.out', overwrite: true,
onComplete: function () { node.style.background = '#1b1b1b'; } });
var card = node.querySelector('.timeline-card');
if (card) gsap.fromTo(card, { opacity: 0 }, { opacity: 1, duration: 0.55, ease: 'power2.out' });
}
nextRevealIndex++;
}
}
function renderProgress() {
drawProgress = state.progress;
progress.style.strokeDashoffset = pathLength * (1 - drawProgress);
updateComet(pathLength);
revealReachedNodes();
if (drawProgress >= 1) {
comet.style.opacity = 0;
nodes.forEach(function (n, index) {
nodeRevealed[index] = true;
n.classList.add('is-visible');
if (useGsap) {
n.style.background = '#1b1b1b';
gsap.set(n, { y: 0, scale: 1 });
var card = n.querySelector('.timeline-card');
if (card) gsap.set(card, { opacity: 1 });
}
});
}
}
function advanceTo(value, duration) {
var next = Math.max(targetDrawProgress, value);
targetDrawProgress = Math.min(1, Math.max(0, next));
if (targetDrawProgress <= state.progress + 0.001) return;
if (!useGsap) {
state.progress = targetDrawProgress;
renderProgress();
return;
}
gsap.to(state, {
progress: targetDrawProgress,
duration: duration || 0.85,
ease: 'power3.out',
overwrite: true,
onUpdate: renderProgress
});
}
function destroyScrollTriggers() {
if (lineTrigger) {
lineTrigger.kill();
lineTrigger = null;
}
nodeTriggers.forEach(function (trigger) { trigger.kill(); });
nodeTriggers = [];
}
function createScrollTriggers() {
if (!useGsap) return;
gsap.registerPlugin(ScrollTrigger);
destroyScrollTriggers();
nextRevealIndex = 0;
nodes.forEach(function (node, index) {
var card = node.querySelector('.timeline-card');
if (nodeRevealed[index]) {
nextRevealIndex = index + 1; // skip already-revealed nodes
node.style.background = '#1b1b1b';
gsap.set(node, { y: 0, scale: 1, transformOrigin: '50% 50%' });
if (card) gsap.set(card, { opacity: 1 });
} else {
node.style.background = ''; // no background: don't block the track
gsap.set(node, { y: -16, scale: 0.97, transformOrigin: '50% 50%' });
if (card) gsap.set(card, { opacity: 0 });
}
});
// Single trigger drives the whole line — no per-node triggers needed.
// start: timeline top at 70% of viewport (just entering view)
// end: last node top at 40% of viewport (card well into view before fully revealed)
lineTrigger = ScrollTrigger.create({
trigger: timeline,
start: 'top 70%',
endTrigger: nodes[nodes.length - 1] || timeline,
end: 'top 60%',
scrub: 0.6,
invalidateOnRefresh: true,
onUpdate: function (self) {
var p = Math.min(1, preRevealProgress + self.progress * (1 - preRevealProgress));
if (p <= state.progress) return;
state.progress = p;
targetDrawProgress = p;
drawProgress = p;
renderProgress();
}
});
}
function init() {
build();
if (reduceMotion) {
drawProgress = 1;
state.progress = 1;
progress.style.strokeDashoffset = 0;
nodes.forEach(function (n) { n.classList.add('is-visible'); });
return;
}
if (useGsap) {
createScrollTriggers();
ScrollTrigger.refresh();
initPreReveal();
var hint = document.getElementById('scroll-hint');
function hideScrollHint() {
if (!hint) return;
hint.style.opacity = '0';
hint = null;
}
if (window.Lenis) {
var lenis = new Lenis({
duration: 0.9,
easing: function (t) { return 1 - Math.pow(1 - t, 3); },
smoothWheel: true
});
lenis.on('scroll', function () {
hideScrollHint();
ScrollTrigger.update();
});
gsap.ticker.add(function (time) { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
} else {
window.addEventListener('scroll', hideScrollHint, { once: true });
}
} else {
advanceTo(1, 0);
}
}
// Recompute geometry on resize (layout flips between 1 and 3 columns).
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();
ScrollTrigger.refresh();
}
}, 120);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Re-measure after fonts/icons settle so markers are positioned correctly.
window.addEventListener('load', function () {
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();
ScrollTrigger.refresh();
initPreReveal();
}
});
})();
</script>
</body>
</html>