Files
FeedGine/scripts/cursor.js
2026-04-22 10:57:37 +02:00

257 lines
9.1 KiB
JavaScript

// cursor.js — Venom/Spider Cursor · Space Edition (white + neon)
document.addEventListener("DOMContentLoaded", function () {
if (window.matchMedia("(pointer: coarse)").matches) return;
/* ── CONFIG ── */
const CONFIG = {
tentacleCount: 10,
triggerDist: 8,
maxLength: 300,
connectionDist: 150,
prediction: 3.5,
// Idle: cold silver-white
idleStroke: { r: 210, g: 228, b: 255 },
idleGlow: { r: 160, g: 200, b: 255 },
// Hover: neon orange (matches site accent)
hoverStroke: { r: 255, g: 102, b: 0 },
hoverGlow: { r: 255, g: 80, b: 0 },
};
/* ── toggle ── */
const toggleBtn = document.getElementById('cursorToggle');
const body = document.body;
let isCursorDisabled = localStorage.getItem('venomCursorDisabled') !== 'false';
function updateCursorState() {
if (isCursorDisabled) {
body.classList.add('system-cursor');
document.documentElement.style.cursor = '';
if (toggleBtn) {
toggleBtn.classList.remove('active');
const icon = toggleBtn.querySelector('.cursor-icon');
if (icon && icon.tagName === 'IMG') {
icon.src = 'images/icons/spider.png';
icon.alt = 'Spider Cursor';
}
}
} else {
body.classList.remove('system-cursor');
document.documentElement.style.cursor = 'none';
if (toggleBtn) {
toggleBtn.classList.add('active');
const icon = toggleBtn.querySelector('.cursor-icon');
if (icon && icon.tagName === 'IMG') {
icon.src = 'images/additional/cursor.png';
icon.alt = 'Custom Cursor';
}
}
}
}
updateCursorState();
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
isCursorDisabled = !isCursorDisabled;
localStorage.setItem('venomCursorDisabled', isCursorDisabled);
updateCursorState();
});
}
/* ── canvas ── */
const canvas = document.createElement('canvas');
canvas.id = 'venom-cursor';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
/* ── state ── */
const mouse = { x: 0, y: 0 };
const oldMouse = { x: 0, y: 0 };
let isHover = false;
let colorT = 0; // 0 = idle, 1 = hover (lerped)
const tentacles = [];
let rotation = 0; // reticle ring slow spin
/* ── hover selector ── */
const HOVER_SEL = [
'a', 'button', '[role="button"]', 'select', 'label',
'.nav-link', '.btn-primary', '.btn-ghost', '.nav-demo-btn',
'.price-cta', '.prob-card', '.module-card', '.how-step',
'.dropdown-item', '.mobile-nav-link', '.mobile-nav-cta',
'[onclick]', '.clickable'
].join(',');
document.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
const el = document.elementFromPoint(e.clientX, e.clientY);
isHover = !!el && (el.matches(HOVER_SEL) || !!el.closest(HOVER_SEL));
}, { passive: true });
/* ── colour helper ── */
function lerpColor(a, b, t) {
return {
r: a.r + (b.r - a.r) * t | 0,
g: a.g + (b.g - a.g) * t | 0,
b: a.b + (b.b - a.b) * t | 0,
};
}
/* ── Tentacle ── */
class Tentacle {
constructor(targetX, targetY) {
this.anchor = { x: targetX, y: targetY };
this.dead = false;
this.dist = 0;
this.age = 0;
}
update() {
const dx = mouse.x - this.anchor.x;
const dy = mouse.y - this.anchor.y;
this.dist = Math.sqrt(dx * dx + dy * dy);
this.age++;
if (this.dist > CONFIG.maxLength) this.dead = true;
}
draw(stroke, glow) {
if (this.dead) return;
const tension = Math.min(this.dist / CONFIG.maxLength, 1);
// Fade in over first 10 frames
const fadeIn = Math.min(this.age / 10, 1);
const lineAlpha = fadeIn * (1 - tension * 0.85) * 0.70;
const dotAlpha = fadeIn * (1 - tension) * 0.90;
const lw = Math.max(0.2, 1.1 * (1 - tension * 0.80));
// Glow pass (wide, soft)
ctx.save();
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},${lineAlpha * 0.55})`;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(mouse.x, mouse.y);
ctx.lineTo(this.anchor.x, this.anchor.y);
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${lineAlpha})`;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
// Anchor dot
ctx.beginPath();
ctx.arc(this.anchor.x, this.anchor.y, 1.8 * (1 - tension), 0, Math.PI * 2);
ctx.fillStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${dotAlpha})`;
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},${dotAlpha * 0.6})`;
ctx.shadowBlur = 4;
ctx.fill();
ctx.shadowBlur = 0;
}
}
/* ── render ── */
function render() {
if (isCursorDisabled) {
requestAnimationFrame(render);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Lerp color T
colorT += (isHover ? 1 : 0 - colorT) * 0.12;
const stroke = lerpColor(CONFIG.idleStroke, CONFIG.hoverStroke, colorT);
const glow = lerpColor(CONFIG.idleGlow, CONFIG.hoverGlow, colorT);
/* spawn tentacle on movement */
const moved = Math.hypot(mouse.x - oldMouse.x, mouse.y - oldMouse.y);
if (moved > CONFIG.triggerDist) {
const vx = mouse.x - oldMouse.x;
const vy = mouse.y - oldMouse.y;
const tx = mouse.x + vx * CONFIG.prediction + (Math.random() - 0.5) * 60;
const ty = mouse.y + vy * CONFIG.prediction + (Math.random() - 0.5) * 60;
tentacles.push(new Tentacle(tx, ty));
oldMouse.x = mouse.x;
oldMouse.y = mouse.y;
}
if (tentacles.length > CONFIG.tentacleCount) tentacles.shift();
/* update + draw tentacles */
for (let i = tentacles.length - 1; i >= 0; i--) {
tentacles[i].update();
if (tentacles[i].dead) tentacles.splice(i, 1);
else tentacles[i].draw(stroke, glow);
}
/* web connections between close anchor pairs */
for (let i = 0; i < tentacles.length; i++) {
for (let j = i + 1; j < tentacles.length; j++) {
const a = tentacles[i].anchor;
const b = tentacles[j].anchor;
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < CONFIG.connectionDist) {
const alpha = (1 - d / CONFIG.connectionDist) * 0.28;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},${alpha})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
/* cursor shape — segmented reticle ring */
rotation += isHover ? 0.040 : 0.016;
const ringR = isHover ? 9 : 7;
const gapHalf = 0.28; // gap half-angle in radians at each cardinal point
const segments = [
[rotation + gapHalf, rotation + Math.PI * 0.5 - gapHalf],
[rotation + Math.PI * 0.5 + gapHalf, rotation + Math.PI - gapHalf],
[rotation + Math.PI + gapHalf, rotation + Math.PI * 1.5 - gapHalf],
[rotation + Math.PI * 1.5 + gapHalf, rotation + Math.PI * 2.0 - gapHalf],
];
ctx.save();
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},0.75)`;
ctx.shadowBlur = isHover ? 14 : 8;
ctx.strokeStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},0.90)`;
ctx.lineWidth = isHover ? 1.4 : 1.1;
ctx.lineCap = 'round';
for (const [start, end] of segments) {
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, ringR, start, end);
ctx.stroke();
}
ctx.restore();
// Center dot — exact cursor tip
ctx.save();
ctx.shadowColor = `rgba(${glow.r},${glow.g},${glow.b},0.90)`;
ctx.shadowBlur = isHover ? 10 : 6;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, isHover ? 2.2 : 1.6, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${stroke.r},${stroke.g},${stroke.b},1)`;
ctx.fill();
ctx.restore();
requestAnimationFrame(render);
}
render();
});