fix(pulse): smooth split + no direct box pulses (#12)
* feat: random light pulse along threads + box outlines A blurred light pulse glides along one of the 5 wave threads (wave1/2/3, thread1/2) every 2.5-7s. Color matches the active theme (cyan on home/portfolio/about, pink/magenta on contact). Pulse also follows the outline of designated boxes marked with data-trail-box: - Home: 'Looking for inspiration?' + 'Ready to collaborate?' - Contact: 'Waiting to hear from you' card Boxes are rebuilt on every pulse so they stay aligned during scroll and respect viewport (off-screen boxes are skipped). Pulse uses requestAnimationFrame + path.getPointAtLength for native smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend mode so it blends with the dark background. * feat(pulse): slower, brighter, comet-shaped pulse along paths Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape (soft halo + mid glow + bright core), rotated to path tangent, ease-in-out timing, mix-blend-mode screen. Fixes: pulse not tracking path was a parallax-offset coordinate mismatch. Cleaned up via getSpeedKey() helper. * fix(pulse): tone down white core so theme color dominates Core was pure white (#ffffff) which washed out the theme color. Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller (10x3 vs 14x4) with 75% opacity so the purple/pink and cyan glow layers come through more visibly. * feat(pulse): double the glide duration Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s). * feat(pulse): split into two halves around boxes When a thread-pulse hits a data-trail-box bound, it splits: one half glides along the top of the box outline, the other along the bottom. They re-merge on the far side and continue as one along the rest of the thread. Implementation: - Two pulse-blob groups (blob-1, blob-2) in SVG - Box-intersection scan samples path every 4px to find entry + exit points - Phase 1: blob-1 glides from start to entry - Phase 2: blob-1 follows top-half of box outline, blob-2 follows bottom-half (both spawned dynamically) - Phase 3: blob-1 continues from exit to end, blob-2 hides - Velocity kept constant across phases so split makes the animation longer rather than faster * fix(pulse): remove direct box-pulses + smooth split transition Two fixes after review: 1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the 5 wave threads. Box outlines are followed only via the split mechanism when a thread-pulse intersects a box. 2. Smooth split transition via Bezier curves: split-path now starts at the EXACT thread intersection point (entryX/Y) and ends at exact exit point, curving smoothly up/down to the box top/bottom edge. Previously the path jumped to box.cy which caused a visible teleport when entering the split phase.
This commit is contained in:
@@ -120,7 +120,6 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
|
||||||
type BoxRect = { left: number; right: number; top: number; bottom: number; cx: number; cy: number; r: number };
|
type BoxRect = { left: number; right: number; top: number; bottom: number; cx: number; cy: number; r: number };
|
||||||
type PulsePath = { kind: 'thread'; id: string; pathTx: number } | { kind: 'box'; cx: number; cy: number };
|
|
||||||
|
|
||||||
if (svg && blob1 && blob2 && wrapper) {
|
if (svg && blob1 && blob2 && wrapper) {
|
||||||
const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
|
const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
|
||||||
@@ -129,49 +128,29 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
|
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
|
||||||
const NS = 'http://www.w3.org/2000/svg';
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
function buildBoxPath(rect: DOMRect): string {
|
|
||||||
const r = 16;
|
|
||||||
const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
|
|
||||||
return [
|
|
||||||
`M ${x + r} ${y}`,
|
|
||||||
`H ${x + w - r}`,
|
|
||||||
`A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,
|
|
||||||
`V ${y + h - r}`,
|
|
||||||
`A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}`,
|
|
||||||
`H ${x + r}`,
|
|
||||||
`A ${r} ${r} 0 0 1 ${x} ${y + h - r}`,
|
|
||||||
`V ${y + r}`,
|
|
||||||
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
|
|
||||||
`Z`,
|
|
||||||
].join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBoxGroup() {
|
function clearBoxGroup() {
|
||||||
if (!boxGroup) return;
|
if (!boxGroup) return;
|
||||||
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
|
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshBoxPaths(): { paths: SVGPathElement[]; rects: DOMRect[] } {
|
function refreshBoxes(): BoxRect[] {
|
||||||
if (!boxGroup) return { paths: [], rects: [] };
|
|
||||||
clearBoxGroup();
|
|
||||||
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
|
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
|
||||||
const paths: SVGPathElement[] = [];
|
const out: BoxRect[] = [];
|
||||||
const rects: DOMRect[] = [];
|
targets.forEach(el => {
|
||||||
targets.forEach((el, idx) => {
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width < 20 || rect.height < 20) return;
|
if (rect.width < 20 || rect.height < 20) return;
|
||||||
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
|
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
|
||||||
const p = document.createElementNS(NS, 'path');
|
out.push({
|
||||||
p.setAttribute('id', `trail-box-${idx}`);
|
left: rect.left,
|
||||||
p.setAttribute('d', buildBoxPath(rect));
|
right: rect.right,
|
||||||
p.setAttribute('fill', 'none');
|
top: rect.top,
|
||||||
p.setAttribute('stroke', 'transparent');
|
bottom: rect.bottom,
|
||||||
p.setAttribute('data-trail-box-path', 'true');
|
cx: rect.left + rect.width / 2,
|
||||||
boxGroup.appendChild(p);
|
cy: rect.top + rect.height / 2,
|
||||||
paths.push(p);
|
r: 16,
|
||||||
rects.push(rect);
|
});
|
||||||
});
|
});
|
||||||
return { paths, rects };
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePulseColors(isSecondary: boolean) {
|
function updatePulseColors(isSecondary: boolean) {
|
||||||
@@ -189,83 +168,105 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
|
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rectExpand(rect: DOMRect, pad: number): BoxRect {
|
|
||||||
return {
|
|
||||||
left: rect.left - pad,
|
|
||||||
right: rect.right + pad,
|
|
||||||
top: rect.top - pad,
|
|
||||||
bottom: rect.bottom + pad,
|
|
||||||
cx: rect.left + rect.width / 2,
|
|
||||||
cy: rect.top + rect.height / 2,
|
|
||||||
r: 16,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointInBox(x: number, y: number, b: BoxRect): boolean {
|
function pointInBox(x: number, y: number, b: BoxRect): boolean {
|
||||||
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
|
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Intersection = {
|
||||||
|
boxIdx: number;
|
||||||
|
entryLen: number;
|
||||||
|
exitLen: number;
|
||||||
|
entryX: number;
|
||||||
|
entryY: number;
|
||||||
|
exitX: number;
|
||||||
|
exitY: number;
|
||||||
|
};
|
||||||
|
|
||||||
function findBoxIntersection(
|
function findBoxIntersection(
|
||||||
path: SVGPathElement,
|
path: SVGPathElement,
|
||||||
pathTx: number,
|
pathTx: number,
|
||||||
boxes: BoxRect[]
|
boxes: BoxRect[]
|
||||||
): { boxIdx: number; entryLen: number; exitLen: number } | null {
|
): Intersection | null {
|
||||||
const total = path.getTotalLength();
|
const total = path.getTotalLength();
|
||||||
const step = 4;
|
const step = 3;
|
||||||
let activeBox = -1;
|
let activeBox = -1;
|
||||||
let entryLen = -1;
|
let entryLen = -1;
|
||||||
|
let entryX = 0, entryY = 0;
|
||||||
for (let len = 0; len <= total; len += step) {
|
for (let len = 0; len <= total; len += step) {
|
||||||
const pt = path.getPointAtLength(len);
|
const pt = path.getPointAtLength(len);
|
||||||
const x = pt.x + pathTx;
|
const x = pt.x + pathTx;
|
||||||
const y = pt.y;
|
const y = pt.y;
|
||||||
for (let bi = 0; bi < boxes.length; bi++) {
|
if (activeBox === -1) {
|
||||||
if (pointInBox(x, y, boxes[bi])) {
|
for (let bi = 0; bi < boxes.length; bi++) {
|
||||||
if (activeBox === -1) {
|
if (pointInBox(x, y, boxes[bi])) {
|
||||||
activeBox = bi;
|
activeBox = bi;
|
||||||
entryLen = len;
|
entryLen = len;
|
||||||
} else if (activeBox === bi) {
|
entryX = x;
|
||||||
// still in same box, continue
|
entryY = y;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
if (activeBox !== -1) {
|
if (!pointInBox(x, y, boxes[activeBox])) {
|
||||||
// Check if we left
|
return {
|
||||||
const stillIn = pointInBox(x, y, boxes[activeBox]);
|
boxIdx: activeBox,
|
||||||
if (!stillIn) {
|
entryLen,
|
||||||
return { boxIdx: activeBox, entryLen, exitLen: len };
|
exitLen: len,
|
||||||
|
entryX,
|
||||||
|
entryY,
|
||||||
|
exitX: x,
|
||||||
|
exitY: y,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBoxHalfPath(box: BoxRect, half: 'top' | 'bottom'): SVGPathElement {
|
function buildSplitHalfPath(
|
||||||
const r = box.r;
|
box: BoxRect,
|
||||||
const x = box.left + 16; // un-pad: revert to original rect
|
entry: { x: number; y: number },
|
||||||
const y = box.top + 16;
|
exit: { x: number; y: number },
|
||||||
const w = (box.right - box.left) - 32;
|
half: 'top' | 'bottom'
|
||||||
const h = (box.bottom - box.top) - 32;
|
): SVGPathElement {
|
||||||
// Entry-point assumed left side at cy; exit-point right side at cy
|
// Goal: smooth path from entry-point → out to box-edge → around half → back from box-edge → exit-point.
|
||||||
|
// We route via the actual box corners + halfway top or bottom of the box.
|
||||||
|
const xL = box.left, xR = box.right, yT = box.top, yB = box.bottom, r = box.r;
|
||||||
const yMid = box.cy;
|
const yMid = box.cy;
|
||||||
|
|
||||||
|
// Tangent-out from entry: smoothly leave the thread and head toward box-edge near top-or-bottom
|
||||||
|
// First waypoint on box-edge — closer to entry's y but rounded into the top/bottom corner
|
||||||
|
const targetTopY = yT;
|
||||||
|
const targetBotY = yB;
|
||||||
|
const apexY = half === 'top' ? targetTopY : targetBotY;
|
||||||
|
|
||||||
|
// Two control points to bezier from entry into the corner curve
|
||||||
|
const transitionDist = 24;
|
||||||
|
const cp1x = entry.x + (half === 'top' ? -10 : -10);
|
||||||
|
const cp1y = entry.y + (half === 'top' ? -transitionDist : transitionDist);
|
||||||
|
const cp2x = xL + r;
|
||||||
|
const cp2y = apexY;
|
||||||
|
|
||||||
|
// After top/bottom traversal, exit via similar curve back to exit-point
|
||||||
|
const cp3x = xR - r;
|
||||||
|
const cp3y = apexY;
|
||||||
|
const cp4x = exit.x + (half === 'top' ? 10 : 10);
|
||||||
|
const cp4y = exit.y + (half === 'top' ? -transitionDist : transitionDist);
|
||||||
|
|
||||||
let d: string;
|
let d: string;
|
||||||
if (half === 'top') {
|
if (half === 'top') {
|
||||||
d = [
|
d = [
|
||||||
`M ${x} ${yMid}`,
|
`M ${entry.x} ${entry.y}`,
|
||||||
`V ${y + r}`,
|
`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y - r}, ${xL + r} ${yT}`,
|
||||||
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
|
`H ${xR - r}`,
|
||||||
`H ${x + w - r}`,
|
`C ${cp3x} ${cp3y - r}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`,
|
||||||
`A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,
|
|
||||||
`V ${yMid}`,
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
} else {
|
} else {
|
||||||
d = [
|
d = [
|
||||||
`M ${x} ${yMid}`,
|
`M ${entry.x} ${entry.y}`,
|
||||||
`V ${y + h - r}`,
|
`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y + r}, ${xL + r} ${yB}`,
|
||||||
`A ${r} ${r} 0 0 0 ${x + r} ${y + h}`,
|
`H ${xR - r}`,
|
||||||
`H ${x + w - r}`,
|
`C ${cp3x} ${cp3y + r}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`,
|
||||||
`A ${r} ${r} 0 0 0 ${x + w} ${y + h - r}`,
|
|
||||||
`V ${yMid}`,
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
const p = document.createElementNS(NS, 'path');
|
const p = document.createElementNS(NS, 'path');
|
||||||
@@ -296,8 +297,9 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
let isPulsing = false;
|
let isPulsing = false;
|
||||||
|
|
||||||
function runThreadPulse(path: SVGPathElement, durationMs: number) {
|
function runThreadPulse(path: SVGPathElement, durationMs: number) {
|
||||||
if (!blob1 || !blob2) return;
|
if (!blob1 || !blob2 || !boxGroup) return;
|
||||||
isPulsing = true;
|
isPulsing = true;
|
||||||
|
clearBoxGroup();
|
||||||
const total = path.getTotalLength();
|
const total = path.getTotalLength();
|
||||||
const isSecondary =
|
const isSecondary =
|
||||||
(path.getAttribute('stroke') || '').includes('gradSec') ||
|
(path.getAttribute('stroke') || '').includes('gradSec') ||
|
||||||
@@ -306,53 +308,46 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
updatePulseColors(isSecondary);
|
updatePulseColors(isSecondary);
|
||||||
|
|
||||||
const speedKey = getSpeedKey(path.id);
|
const speedKey = getSpeedKey(path.id);
|
||||||
const { rects: boxRects } = refreshBoxPaths();
|
const boxes = refreshBoxes();
|
||||||
const boxes: BoxRect[] = boxRects.map(r => rectExpand(r, 4));
|
|
||||||
|
|
||||||
const pathTx0 = speedKey ? window.scrollY * speeds[speedKey] : 0;
|
const pathTx0 = speedKey ? window.scrollY * speeds[speedKey] : 0;
|
||||||
const hit = findBoxIntersection(path, pathTx0, boxes);
|
const hit = findBoxIntersection(path, pathTx0, boxes);
|
||||||
|
|
||||||
// Velocity = pixels per ms across full pathLength over full duration
|
|
||||||
const velocity = total / durationMs;
|
const velocity = total / durationMs;
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
blob2.setAttribute('opacity', '0');
|
blob2.setAttribute('opacity', '0');
|
||||||
|
|
||||||
// PHASE 1: glide from 0 to entry (or full if no hit)
|
|
||||||
const entryLen = hit ? hit.entryLen : total;
|
const entryLen = hit ? hit.entryLen : total;
|
||||||
const exitLen = hit ? hit.exitLen : total;
|
const exitLen = hit ? hit.exitLen : total;
|
||||||
const phase1Duration = entryLen / velocity;
|
const phase1Duration = entryLen / velocity;
|
||||||
|
|
||||||
// PHASE 2: split around box from entryLen at velocity
|
|
||||||
let topPath: SVGPathElement | null = null;
|
let topPath: SVGPathElement | null = null;
|
||||||
let bottomPath: SVGPathElement | null = null;
|
let bottomPath: SVGPathElement | null = null;
|
||||||
let phase2Duration = 0;
|
let phase2Duration = 0;
|
||||||
if (hit) {
|
if (hit) {
|
||||||
topPath = buildBoxHalfPath(boxes[hit.boxIdx], 'top');
|
const entryPt = { x: hit.entryX, y: hit.entryY };
|
||||||
bottomPath = buildBoxHalfPath(boxes[hit.boxIdx], 'bottom');
|
const exitPt = { x: hit.exitX, y: hit.exitY };
|
||||||
if (boxGroup) {
|
topPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'top');
|
||||||
boxGroup.appendChild(topPath);
|
bottomPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'bottom');
|
||||||
boxGroup.appendChild(bottomPath);
|
boxGroup.appendChild(topPath);
|
||||||
}
|
boxGroup.appendChild(bottomPath);
|
||||||
const topLen = topPath.getTotalLength();
|
const topLen = topPath.getTotalLength();
|
||||||
const bottomLen = bottomPath.getTotalLength();
|
const bottomLen = bottomPath.getTotalLength();
|
||||||
const maxHalf = Math.max(topLen, bottomLen);
|
phase2Duration = Math.max(topLen, bottomLen) / velocity;
|
||||||
phase2Duration = maxHalf / velocity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PHASE 3: glide from exitLen to end at velocity
|
|
||||||
const phase3Duration = hit ? (total - exitLen) / velocity : 0;
|
const phase3Duration = hit ? (total - exitLen) / velocity : 0;
|
||||||
|
const totalTime = phase1Duration + phase2Duration + phase3Duration;
|
||||||
|
|
||||||
function step(now: number) {
|
function step(now: number) {
|
||||||
if (!blob1 || !blob2) return;
|
if (!blob1 || !blob2) return;
|
||||||
const elapsed = now - startTime;
|
const elapsed = now - startTime;
|
||||||
const totalTime = phase1Duration + phase2Duration + phase3Duration;
|
|
||||||
const globalT = Math.min(elapsed / totalTime, 1);
|
const globalT = Math.min(elapsed / totalTime, 1);
|
||||||
const fade = fadeFactor(globalT);
|
const fade = fadeFactor(globalT);
|
||||||
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
|
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
|
||||||
|
|
||||||
if (elapsed <= phase1Duration) {
|
if (elapsed <= phase1Duration) {
|
||||||
// Phase 1: single blob on path
|
|
||||||
const len = elapsed * velocity;
|
const len = elapsed * velocity;
|
||||||
const pt = path.getPointAtLength(Math.min(len, total));
|
const pt = path.getPointAtLength(Math.min(len, total));
|
||||||
const angle = tangentAngle(path, Math.min(len, total), total);
|
const angle = tangentAngle(path, Math.min(len, total), total);
|
||||||
@@ -360,7 +355,6 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
blob1.setAttribute('opacity', String(fade));
|
blob1.setAttribute('opacity', String(fade));
|
||||||
blob2.setAttribute('opacity', '0');
|
blob2.setAttribute('opacity', '0');
|
||||||
} else if (hit && elapsed <= phase1Duration + phase2Duration) {
|
} else if (hit && elapsed <= phase1Duration + phase2Duration) {
|
||||||
// Phase 2: split around box
|
|
||||||
const inPhase2 = elapsed - phase1Duration;
|
const inPhase2 = elapsed - phase1Duration;
|
||||||
if (topPath && bottomPath) {
|
if (topPath && bottomPath) {
|
||||||
const topLen = topPath.getTotalLength();
|
const topLen = topPath.getTotalLength();
|
||||||
@@ -379,7 +373,6 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
blob2.setAttribute('opacity', String(fade));
|
blob2.setAttribute('opacity', String(fade));
|
||||||
}
|
}
|
||||||
} else if (hit) {
|
} else if (hit) {
|
||||||
// Phase 3: single blob continues from exitLen
|
|
||||||
const inPhase3 = elapsed - phase1Duration - phase2Duration;
|
const inPhase3 = elapsed - phase1Duration - phase2Duration;
|
||||||
const len = exitLen + inPhase3 * velocity;
|
const len = exitLen + inPhase3 * velocity;
|
||||||
const clamped = Math.min(len, total);
|
const clamped = Math.min(len, total);
|
||||||
@@ -390,39 +383,12 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
blob2.setAttribute('opacity', '0');
|
blob2.setAttribute('opacity', '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed < (phase1Duration + phase2Duration + phase3Duration) && globalT < 1) {
|
if (elapsed < totalTime && globalT < 1) {
|
||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
} else {
|
} else {
|
||||||
blob1.setAttribute('opacity', '0');
|
blob1.setAttribute('opacity', '0');
|
||||||
blob2.setAttribute('opacity', '0');
|
blob2.setAttribute('opacity', '0');
|
||||||
if (topPath && boxGroup && topPath.parentNode) boxGroup.removeChild(topPath);
|
clearBoxGroup();
|
||||||
if (bottomPath && boxGroup && bottomPath.parentNode) boxGroup.removeChild(bottomPath);
|
|
||||||
isPulsing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runBoxPulse(boxPath: SVGPathElement, durationMs: number) {
|
|
||||||
if (!blob1 || !blob2) return;
|
|
||||||
isPulsing = true;
|
|
||||||
updatePulseColors(true);
|
|
||||||
const total = boxPath.getTotalLength();
|
|
||||||
const startTime = performance.now();
|
|
||||||
blob2.setAttribute('opacity', '0');
|
|
||||||
function step(now: number) {
|
|
||||||
if (!blob1) return;
|
|
||||||
const elapsed = now - startTime;
|
|
||||||
const t = Math.min(elapsed / durationMs, 1);
|
|
||||||
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
||||||
const len = eased * total;
|
|
||||||
const pt = boxPath.getPointAtLength(len);
|
|
||||||
const angle = tangentAngle(boxPath, len, total);
|
|
||||||
applyTransform(blob1, pt.x, pt.y, angle);
|
|
||||||
blob1.setAttribute('opacity', String(fadeFactor(t)));
|
|
||||||
if (t < 1) requestAnimationFrame(step);
|
|
||||||
else {
|
|
||||||
blob1.setAttribute('opacity', '0');
|
|
||||||
isPulsing = false;
|
isPulsing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,21 +400,12 @@ const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
|
|||||||
schedule();
|
schedule();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { paths: boxPaths } = refreshBoxPaths();
|
if (wavePaths.length === 0) {
|
||||||
const allTargets: { kind: 'wave' | 'box'; path: SVGPathElement }[] = [
|
|
||||||
...wavePaths.map(p => ({ kind: 'wave' as const, path: p })),
|
|
||||||
...boxPaths.map(p => ({ kind: 'box' as const, path: p })),
|
|
||||||
];
|
|
||||||
if (allTargets.length === 0) {
|
|
||||||
schedule();
|
schedule();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const choice = allTargets[Math.floor(Math.random() * allTargets.length)];
|
const choice = wavePaths[Math.floor(Math.random() * wavePaths.length)];
|
||||||
if (choice.kind === 'wave') {
|
runThreadPulse(choice, 18000 + Math.random() * 8000);
|
||||||
runThreadPulse(choice.path, 18000 + Math.random() * 8000);
|
|
||||||
} else {
|
|
||||||
runBoxPulse(choice.path, 14000 + Math.random() * 4000);
|
|
||||||
}
|
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user