660 lines
26 KiB
Plaintext
660 lines
26 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><%= t('timeline.pageTitle') %></title>
|
|
<meta name="description" content="<%= t('timeline.pageDescription') %>">
|
|
<meta property="og:title" content="<%= t('timeline.pageTitle') %>">
|
|
<meta property="og:description" content="<%= t('timeline.pageDescription') %>">
|
|
<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 .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-glow {
|
|
fill: rgba(144, 238, 144, 0.28);
|
|
filter: blur(5px);
|
|
}
|
|
|
|
.timeline-line .comet {
|
|
fill: #F5F5DC;
|
|
stroke: #90EE90;
|
|
stroke-width: 2;
|
|
filter: drop-shadow(0 0 6px rgba(144, 238, 144, 0.95));
|
|
}
|
|
|
|
.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: 56vh 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-node:nth-child(14) { grid-area: 5 / 2; }
|
|
}
|
|
|
|
.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"><%= t('timeline.eyebrow') %></p>
|
|
<h1 class="text-4xl lg:text-6xl font-extrabold mb-5 gradient-text"><%= t('timeline.heading') %></h1>
|
|
<p class="text-lg text-white/70 max-w-2xl mx-auto">
|
|
<%= t('timeline.pageDescription') %>
|
|
</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="progress" id="timelineProgress" d=""></path>
|
|
<circle class="comet-glow" id="timelineCometGlow" r="12" cx="0" cy="0" style="opacity:0"></circle>
|
|
<circle class="comet" id="timelineComet" r="5" cx="0" cy="0" style="opacity:0"></circle>
|
|
</svg>
|
|
|
|
<%
|
|
const timelineCards = [
|
|
{ icon: 'seedling', count: 2 }, { icon: 'terminal', count: 3 },
|
|
{ icon: 'tag', count: 4 }, { icon: 'users', count: 8 },
|
|
{ icon: 'file-code', count: 15 }, { icon: 'robot', count: 20 },
|
|
{ icon: 'fire', count: 60 }, { icon: 'laptop-code', count: 100 },
|
|
{ icon: 'route', count: 340 }, { icon: 'handshake', count: 450 },
|
|
{ icon: 'coins', count: 580 }, { icon: 'plug', count: 600 },
|
|
{ icon: 'lightbulb', count: 610 }, { icon: 'location-dot', count: serverCount }
|
|
];
|
|
%>
|
|
<div class="timeline-grid">
|
|
<% timelineCards.forEach((card, index) => { const key = 'timeline.cards.' + (index + 1); %>
|
|
<article class="timeline-node">
|
|
<div class="timeline-card">
|
|
<div class="timeline-marker"><i class="fas fa-<%= card.icon %>"></i></div>
|
|
<div class="pt-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="timeline-index"><%= String(index + 1).padStart(2, '0') %> · <%= t(key + '.label') %></span>
|
|
<span class="timeline-date"><%= t(key + '.date') %></span>
|
|
</div>
|
|
<h3 class="timeline-title mb-3"><%= t(key + '.title') %></h3>
|
|
<p class="timeline-desc"><%- t(key + '.description') %></p>
|
|
<div class="timeline-card-footer">
|
|
<i class="fab fa-discord"></i><span><%= t('timeline.servers', { count: card.count }) %></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><%= t('timeline.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>
|
|
|
|
<!-- Serpentine timeline renderer -->
|
|
<script>
|
|
(function () {
|
|
var timeline = document.getElementById('timeline');
|
|
var svg = document.getElementById('timelineLine');
|
|
var progress = document.getElementById('timelineProgress');
|
|
var comet = document.getElementById('timelineComet');
|
|
var cometGlow = document.getElementById('timelineCometGlow');
|
|
if (!timeline || !svg) return;
|
|
|
|
var nodes = Array.prototype.slice.call(timeline.querySelectorAll('.timeline-node'));
|
|
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);
|
|
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);
|
|
});
|
|
|
|
updateComet(pathLength);
|
|
return { pts: pts, len: pathLength };
|
|
}
|
|
|
|
function updateComet(len) {
|
|
if (!comet || !cometGlow || reduceMotion || drawProgress <= 0 || drawProgress >= 1) {
|
|
if (comet) comet.style.opacity = 0;
|
|
if (cometGlow) cometGlow.style.opacity = 0;
|
|
return;
|
|
}
|
|
var point = progress.getPointAtLength(len * drawProgress);
|
|
var x = point.x.toFixed(1);
|
|
var y = point.y.toFixed(1);
|
|
comet.setAttribute('cx', x);
|
|
comet.setAttribute('cy', y);
|
|
cometGlow.setAttribute('cx', x);
|
|
cometGlow.setAttribute('cy', y);
|
|
comet.style.opacity = 1;
|
|
cometGlow.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) {
|
|
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: true,
|
|
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.scrollY > 8) hideScrollHint();
|
|
window.addEventListener('scroll', hideScrollHint, { once: true, passive: 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>
|