timeline changes

This commit is contained in:
deploy
2026-06-04 21:59:01 +00:00
parent 732730829d
commit 6ceb800855
2 changed files with 117 additions and 97 deletions
+116 -96
View File
@@ -73,14 +73,12 @@
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px rgba(144, 238, 144, 0.45));
/* dash values are set by JS once we know the path length */
transition: stroke-dashoffset 0.05s linear;
/* no filter, no CSS transition — GSAP owns stroke-dashoffset each frame */
}
.timeline-line .comet {
fill: #F5F5DC;
filter: drop-shadow(0 0 10px rgba(245, 245, 220, 0.9)) drop-shadow(0 0 18px rgba(144, 238, 144, 0.6));
filter: drop-shadow(0 0 8px rgba(144, 238, 144, 0.7));
}
/* The grid of milestone nodes. Default = single column (mobile). */
@@ -112,18 +110,10 @@
.timeline-card {
position: relative;
background: linear-gradient(135deg, rgba(62, 78, 62, 0.22) 0%, rgba(44, 44, 44, 0.22) 100%);
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);
backdrop-filter: blur(12px);
border-radius: 1rem;
padding: 1.75rem;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.35s ease, box-shadow 0.35s ease;
}
.timeline-card:hover {
transform: translateY(-4px);
border-color: rgba(144, 238, 144, 0.35);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
}
/* Node marker: the dot that the line threads through */
@@ -170,21 +160,25 @@
font-size: 0.95rem;
}
/* Reveal animation, staggered as the line draws past each node */
/* 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. */
.timeline-node {
opacity: 0;
transform: translateY(28px);
position: relative;
z-index: 2;
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.2, 0.9, 0.25, 1);
/* background is set dynamically in JS only when a card starts
revealing, so unrevealed cards never block the SVG track line */
}
.timeline-node.is-visible {
/* 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;
transform: translateY(0);
transition: opacity 0.6s ease;
}
@media (prefers-reduced-motion: reduce) {
.timeline-node { opacity: 1; transform: none; transition: none; }
.timeline-node .timeline-card { opacity: 1 !important; transition: none; }
.timeline-line .progress { transition: none; }
}
</style>
@@ -419,6 +413,7 @@
<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>
@@ -441,7 +436,7 @@
var state = { progress: 0 };
var lineTrigger = null;
var nodeTriggers = [];
var scrollNormalized = false;
var nextRevealIndex = 0;
// Build a rounded-corner path that threads through a list of points.
function roundedPath(pts, radius) {
@@ -467,16 +462,28 @@
}
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function build() {
var box = timeline.getBoundingClientRect();
svg.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
// 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 };
}
// Anchor point of each node = centre of its circular marker.
var pts = nodes.map(function (node) {
var marker = node.querySelector('.timeline-marker');
var r = (marker || node).getBoundingClientRect();
return { x: r.left - box.left + r.width / 2, y: r.top - box.top + r.height / 2 };
});
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);
@@ -488,18 +495,35 @@
nodeProgresses = nodes.map(function (node, index) {
return nodePathProgress(node, index, pathLength);
});
updateComet(pathLength);
return { pts: pts, len: pathLength };
}
function updateComet(len) {
if (reduceMotion || drawProgress <= 0 || drawProgress >= 1) {
if (reduceMotion || drawProgress <= 0 || drawProgress >= 1 || !_samples || !_samples.length) {
comet.style.opacity = 0;
return;
}
var pt = progress.getPointAtLength(len * drawProgress);
comet.setAttribute('cx', pt.x);
comet.setAttribute('cy', pt.y);
// 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;
}
@@ -525,47 +549,43 @@
function nodePathProgress(node, index, len) {
if (index === nodes.length - 1) return 1;
var marker = node.querySelector('.timeline-marker');
var markerRect = (marker || node).getBoundingClientRect();
var box = timeline.getBoundingClientRect();
var pt = {
x: markerRect.left - box.left + markerRect.width / 2,
y: markerRect.top - box.top + markerRect.height / 2
};
return lengthAtPoint(pt, len) / len;
return lengthAtPoint(getMarkerCenter(node), len) / len;
}
function revealReachedNodes(len) {
nodes.forEach(function (node, index) {
if (nodeRevealed[index]) return;
if (drawProgress >= (nodeProgresses[index] || 0) - 0.012) {
nodeRevealed[index] = true;
node.classList.add('is-visible');
if (useGsap) {
gsap.to(node, {
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.7,
ease: 'power3.out',
overwrite: true
});
}
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 = '#1b1b1b';
gsap.fromTo(node, { y: -16, scale: 0.97 }, { y: 0, scale: 1, duration: 0.55, ease: 'power2.out', overwrite: true });
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(pathLength);
revealReachedNodes();
if (drawProgress >= 1) {
comet.style.opacity = 0;
nodes.forEach(function (n, index) {
nodeRevealed[index] = true;
n.classList.add('is-visible');
if (useGsap) gsap.set(n, { autoAlpha: 1, y: 0, scale: 1 });
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 });
}
});
}
}
@@ -603,50 +623,41 @@
if (!useGsap) return;
gsap.registerPlugin(ScrollTrigger);
if (!scrollNormalized) {
try {
ScrollTrigger.normalizeScroll({
allowNestedScroll: true,
lockAxis: false,
momentum: function (self) {
return Math.min(2.4, Math.abs(self.velocityY) / 1100);
}
});
} catch (e) {
ScrollTrigger.normalizeScroll(true);
}
scrollNormalized = true;
}
destroyScrollTriggers();
nextRevealIndex = 0;
nodes.forEach(function (node, index) {
gsap.set(node, nodeRevealed[index]
? { autoAlpha: 1, y: 0, scale: 1, transformOrigin: '50% 50%' }
: { autoAlpha: 0, y: 34, scale: 0.985, transformOrigin: '50% 50%' });
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 82%',
start: 'top 70%',
endTrigger: nodes[nodes.length - 1] || timeline,
end: 'top 82%',
scrub: 0.75,
end: 'top 40%',
scrub: 0.6,
invalidateOnRefresh: true,
onUpdate: function (self) {
advanceTo(self.progress, 0.45);
var p = Math.min(1, Math.max(0, self.progress));
if (p <= state.progress + 0.0005) return;
state.progress = p;
targetDrawProgress = p;
drawProgress = p;
renderProgress();
}
});
nodes.forEach(function (node, index) {
nodeTriggers.push(ScrollTrigger.create({
trigger: node,
start: 'top 82%',
once: true,
onEnter: function () {
advanceTo(nodeProgresses[index] || 0, 0.85);
}
}));
});
}
function init() {
@@ -662,6 +673,17 @@
createScrollTriggers();
renderProgress();
ScrollTrigger.refresh();
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', ScrollTrigger.update);
gsap.ticker.add(function (time) { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
}
} else {
advanceTo(1, 0);
}
@@ -672,7 +694,6 @@
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
_samples = null;
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();
@@ -688,7 +709,6 @@
}
// Re-measure after fonts/icons settle so markers are positioned correctly.
window.addEventListener('load', function () {
_samples = null;
build();
if (!reduceMotion && useGsap) {
createScrollTriggers();