Skip to content

Commit 7ee569d

Browse files
committed
feat(docs): enrich homepage canvas effects
1 parent fd22949 commit 7ee569d

5 files changed

Lines changed: 960 additions & 29 deletions

File tree

website/theme/components/CanvasGridEffect.tsx

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ type CanvasGridEffectProps = {
88

99
type GridWave = {
1010
bornAt: number;
11+
strength: number;
1112
x: number;
1213
y: number;
1314
};
1415

1516
const GRID_TINT = [125, 182, 255] as const;
16-
const WAVE_LIFETIME = 1_250;
17+
const WAVE_LIFETIME = 1_650;
18+
const AMBIENT_WAVE_INTERVAL = 3_800;
1719

1820
function clamp(value: number, minimum: number, maximum: number) {
1921
return Math.min(Math.max(value, minimum), maximum);
@@ -51,6 +53,7 @@ export function CanvasGridEffect({
5153
let previousPointer = { x: -cellSize, y: -cellSize };
5254
let pointer = { x: -cellSize * 4, y: -cellSize * 4 };
5355
let waves: GridWave[] = [];
56+
let ambientWaveIndex = 0;
5457
let width = 1;
5558
let height = 1;
5659
let pixelRatio = 1;
@@ -98,47 +101,75 @@ export function CanvasGridEffect({
98101

99102
for (const wave of activeWaves) {
100103
const progress = clamp((now - wave.bornAt) / WAVE_LIFETIME, 0, 1);
101-
const radius = progress * maxDimension * 0.72;
104+
const radius = progress * maxDimension * 0.78;
102105
const distance = Math.hypot(centerX - wave.x, centerY - wave.y);
103106
const ring = Math.exp(
104-
-Math.pow((distance - radius) / (cellSize * 1.35), 2),
107+
-Math.pow((distance - radius) / (cellSize * 1.5), 2),
105108
);
106-
waveLift = Math.max(waveLift, ring * (1 - progress));
109+
waveLift = Math.max(
110+
waveLift,
111+
ring * (1 - progress) * wave.strength,
112+
);
113+
}
114+
115+
const lift = clamp(pointerLift * 0.66 + waveLift, 0, 1);
116+
const inset = 4 - lift * 2.35;
117+
const offsetY = -lift * 7;
118+
const alpha = intensity * (0.035 + lift * 0.22);
119+
const fillAlpha = intensity * (0.004 + lift * 0.045);
120+
const tileX = column * cellSize + inset;
121+
const tileY = row * cellSize + inset + offsetY;
122+
const tileWidth = cellSize - inset * 2;
123+
const depth = lift * 5;
124+
125+
if (depth > 0.25) {
126+
drawingContext.fillStyle = `rgba(${GRID_TINT.join(', ')}, ${intensity * lift * 0.055})`;
127+
drawingContext.beginPath();
128+
drawingContext.moveTo(tileX, tileY + tileWidth);
129+
drawingContext.lineTo(tileX + tileWidth, tileY + tileWidth);
130+
drawingContext.lineTo(tileX + tileWidth, tileY + tileWidth + depth);
131+
drawingContext.lineTo(tileX, tileY + tileWidth + depth);
132+
drawingContext.closePath();
133+
drawingContext.fill();
134+
135+
drawingContext.fillStyle = `rgba(${GRID_TINT.join(', ')}, ${intensity * lift * 0.035})`;
136+
drawingContext.beginPath();
137+
drawingContext.moveTo(tileX + tileWidth, tileY);
138+
drawingContext.lineTo(tileX + tileWidth, tileY + tileWidth);
139+
drawingContext.lineTo(tileX + tileWidth, tileY + tileWidth + depth);
140+
drawingContext.lineTo(tileX + tileWidth + depth, tileY + depth);
141+
drawingContext.closePath();
142+
drawingContext.fill();
107143
}
108144

109-
const lift = clamp(pointerLift * 0.58 + waveLift, 0, 1);
110-
const inset = 4 - lift * 2.2;
111-
const offsetY = -lift * 4;
112-
const alpha = intensity * (0.018 + lift * 0.16);
113-
const fillAlpha = intensity * lift * 0.025;
145+
drawingContext.fillStyle = `rgba(${GRID_TINT.join(', ')}, ${fillAlpha})`;
146+
drawingContext.fillRect(tileX, tileY, tileWidth, tileWidth);
114147

115148
drawingContext.strokeStyle = `rgba(${GRID_TINT.join(', ')}, ${alpha})`;
116149
drawingContext.lineWidth = 1;
117-
drawingContext.strokeRect(
118-
column * cellSize + inset,
119-
row * cellSize + inset + offsetY,
120-
cellSize - inset * 2,
121-
cellSize - inset * 2,
122-
);
123-
124-
if (fillAlpha > 0.002) {
125-
drawingContext.fillStyle = `rgba(${GRID_TINT.join(', ')}, ${fillAlpha})`;
126-
drawingContext.fillRect(
127-
column * cellSize + inset,
128-
row * cellSize + inset + offsetY,
129-
cellSize - inset * 2,
130-
cellSize - inset * 2,
131-
);
132-
}
150+
drawingContext.strokeRect(tileX, tileY, tileWidth, tileWidth);
133151
}
134152
}
135153

136154
if (activeWaves.length > 0) scheduleDraw();
137155
}
138156

139-
const addWave = (x: number, y: number, now: number) => {
140-
waves = [...waves.slice(-3), { bornAt: now, x, y }];
157+
const addWave = (x: number, y: number, now: number, strength = 1) => {
158+
waves = [...waves.slice(-4), { bornAt: now, strength, x, y }];
141159
lastWaveAt = now;
160+
scheduleDraw();
161+
};
162+
163+
const addAmbientWave = () => {
164+
if (reducedMotion || !isVisible || isPointerInside) return;
165+
const positions = [
166+
[0.72, 0.3],
167+
[0.28, 0.62],
168+
[0.58, 0.78],
169+
] as const;
170+
const [xRatio, yRatio] = positions[ambientWaveIndex % positions.length];
171+
ambientWaveIndex += 1;
172+
addWave(width * xRatio, height * yRatio, performance.now(), 0.58);
142173
};
143174

144175
const handlePointerMove = (event: PointerEvent) => {
@@ -152,7 +183,7 @@ export function CanvasGridEffect({
152183
pointer = { x, y };
153184
isPointerInside = true;
154185
if (distance > cellSize * 1.25 && now - lastWaveAt > 90) {
155-
addWave(x, y, now);
186+
addWave(x, y, now, 1);
156187
previousPointer = { x, y };
157188
}
158189
scheduleDraw();
@@ -181,9 +212,17 @@ export function CanvasGridEffect({
181212
host.addEventListener('pointermove', handlePointerMove);
182213
host.addEventListener('pointerleave', handlePointerLeave);
183214
resize();
215+
if (!reducedMotion) {
216+
addWave(width * 0.72, height * 0.32, performance.now(), 0.62);
217+
}
218+
const ambientWaveTimer = window.setInterval(
219+
addAmbientWave,
220+
AMBIENT_WAVE_INTERVAL,
221+
);
184222

185223
return () => {
186224
window.cancelAnimationFrame(frame);
225+
window.clearInterval(ambientWaveTimer);
187226
resizeObserver.disconnect();
188227
intersectionObserver.disconnect();
189228
motionPreference.removeEventListener('change', handleMotionChange);

website/theme/components/HomeLayout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from 'codehike/utils/selection';
1414
import { CanvasGridEffect } from './CanvasGridEffect';
1515
import { InstallSwitcher } from './InstallSwitcher';
16+
import { PremiumInteractions } from './PremiumInteractions';
1617
import runtimeTutorialData from '../generated/runtime-tutorial.json';
1718

1819
type Locale = 'zh' | 'en';
@@ -1395,6 +1396,7 @@ export function HomeLayout() {
13951396

13961397
return (
13971398
<main className="a3s-home">
1399+
<PremiumInteractions />
13981400
<section className="a3s-hero">
13991401
<div className="a3s-hero-copy">
14001402
<div className="a3s-eyebrow">
@@ -1429,7 +1431,7 @@ export function HomeLayout() {
14291431
<CanvasGridEffect
14301432
cellSize={42}
14311433
className="a3s-hero-canvas"
1432-
intensity={0.72}
1434+
intensity={0.9}
14331435
/>
14341436
<RuntimeExecutionFlow labels={labels} />
14351437
</div>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
const SURFACE_SELECTOR = [
4+
'.a3s-install-console',
5+
'.a3s-feature-card',
6+
'.a3s-bento-card',
7+
'.a3s-surface-card',
8+
].join(',');
9+
10+
type PointerPosition = {
11+
clientX: number;
12+
clientY: number;
13+
target: EventTarget | null;
14+
};
15+
16+
/**
17+
* Coordinates the lightweight Glass / Hex Float-inspired surface lighting.
18+
* The visual treatment stays in CSS; this component only updates local
19+
* pointer coordinates and never changes layout or intercepts interaction.
20+
*/
21+
export function PremiumInteractions() {
22+
const anchorRef = useRef<HTMLSpanElement>(null);
23+
24+
useEffect(() => {
25+
const host = anchorRef.current?.closest<HTMLElement>('.a3s-home');
26+
if (!host) return undefined;
27+
28+
const hero = host.querySelector<HTMLElement>('.a3s-hero');
29+
const motionPreference = window.matchMedia(
30+
'(prefers-reduced-motion: reduce)',
31+
);
32+
let activeSurface: HTMLElement | null = null;
33+
let animationFrame = 0;
34+
let latestPointer: PointerPosition | null = null;
35+
36+
const clearActiveSurface = () => {
37+
activeSurface?.classList.remove('is-pointer-active');
38+
activeSurface = null;
39+
};
40+
41+
const paintPointer = () => {
42+
animationFrame = 0;
43+
const pointer = latestPointer;
44+
if (!pointer || motionPreference.matches) {
45+
clearActiveSurface();
46+
return;
47+
}
48+
49+
const target =
50+
pointer.target instanceof Element ? pointer.target : undefined;
51+
const surface = target?.closest<HTMLElement>(SURFACE_SELECTOR) ?? null;
52+
53+
if (surface && host.contains(surface)) {
54+
if (surface !== activeSurface) {
55+
clearActiveSurface();
56+
activeSurface = surface;
57+
surface.classList.add('is-pointer-active');
58+
}
59+
60+
const bounds = surface.getBoundingClientRect();
61+
surface.style.setProperty(
62+
'--a3s-spot-x',
63+
`${pointer.clientX - bounds.left}px`,
64+
);
65+
surface.style.setProperty(
66+
'--a3s-spot-y',
67+
`${pointer.clientY - bounds.top}px`,
68+
);
69+
} else {
70+
clearActiveSurface();
71+
}
72+
73+
if (hero) {
74+
const bounds = hero.getBoundingClientRect();
75+
const x = ((pointer.clientX - bounds.left) / bounds.width) * 100;
76+
const y = ((pointer.clientY - bounds.top) / bounds.height) * 100;
77+
hero.style.setProperty(
78+
'--a3s-hero-x',
79+
`${Math.max(0, Math.min(x, 100))}%`,
80+
);
81+
hero.style.setProperty(
82+
'--a3s-hero-y',
83+
`${Math.max(0, Math.min(y, 100))}%`,
84+
);
85+
}
86+
};
87+
88+
const handlePointerMove = (event: PointerEvent) => {
89+
if (event.pointerType === 'touch') return;
90+
latestPointer = {
91+
clientX: event.clientX,
92+
clientY: event.clientY,
93+
target: event.target,
94+
};
95+
if (!animationFrame) {
96+
animationFrame = window.requestAnimationFrame(paintPointer);
97+
}
98+
};
99+
100+
const handlePointerLeave = () => {
101+
latestPointer = null;
102+
clearActiveSurface();
103+
hero?.style.removeProperty('--a3s-hero-x');
104+
hero?.style.removeProperty('--a3s-hero-y');
105+
};
106+
107+
const handleMotionChange = () => {
108+
if (motionPreference.matches) handlePointerLeave();
109+
};
110+
111+
host.dataset.premiumEffects = 'ready';
112+
host.addEventListener('pointermove', handlePointerMove);
113+
host.addEventListener('pointerleave', handlePointerLeave);
114+
motionPreference.addEventListener('change', handleMotionChange);
115+
116+
return () => {
117+
window.cancelAnimationFrame(animationFrame);
118+
clearActiveSurface();
119+
delete host.dataset.premiumEffects;
120+
host.removeEventListener('pointermove', handlePointerMove);
121+
host.removeEventListener('pointerleave', handlePointerLeave);
122+
motionPreference.removeEventListener('change', handleMotionChange);
123+
};
124+
}, []);
125+
126+
return (
127+
<span
128+
aria-hidden="true"
129+
className="a3s-premium-effects-anchor"
130+
ref={anchorRef}
131+
/>
132+
);
133+
}

website/theme/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import './index.css';
22
import './codehike-docs.css';
3+
import './premium-effects.css';
34

45
export { HomeLayout } from './components/HomeLayout';
56
export { Nav } from './components/Nav';

0 commit comments

Comments
 (0)