feat: enhance resume interactions
This commit is contained in:
+317
-39
@@ -231,10 +231,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
<h2 id="rail-name">{displayNameFor(resume)}</h2>
|
||||
<p id="rail-intent">{resume.intent}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt id="phone-label">{labels.zh.phone}</dt>
|
||||
<dd><a id="rail-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt id="email-label">{labels.zh.email}</dt>
|
||||
<dd class="email-list" id="rail-emails">
|
||||
@@ -385,7 +381,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
<div class="contact-actions">
|
||||
<a class="button button-primary" id="send-email" href={`mailto:${resume.contact.email}`}>{labels.zh.sendEmail}</a>
|
||||
<a class="button" id="contact-email-alt" href={`mailto:${resume.contact.emailAlt}`}>{resume.contact.emailAlt}</a>
|
||||
<a class="button" id="contact-phone" href={`tel:${resume.contact.phone}`}>{resume.contact.phone}</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -604,11 +599,8 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
setText("rail-label", text.candidate);
|
||||
setText("rail-name", name);
|
||||
setText("rail-intent", data.intent);
|
||||
setText("phone-label", text.phone);
|
||||
setText("email-label", text.email);
|
||||
setText("education-label", text.education);
|
||||
setText("rail-phone", data.contact.phone);
|
||||
$("rail-phone").href = `tel:${data.contact.phone}`;
|
||||
setText("rail-email", data.contact.email);
|
||||
$("rail-email").href = `mailto:${data.contact.email}`;
|
||||
setText("rail-email-alt", data.contact.emailAlt);
|
||||
@@ -632,8 +624,6 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
$("send-email").href = `mailto:${data.contact.email}`;
|
||||
setText("contact-email-alt", data.contact.emailAlt);
|
||||
$("contact-email-alt").href = `mailto:${data.contact.emailAlt}`;
|
||||
setText("contact-phone", data.contact.phone);
|
||||
$("contact-phone").href = `tel:${data.contact.phone}`;
|
||||
setText("footer-text", `© 2026 ${name}. ${text.footer}`);
|
||||
|
||||
renderBasics(data);
|
||||
@@ -687,6 +677,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
ty: window.innerHeight * 0.42,
|
||||
speed: 0,
|
||||
};
|
||||
const trail = [];
|
||||
const bursts = [];
|
||||
const tokens = [];
|
||||
const tokenLexicon = ["AI", "SSE", "Agent", "LLM", "RAG", "Vision", "Next", "RN", "3D"];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
@@ -698,19 +692,32 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
? {
|
||||
dot: "rgba(139, 180, 255, 0.55)",
|
||||
dotAlt: "rgba(99, 215, 155, 0.5)",
|
||||
line: "rgba(244, 239, 230, 0.12)",
|
||||
dotHot: "rgba(255, 138, 168, 0.64)",
|
||||
line: "rgba(244, 239, 230, 0.11)",
|
||||
lineActive: "rgba(139, 180, 255, 0.48)",
|
||||
cursor: "rgba(240, 179, 90, 0.34)",
|
||||
trail: "rgba(139, 180, 255, 0.58)",
|
||||
trailFast: "rgba(255, 138, 168, 0.62)",
|
||||
burst: "rgba(240, 179, 90, 0.68)",
|
||||
token: "rgba(244, 239, 230, 0.7)",
|
||||
}
|
||||
: {
|
||||
dot: "rgba(47, 95, 190, 0.5)",
|
||||
dotAlt: "rgba(22, 133, 94, 0.45)",
|
||||
line: "rgba(23, 23, 23, 0.11)",
|
||||
dotHot: "rgba(180, 60, 100, 0.55)",
|
||||
line: "rgba(23, 23, 23, 0.1)",
|
||||
lineActive: "rgba(47, 95, 190, 0.42)",
|
||||
cursor: "rgba(167, 101, 22, 0.28)",
|
||||
trail: "rgba(47, 95, 190, 0.5)",
|
||||
trailFast: "rgba(180, 60, 100, 0.52)",
|
||||
burst: "rgba(167, 101, 22, 0.52)",
|
||||
token: "rgba(23, 23, 23, 0.58)",
|
||||
};
|
||||
}
|
||||
|
||||
function seedParticles() {
|
||||
const count = Math.min(86, Math.max(38, Math.floor((width * height) / 17000)));
|
||||
const compact = width < 760;
|
||||
const count = Math.min(compact ? 58 : 96, Math.max(compact ? 30 : 44, Math.floor((width * height) / (compact ? 23000 : 15500))));
|
||||
particles = Array.from({ length: count }, (_, index) => ({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
@@ -718,6 +725,7 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
vy: (Math.random() - 0.5) * 0.24,
|
||||
size: 0.8 + Math.random() * 1.8,
|
||||
hue: index % 3,
|
||||
pulse: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -734,10 +742,146 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
function onPointerMove(event) {
|
||||
const dx = event.clientX - pointer.tx;
|
||||
const dy = event.clientY - pointer.ty;
|
||||
const speed = Math.min(1, Math.hypot(dx, dy) / 80);
|
||||
pointer.tx = event.clientX;
|
||||
pointer.ty = event.clientY;
|
||||
pointer.speed = Math.min(1, Math.hypot(dx, dy) / 80);
|
||||
pointer.speed = Math.max(pointer.speed, speed);
|
||||
pointer.active = true;
|
||||
if (Math.hypot(dx, dy) > 2) {
|
||||
trail.push({ x: pointer.tx, y: pointer.ty, speed, life: 1 });
|
||||
if (trail.length > (width < 760 ? 18 : 28)) trail.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function emitBurst(x, y, power = 1) {
|
||||
const compact = width < 760;
|
||||
bursts.push({ x, y, age: 0, life: compact ? 42 : 54, power });
|
||||
if (bursts.length > 8) bursts.shift();
|
||||
|
||||
particles.forEach((particle) => {
|
||||
const dx = particle.x - x;
|
||||
const dy = particle.y - y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < 260) {
|
||||
const force = (1 - dist / 260) * (1.7 + power);
|
||||
particle.vx += (dx / (dist || 1)) * force * 0.58;
|
||||
particle.vy += (dy / (dist || 1)) * force * 0.58;
|
||||
particle.pulse = 1;
|
||||
}
|
||||
});
|
||||
|
||||
const tokenCount = compact ? 4 : 7;
|
||||
for (let index = 0; index < tokenCount; index += 1) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const velocity = 1.15 + Math.random() * 2.15;
|
||||
tokens.push({
|
||||
text: tokenLexicon[Math.floor(Math.random() * tokenLexicon.length)],
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * velocity,
|
||||
vy: Math.sin(angle) * velocity - 0.35,
|
||||
age: 0,
|
||||
life: 34 + Math.random() * 24,
|
||||
size: 9 + Math.random() * 4,
|
||||
});
|
||||
}
|
||||
if (tokens.length > 42) tokens.splice(0, tokens.length - 42);
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
onPointerMove(event);
|
||||
emitBurst(event.clientX, event.clientY, 0.75 + pointer.speed);
|
||||
if (event.target instanceof Element) {
|
||||
const target = event.target.closest(
|
||||
"a, button, .project-row, .map-node, .strength-grid article, .domain-strip article, .skill-list span, .tech-line span",
|
||||
);
|
||||
if (target) {
|
||||
target.classList.add("is-pressed");
|
||||
window.setTimeout(() => target.classList.remove("is-pressed"), 420);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTrail(colors) {
|
||||
for (let index = trail.length - 1; index >= 0; index -= 1) {
|
||||
trail[index].life *= 0.935;
|
||||
if (trail[index].life < 0.035) trail.splice(index, 1);
|
||||
}
|
||||
if (trail.length < 2) return;
|
||||
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (let index = 1; index < trail.length; index += 1) {
|
||||
const previous = trail[index - 1];
|
||||
const point = trail[index];
|
||||
const strength = Math.min(previous.life, point.life) * (index / trail.length);
|
||||
ctx.globalAlpha = strength * (0.34 + point.speed * 0.42);
|
||||
ctx.strokeStyle = point.speed > 0.46 ? colors.trailFast : colors.trail;
|
||||
ctx.lineWidth = 1.1 + point.speed * 2.4 + (index / trail.length) * 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(previous.x, previous.y);
|
||||
ctx.lineTo(point.x, point.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawBursts(colors) {
|
||||
bursts.forEach((burst) => {
|
||||
const t = burst.age / burst.life;
|
||||
const fade = Math.max(0, 1 - t);
|
||||
for (let ring = 0; ring < 3; ring += 1) {
|
||||
const radius = 10 + t * (62 + ring * 28) * burst.power + ring * 9;
|
||||
ctx.globalAlpha = fade * (0.38 - ring * 0.08);
|
||||
ctx.strokeStyle = ring === 1 ? colors.trail : colors.burst;
|
||||
ctx.lineWidth = 1.2 + ring * 0.35;
|
||||
ctx.beginPath();
|
||||
ctx.arc(burst.x, burst.y, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = fade * 0.34;
|
||||
ctx.strokeStyle = colors.burst;
|
||||
ctx.lineWidth = 1;
|
||||
for (let spoke = 0; spoke < 10; spoke += 1) {
|
||||
const angle = (Math.PI * 2 * spoke) / 10 + t * 0.7;
|
||||
const inner = 20 + t * 46;
|
||||
const outer = inner + 12 + burst.power * 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(burst.x + Math.cos(angle) * inner, burst.y + Math.sin(angle) * inner);
|
||||
ctx.lineTo(burst.x + Math.cos(angle) * outer, burst.y + Math.sin(angle) * outer);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
burst.age += 1;
|
||||
});
|
||||
for (let index = bursts.length - 1; index >= 0; index -= 1) {
|
||||
if (bursts[index].age > bursts[index].life) bursts.splice(index, 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawTokens(colors) {
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
tokens.forEach((token) => {
|
||||
const t = token.age / token.life;
|
||||
const fade = Math.max(0, 1 - t);
|
||||
token.x += token.vx;
|
||||
token.y += token.vy;
|
||||
token.vx *= 0.985;
|
||||
token.vy = token.vy * 0.985 - 0.004;
|
||||
token.age += 1;
|
||||
|
||||
ctx.globalAlpha = fade * 0.72;
|
||||
ctx.fillStyle = colors.token;
|
||||
ctx.font = `700 ${token.size}px Inter, ui-sans-serif, system-ui, sans-serif`;
|
||||
ctx.fillText(token.text, token.x, token.y);
|
||||
});
|
||||
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
||||
if (tokens[index].age > tokens[index].life) tokens.splice(index, 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
@@ -746,19 +890,21 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
pointer.x += (pointer.tx - pointer.x) * 0.08;
|
||||
pointer.y += (pointer.ty - pointer.y) * 0.08;
|
||||
pointer.speed *= 0.92;
|
||||
drawTrail(colors);
|
||||
|
||||
particles.forEach((particle, index) => {
|
||||
const dx = particle.x - pointer.x;
|
||||
const dy = particle.y - pointer.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (pointer.active && dist < 150) {
|
||||
const force = (1 - dist / 150) * (0.55 + pointer.speed);
|
||||
if (pointer.active && dist < 172) {
|
||||
const force = (1 - dist / 172) * (0.62 + pointer.speed);
|
||||
particle.vx += (dx / (dist || 1)) * force * 0.018;
|
||||
particle.vy += (dy / (dist || 1)) * force * 0.018;
|
||||
}
|
||||
|
||||
particle.vx *= 0.988;
|
||||
particle.vy *= 0.988;
|
||||
particle.pulse *= 0.92;
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
@@ -770,10 +916,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
for (let next = index + 1; next < particles.length; next += 1) {
|
||||
const other = particles[next];
|
||||
const lineDistance = Math.hypot(particle.x - other.x, particle.y - other.y);
|
||||
if (lineDistance < 112) {
|
||||
ctx.globalAlpha = (1 - lineDistance / 112) * 0.46;
|
||||
ctx.strokeStyle = colors.line;
|
||||
ctx.lineWidth = 1;
|
||||
if (lineDistance < 118) {
|
||||
ctx.globalAlpha = (1 - lineDistance / 118) * (0.36 + particle.pulse * 0.25 + other.pulse * 0.25);
|
||||
ctx.strokeStyle = particle.pulse + other.pulse > 0.2 ? colors.lineActive : colors.line;
|
||||
ctx.lineWidth = 0.9 + Math.max(particle.pulse, other.pulse) * 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(other.x, other.y);
|
||||
@@ -781,19 +927,38 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.58;
|
||||
ctx.fillStyle = particle.hue === 1 ? colors.dotAlt : colors.dot;
|
||||
if (pointer.active && dist < 198) {
|
||||
ctx.globalAlpha = (1 - dist / 198) * (0.2 + pointer.speed * 0.38);
|
||||
ctx.strokeStyle = particle.hue === 2 ? colors.trailFast : colors.lineActive;
|
||||
ctx.lineWidth = 0.8 + pointer.speed * 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pointer.x, pointer.y);
|
||||
ctx.lineTo(particle.x, particle.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.5 + particle.pulse * 0.28;
|
||||
ctx.fillStyle = particle.pulse > 0.35 ? colors.dotHot : particle.hue === 1 ? colors.dotAlt : colors.dot;
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.arc(particle.x, particle.y, particle.size + particle.pulse * 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
drawBursts(colors);
|
||||
drawTokens(colors);
|
||||
|
||||
if (pointer.active) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.globalAlpha = 0.4 + pointer.speed * 0.16;
|
||||
ctx.strokeStyle = colors.cursor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineWidth = 1 + pointer.speed * 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointer.x, pointer.y, 26 + pointer.speed * 18, 0, Math.PI * 2);
|
||||
ctx.arc(pointer.x, pointer.y, 24 + pointer.speed * 24, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 0.26 + pointer.speed * 0.2;
|
||||
ctx.strokeStyle = colors.trail;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pointer.x, pointer.y, 7 + pointer.speed * 7, 0.3, Math.PI * 1.45);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
@@ -802,7 +967,7 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
window.addEventListener("pointerdown", onPointerMove, { passive: true });
|
||||
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
||||
document.addEventListener("pointerleave", () => {
|
||||
pointer.active = false;
|
||||
});
|
||||
@@ -820,11 +985,14 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
}
|
||||
|
||||
const pointer = { active: false, x: 0, y: 0 };
|
||||
const pulses = [];
|
||||
const travelers = Array.from({ length: 9 }, (_, index) => ({
|
||||
edge: index % 4,
|
||||
progress: Math.random(),
|
||||
speed: 0.002 + Math.random() * 0.0025,
|
||||
}));
|
||||
let routeFocus = -1;
|
||||
let routePulse = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
@@ -835,12 +1003,16 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
? {
|
||||
line: "rgba(244, 239, 230, 0.22)",
|
||||
active: "rgba(139, 180, 255, 0.55)",
|
||||
route: "rgba(240, 179, 90, 0.66)",
|
||||
pulse: "rgba(99, 215, 155, 0.86)",
|
||||
glow: "rgba(139, 180, 255, 0.16)",
|
||||
}
|
||||
: {
|
||||
line: "rgba(23, 23, 23, 0.18)",
|
||||
active: "rgba(47, 95, 190, 0.52)",
|
||||
route: "rgba(167, 101, 22, 0.58)",
|
||||
pulse: "rgba(22, 133, 94, 0.78)",
|
||||
glow: "rgba(47, 95, 190, 0.14)",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -859,49 +1031,122 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
return [...map.querySelectorAll(".map-node")].map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
element: node,
|
||||
main: node.classList.contains("map-node-main"),
|
||||
route: node.dataset.mapNode === undefined ? -1 : Number(node.dataset.mapNode),
|
||||
x: rect.left - mapRect.left + rect.width / 2,
|
||||
y: rect.top - mapRect.top + rect.height / 2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pointOnEdge(start, end, progress) {
|
||||
function curveControl(start, end) {
|
||||
const horizontal = end.x < start.x ? -1 : 1;
|
||||
const vertical = end.y < start.y ? -1 : 1;
|
||||
return {
|
||||
x: start.x + (end.x - start.x) * progress,
|
||||
y: start.y + (end.y - start.y) * progress,
|
||||
x: (start.x + end.x) / 2 + horizontal * 18,
|
||||
y: (start.y + end.y) / 2 + vertical * 12,
|
||||
};
|
||||
}
|
||||
|
||||
function pointOnEdge(start, end, progress) {
|
||||
const control = curveControl(start, end);
|
||||
const inverse = 1 - progress;
|
||||
return {
|
||||
x: inverse * inverse * start.x + 2 * inverse * progress * control.x + progress * progress * end.x,
|
||||
y: inverse * inverse * start.y + 2 * inverse * progress * control.y + progress * progress * end.y,
|
||||
};
|
||||
}
|
||||
|
||||
function strokeRoute(start, end, color, widthValue, alpha) {
|
||||
const control = curveControl(start, end);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = widthValue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
ctx.quadraticCurveTo(control.x, control.y, end.x, end.y);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function setRouteFocus(route, node) {
|
||||
routeFocus = route;
|
||||
routePulse = 1;
|
||||
map.querySelectorAll(".map-node.is-active-route").forEach((item) => item.classList.remove("is-active-route"));
|
||||
map.querySelector(".map-node-main")?.classList.add("is-active-route");
|
||||
node?.classList.add("is-active-route");
|
||||
window.setTimeout(() => {
|
||||
if (routeFocus === route) {
|
||||
map.querySelectorAll(".map-node.is-active-route").forEach((item) => item.classList.remove("is-active-route"));
|
||||
}
|
||||
}, 760);
|
||||
}
|
||||
|
||||
function focusNearestRoute() {
|
||||
const nodes = centers();
|
||||
const targets = nodes.filter((node) => !node.main);
|
||||
let nearest = null;
|
||||
let nearestDistance = Infinity;
|
||||
targets.forEach((target) => {
|
||||
const distance = Math.hypot(pointer.x - target.x, pointer.y - target.y);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = target;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
if (nearest && nearestDistance < 180) setRouteFocus(nearest.route, nearest.element);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const colors = palette();
|
||||
const nodes = centers();
|
||||
const main = nodes.find((node) => node.main) || nodes[0];
|
||||
const targets = nodes.filter((node) => node !== main);
|
||||
routePulse *= 0.94;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
targets.forEach((target) => {
|
||||
const distanceToPointer = pointer.active ? Math.hypot(pointer.x - target.x, pointer.y - target.y) : 999;
|
||||
ctx.strokeStyle = distanceToPointer < 135 ? colors.active : colors.line;
|
||||
ctx.lineWidth = distanceToPointer < 135 ? 1.6 : 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(main.x, main.y);
|
||||
ctx.lineTo(target.x, target.y);
|
||||
ctx.stroke();
|
||||
const hoverIntensity = distanceToPointer < 135 ? 1 - distanceToPointer / 135 : 0;
|
||||
const focusIntensity = routeFocus === target.route ? routePulse : 0;
|
||||
const intensity = Math.max(hoverIntensity, focusIntensity);
|
||||
if (intensity > 0.08) {
|
||||
strokeRoute(main, target, colors.glow, 6 + intensity * 5, intensity * 0.72);
|
||||
}
|
||||
strokeRoute(
|
||||
main,
|
||||
target,
|
||||
focusIntensity > hoverIntensity ? colors.route : intensity > 0 ? colors.active : colors.line,
|
||||
1 + intensity * 1.4,
|
||||
0.82 + intensity * 0.18,
|
||||
);
|
||||
});
|
||||
|
||||
travelers.forEach((traveler) => {
|
||||
if (!targets.length) return;
|
||||
const target = targets[traveler.edge % targets.length];
|
||||
traveler.progress += traveler.speed;
|
||||
const focusBoost = routeFocus === target.route ? 1 + routePulse * 3.2 : 1;
|
||||
traveler.progress += traveler.speed * focusBoost;
|
||||
if (traveler.progress > 1) {
|
||||
traveler.progress = 0;
|
||||
traveler.edge = (traveler.edge + 1) % targets.length;
|
||||
}
|
||||
const point = pointOnEdge(main, target, traveler.progress);
|
||||
const isFocused = routeFocus === target.route && routePulse > 0.08;
|
||||
if (isFocused) {
|
||||
ctx.globalAlpha = routePulse * 0.26;
|
||||
ctx.fillStyle = colors.route;
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, 7.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = colors.pulse;
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2);
|
||||
ctx.arc(point.x, point.y, isFocused ? 3 : 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
@@ -909,8 +1154,10 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
targets.forEach((target) => {
|
||||
const distance = Math.hypot(pointer.x - target.x, pointer.y - target.y);
|
||||
if (distance < 170) {
|
||||
ctx.globalAlpha = 1 - distance / 170;
|
||||
const alpha = 1 - distance / 170;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = colors.active;
|
||||
ctx.lineWidth = 1 + alpha;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pointer.x, pointer.y);
|
||||
ctx.lineTo(target.x, target.y);
|
||||
@@ -920,6 +1167,22 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
pulses.forEach((pulse) => {
|
||||
const t = pulse.age / pulse.life;
|
||||
const fade = Math.max(0, 1 - t);
|
||||
ctx.globalAlpha = fade * 0.58;
|
||||
ctx.strokeStyle = colors.route;
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pulse.x, pulse.y, 10 + t * 42, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
pulse.age += 1;
|
||||
});
|
||||
for (let index = pulses.length - 1; index >= 0; index -= 1) {
|
||||
if (pulses[index].age > pulses[index].life) pulses.splice(index, 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
@@ -932,6 +1195,21 @@ const projectLogoFor = (id: string) => brandAssets.projects[id] ?? null;
|
||||
map.addEventListener("pointerleave", () => {
|
||||
pointer.active = false;
|
||||
});
|
||||
map.addEventListener("pointerdown", (event) => {
|
||||
const rect = map.getBoundingClientRect();
|
||||
pointer.x = event.clientX - rect.left;
|
||||
pointer.y = event.clientY - rect.top;
|
||||
pointer.active = true;
|
||||
pulses.push({ x: pointer.x, y: pointer.y, age: 0, life: 34 });
|
||||
if (pulses.length > 6) pulses.shift();
|
||||
focusNearestRoute();
|
||||
});
|
||||
map.querySelectorAll(".map-node").forEach((node) => {
|
||||
node.addEventListener("pointerenter", () => {
|
||||
if (node.dataset.mapNode === undefined) return;
|
||||
setRouteFocus(Number(node.dataset.mapNode), node);
|
||||
});
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
if ("ResizeObserver" in window) new ResizeObserver(resize).observe(map);
|
||||
resize();
|
||||
|
||||
+126
-10
@@ -189,6 +189,7 @@ a {
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
@@ -213,6 +214,7 @@ a {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
@@ -234,6 +236,7 @@ a {
|
||||
.github-link:hover,
|
||||
.control-button:hover,
|
||||
.topbar-cta:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--ink);
|
||||
background: var(--button-hover);
|
||||
}
|
||||
@@ -309,7 +312,7 @@ main {
|
||||
text-decoration: none;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 750;
|
||||
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease;
|
||||
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
@@ -468,6 +471,21 @@ main {
|
||||
font-weight: 800;
|
||||
box-shadow: 0 10px 24px rgba(23, 23, 23, 0.06);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, filter 180ms ease;
|
||||
}
|
||||
|
||||
.map-node.is-active-route {
|
||||
border-color: color-mix(in srgb, var(--blue) 58%, var(--ink));
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--blue) 24%, transparent),
|
||||
0 14px 34px color-mix(in srgb, var(--blue) 18%, transparent);
|
||||
filter: saturate(1.16);
|
||||
}
|
||||
|
||||
.map-node-main.is-active-route {
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--amber) 38%, transparent),
|
||||
0 18px 42px color-mix(in srgb, var(--amber) 20%, transparent);
|
||||
}
|
||||
|
||||
.map-node-main {
|
||||
@@ -661,6 +679,13 @@ main {
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.strength-grid article:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--green) 42%, var(--line-strong));
|
||||
box-shadow: 0 16px 34px rgba(23, 23, 23, 0.07);
|
||||
}
|
||||
|
||||
.strength-grid span {
|
||||
@@ -713,6 +738,14 @@ main {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.skill-list span:hover,
|
||||
.tech-line span:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--blue) 42%, var(--line));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.with-action {
|
||||
@@ -735,10 +768,12 @@ main {
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.filter-tabs button:hover,
|
||||
.filter-tabs button.active {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--ink);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
@@ -758,6 +793,13 @@ main {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 36px rgba(23, 23, 23, 0.07);
|
||||
backdrop-filter: blur(12px);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.project-row:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--blue) 42%, var(--line-strong));
|
||||
box-shadow: 0 20px 42px rgba(23, 23, 23, 0.09);
|
||||
}
|
||||
|
||||
.project-row[hidden] {
|
||||
@@ -893,6 +935,13 @@ main {
|
||||
linear-gradient(135deg, rgba(31, 143, 95, 0.14), transparent 48%),
|
||||
var(--surface-strong);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.domain-strip article:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--amber) 46%, var(--line-strong));
|
||||
box-shadow: 0 16px 34px rgba(23, 23, 23, 0.07);
|
||||
}
|
||||
|
||||
.domain-strip .brand-logo {
|
||||
@@ -908,11 +957,11 @@ main {
|
||||
|
||||
.contact-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1.2rem;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(280px, 0.68fr);
|
||||
gap: clamp(1.2rem, 4vw, 2.2rem);
|
||||
align-items: stretch;
|
||||
margin-top: 3.2rem;
|
||||
padding: clamp(1.1rem, 3.4vw, 1.6rem);
|
||||
padding: clamp(1.2rem, 3.6vw, 2rem);
|
||||
border: 1px solid rgba(23, 23, 23, 0.82);
|
||||
border-radius: var(--radius);
|
||||
background: var(--ink);
|
||||
@@ -920,17 +969,46 @@ main {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.contact-section > div:first-child {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.contact-section h2 {
|
||||
max-width: 440px;
|
||||
font-size: clamp(1.65rem, 3.2vw, 2.55rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.contact-section span {
|
||||
display: block;
|
||||
margin-top: 0.62rem;
|
||||
color: rgba(244, 241, 234, 0.64);
|
||||
color: color-mix(in srgb, currentColor 62%, transparent);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
align-content: center;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
padding: 0.78rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, currentColor 5%, transparent);
|
||||
}
|
||||
|
||||
.contact-section .button {
|
||||
border-color: rgba(244, 241, 234, 0.3);
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.82rem;
|
||||
border-color: color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
color: currentColor;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.contact-section .button-primary {
|
||||
@@ -957,6 +1035,29 @@ main {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.is-pressed {
|
||||
animation: press-feedback 420ms ease;
|
||||
}
|
||||
|
||||
@keyframes press-feedback {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--amber) 36%, transparent);
|
||||
filter: saturate(1);
|
||||
}
|
||||
|
||||
44% {
|
||||
box-shadow:
|
||||
0 0 0 5px color-mix(in srgb, var(--amber) 20%, transparent),
|
||||
0 0 22px color-mix(in srgb, var(--blue) 18%, transparent);
|
||||
filter: saturate(1.18);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--amber) 0%, transparent);
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.topbar {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
@@ -1065,6 +1166,7 @@ main {
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -1106,10 +1208,24 @@ main {
|
||||
|
||||
.reveal,
|
||||
.button,
|
||||
.nav a {
|
||||
.nav a,
|
||||
.topbar-cta,
|
||||
.github-link,
|
||||
.control-button,
|
||||
.filter-tabs button,
|
||||
.project-row,
|
||||
.strength-grid article,
|
||||
.domain-strip article,
|
||||
.skill-list span,
|
||||
.tech-line span,
|
||||
.map-node {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.is-pressed {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
|
||||
Reference in New Issue
Block a user