timeline changes
This commit is contained in:
+1
-1
@@ -611,7 +611,7 @@ app.use((req, res, next) => {
|
||||
} else {
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; " +
|
||||
"script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://cdn.jsdelivr.net; " +
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
|
||||
"font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
|
||||
+113
-93
@@ -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;
|
||||
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) {
|
||||
gsap.to(node, {
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.7,
|
||||
ease: 'power3.out',
|
||||
overwrite: true
|
||||
});
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user