@@ -5,10 +5,9 @@ interface Props {
55 cellSize? : number ; // cell width & height in SVG units
66 cellGap? : number ; // gap between cells
77 cellRadius? : number ; // border-radius (SVG rx/ry)
8- strokeWidth? : number ; // stroke thickness for inactive cells
9- opacityMin? : number ; // active cell min opacity (animation start)
10- opacityMax? : number ; // active cell max opacity (animation end)
11- breatheDuration? : number ; // animation cycle in seconds
8+ opacityStart? : number ; // resting opacity at load + the field's baseline
9+ opacityMin? : number ; // dimmest the wave can drive a cell
10+ opacityMax? : number ; // brightest the wave can drive a cell
1211}
1312
1413const {
@@ -17,43 +16,45 @@ const {
1716 cellSize = 60 ,
1817 cellGap = 8 ,
1918 cellRadius = 1 ,
20- strokeWidth = 1.4 ,
19+ opacityStart = 0.75 ,
2120 opacityMin = 0.4 ,
22- opacityMax = 0.8 ,
23- breatheDuration = 8 ,
21+ opacityMax = 0.85 ,
2422} = Astro .props ;
2523
2624// Logo-inspired pattern: matches the trace-studio logo-notext shape
27- // 1 = filled (active), 0 = empty (inactive)
25+ // 1 = filled (active), 0 = empty. Empty cells are intentionally NOT
26+ // rendered — only the filled cells that form the logo are drawn.
2827const pattern = [
2928 [1 , 1 , 0 , 0 , 1 , 1 ],
3029 [1 , 0 , 1 , 0 , 0 , 1 ],
3130 [1 , 0 , 0 , 1 , 0 , 1 ],
3231 [1 , 1 , 0 , 0 , 1 , 1 ],
3332];
3433
35- type Cell = {
36- row: number ;
37- col: number ;
38- active: boolean ;
39- delay: number ;
40- };
34+ type Cell = { row: number ; col: number };
4135
4236const cells: Cell [] = [];
4337
4438for (let row = 0 ; row < rows ; row ++ ) {
4539 for (let col = 0 ; col < cols ; col ++ ) {
46- const active = pattern [row ]?.[col ] === 1 ;
47- const delay = ((row * 5 + col * 3 ) % 11 ) * 0.7 ;
48- cells .push ({ row , col , active , delay });
40+ if (pattern [row ]?.[col ] !== 1 ) continue ; // skip empty cells
41+ cells .push ({ row , col });
4942 }
5043}
5144
5245const svgW = cols * (cellSize + cellGap ) - cellGap ;
5346const svgH = rows * (cellSize + cellGap ) - cellGap ;
5447---
5548
56- <div class =" rank-matrix" aria-hidden =" true" >
49+ <div
50+ class =" rank-matrix"
51+ aria-hidden =" true"
52+ data-cols ={ cols }
53+ data-rows ={ rows }
54+ data-op-start ={ opacityStart }
55+ data-op-min ={ opacityMin }
56+ data-op-max ={ opacityMax }
57+ >
5758 <svg
5859 viewBox ={ ` 0 0 ${svgW } ${svgH } ` }
5960 xmlns =" http://www.w3.org/2000/svg"
@@ -66,28 +67,17 @@ const svgH = rows * (cellSize + cellGap) - cellGap;
6667 height = { cellSize }
6768 rx = { cellRadius }
6869 ry = { cellRadius }
69- fill = { cell .active ? " currentColor" : " none" }
70- stroke = { cell .active ? " none" : " currentColor" }
71- stroke-width = { cell .active ? " 0" : String (strokeWidth )}
72- class :list = { [
73- " rank-matrix__cell" ,
74- { " rank-matrix__cell--active" : cell .active },
75- ]}
76- style = { cell .active
77- ? ` opacity: ${opacityMin }; animation-delay: ${cell .delay }s `
78- : undefined }
70+ fill = " currentColor"
71+ class = " rank-matrix__cell--active"
72+ data-col = { cell .col }
73+ data-row = { cell .row }
74+ style = { ` opacity: ${opacityStart } ` }
7975 />
8076 ))}
8177 </svg >
8278</div >
8379
84- <style
85- define:vars ={ {
86- opacityMin: String (opacityMin ),
87- opacityMax: String (opacityMax ),
88- breatheDuration: ` ${breatheDuration }s ` ,
89- }}
90- >
80+ <style >
9181 .rank-matrix {
9282 width: 100%;
9383 max-width: 360px;
@@ -98,27 +88,127 @@ const svgH = rows * (cellSize + cellGap) - cellGap;
9888 width: 100%;
9989 height: auto;
10090 }
91+ </style >
10192
102- .rank-matrix__cell {
103- opacity: 0.05;
104- }
93+ <script >
94+ // ── Coordinated "water surface" shimmer for the logo matrix ────────────
95+ // Per-cell independent breathing looks ragged. Instead, a couple of virtual
96+ // points drift across the logo and each cell's opacity is a smooth function
97+ // of its distance to those points — so brightness ripples across the matrix
98+ // like light moving on water.
99+ //
100+ // Performance: only ~14 cells × 2 points per frame, and the rAF loop runs
101+ // ONLY while the logo is on-screen (IntersectionObserver) and motion is
102+ // allowed (prefers-reduced-motion disables it entirely → static at rest).
103+ const root = document.querySelector<HTMLElement>(".rank-matrix");
104+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
105105
106- .rank-matrix__cell--active {
107- opacity: var(--opacityMin);
108- }
106+ if (root && !reduceMotion) {
107+ const cellEls = Array.from(
108+ root.querySelectorAll<SVGRectElement>(".rank-matrix__cell--active"),
109+ );
109110
110- @media (prefers-reduced-motion: no-preference) {
111- .rank-matrix__cell--active {
112- animation: cell-breathe var(--breatheDuration) ease-in-out infinite alternate;
113- }
114- }
111+ if (cellEls.length) {
112+ const cols = Number(root.dataset.cols) || 6;
113+ const rows = Number(root.dataset.rows) || 4;
114+ const opStart = Number(root.dataset.opStart) || 0.75;
115+ const opMin = Number(root.dataset.opMin) || 0.4;
116+ const opMax = Number(root.dataset.opMax) || 0.85;
115117
116- @keyframes cell-breathe {
117- 0% {
118- opacity: var(--opacityMin);
119- }
120- 100% {
121- opacity: var(--opacityMax);
118+ // ── Tuning knobs ──────────────────────────────────────────────────
119+ const RADIUS = 2.2; // falloff radius in grid units (how wide each blob is)
120+ const AMP = 0.32; // brightness push at a point's centre
121+ const SPEED = 1.0; // drift speed, grid units / second (lower = calmer)
122+ const INTRO_MS = 800; // fade the effect in from the resting state
123+ const R2 = RADIUS * RADIUS;
124+
125+ const cells = cellEls.map((el) => ({
126+ el,
127+ col: Number(el.dataset.col),
128+ row: Number(el.dataset.row),
129+ }));
130+
131+ type Pt = { x: number; y: number; tx: number; ty: number; sign: number };
132+ const rnd = (a: number, b: number) => a + Math.random() * (b - a);
133+ const retarget = (p: Pt) => {
134+ // Aim slightly past the edges so border cells also reach the extremes.
135+ p.tx = rnd(-0.5, cols - 0.5);
136+ p.ty = rnd(-0.5, rows - 0.5);
137+ };
138+ // One brightening point, one dimming point → crests and troughs glide.
139+ const points: Pt[] = [
140+ { x: rnd(0, cols), y: rnd(0, rows), tx: 0, ty: 0, sign: 1 },
141+ { x: rnd(0, cols), y: rnd(0, rows), tx: 0, ty: 0, sign: -1 },
142+ ];
143+ points.forEach(retarget);
144+
145+ let raf = 0;
146+ let prev = 0;
147+ let t0 = 0;
148+
149+ function tick(now: number) {
150+ if (!t0) t0 = now;
151+ const dt = prev ? Math.min((now - prev) / 1000, 0.05) : 0.016;
152+ prev = now;
153+
154+ // Ease the whole effect in from the flat resting state on (re)start.
155+ const intro = Math.min(1, (now - t0) / INTRO_MS);
156+ const amp = AMP * (intro * intro * (3 - 2 * intro)); // smoothstep
157+
158+ // Drift each point toward its target; pick a new one on arrival.
159+ for (const p of points) {
160+ const dx = p.tx - p.x;
161+ const dy = p.ty - p.y;
162+ const dist = Math.hypot(dx, dy);
163+ if (dist < 0.15) {
164+ retarget(p);
165+ } else {
166+ const step = Math.min(SPEED * dt, dist);
167+ p.x += (dx / dist) * step;
168+ p.y += (dy / dist) * step;
169+ }
170+ }
171+
172+ // Sample the distance field at each cell centre.
173+ for (const c of cells) {
174+ let v = opStart;
175+ for (const p of points) {
176+ const dx = c.col - p.x;
177+ const dy = c.row - p.y;
178+ v += p.sign * amp * Math.exp(-(dx * dx + dy * dy) / R2);
179+ }
180+ v = v < opMin ? opMin : v > opMax ? opMax : v;
181+ c.el.style.opacity = v.toFixed(3);
182+ }
183+
184+ raf = requestAnimationFrame(tick);
185+ }
186+
187+ function start() {
188+ if (!raf) {
189+ prev = 0;
190+ t0 = 0;
191+ raf = requestAnimationFrame(tick);
192+ }
193+ }
194+ function stop() {
195+ if (raf) {
196+ cancelAnimationFrame(raf);
197+ raf = 0;
198+ }
199+ }
200+
201+ // Animate only while the logo is actually in view.
202+ const io = new IntersectionObserver(
203+ (entries) => {
204+ for (const e of entries) {
205+ if (e.isIntersecting) start();
206+ else stop();
207+ }
208+ },
209+ { threshold: 0 },
210+ );
211+ io.observe(root);
122212 }
123213 }
124- </style >
214+ </script >
0 commit comments