Skip to content

Commit 7007f67

Browse files
authored
Add animated linecharts to the background (#275)
1 parent ac5ef23 commit 7007f67

3 files changed

Lines changed: 233 additions & 0 deletions

File tree

doc/bg-lines.js

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// bg-lines.js — Animated background line chart drawings
2+
(function () {
3+
'use strict';
4+
5+
const GRID = 40;
6+
const MAX_OPACITY = 0.25;
7+
const POINT_R = 2.5;
8+
const LINE_W = 1.25;
9+
const MAX_SERIES = 4;
10+
const DRAW_DURATION = 12000; // ms to draw full line
11+
const HOLD_MS = 1500;
12+
const FADE_MS = 2500;
13+
const SPAWN_MIN = 500;
14+
const SPAWN_MAX = 2000;
15+
16+
const COLORS = [
17+
[0, 95, 115], // darkteal
18+
[10, 147, 150], // teal
19+
[148, 210, 189], // lightteal
20+
];
21+
22+
const canvas = document.getElementById('bg-lines');
23+
if (!canvas) return;
24+
const ctx = canvas.getContext('2d');
25+
26+
// --- Helpers ---
27+
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
28+
function randInt(lo, hi) { return Math.floor(rand(lo, hi + 1)); }
29+
function pick(arr) { return arr[randInt(0, arr.length - 1)]; }
30+
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
31+
32+
// --- Resize ---
33+
let W, H;
34+
function resize() {
35+
const dpr = window.devicePixelRatio || 1;
36+
W = window.innerWidth;
37+
H = window.innerHeight;
38+
canvas.width = W * dpr;
39+
canvas.height = H * dpr;
40+
canvas.style.width = W + 'px';
41+
canvas.style.height = H + 'px';
42+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
43+
}
44+
45+
let resizeTimer;
46+
window.addEventListener('resize', function () {
47+
clearTimeout(resizeTimer);
48+
resizeTimer = setTimeout(resize, 100);
49+
});
50+
resize();
51+
52+
// --- Line generation ---
53+
const STEP_X = 40; // fixed sampling interval along x
54+
55+
function generatePoints() {
56+
const n = Math.ceil(W / STEP_X) + 1;
57+
const startY = rand(H * 0.15, H * 0.85);
58+
const volatility = rand(25, 60);
59+
60+
const pts = [];
61+
let y = startY;
62+
for (let i = 0; i < n; i++) {
63+
pts.push({ x: i * STEP_X, y: y });
64+
y += (Math.random() - 0.5) * 2 * volatility;
65+
y = Math.max(GRID * 2, Math.min(H - GRID * 2, y));
66+
}
67+
return pts;
68+
}
69+
70+
// --- Series ---
71+
function createSeries() {
72+
return {
73+
pts: generatePoints(),
74+
color: pick(COLORS),
75+
state: 'drawing', // drawing | holding | fading | dead
76+
progress: 0, // fractional segment index
77+
opacity: MAX_OPACITY,
78+
stateStart: 0,
79+
};
80+
}
81+
82+
function updateSeries(s, now, dt) {
83+
switch (s.state) {
84+
case 'drawing':
85+
s.progress += (s.pts.length - 1) * (dt / DRAW_DURATION);
86+
if (s.progress >= s.pts.length - 1) {
87+
s.progress = s.pts.length - 1;
88+
s.state = 'holding';
89+
s.stateStart = now;
90+
}
91+
break;
92+
case 'holding':
93+
if (now - s.stateStart > HOLD_MS) {
94+
s.state = 'fading';
95+
s.stateStart = now;
96+
}
97+
break;
98+
case 'fading': {
99+
const t = (now - s.stateStart) / FADE_MS;
100+
s.opacity = MAX_OPACITY * (1 - t);
101+
if (s.opacity <= 0) {
102+
s.opacity = 0;
103+
s.state = 'dead';
104+
}
105+
break;
106+
}
107+
}
108+
}
109+
110+
function drawSeries(s) {
111+
if (s.opacity <= 0) return;
112+
const col = rgba(s.color, s.opacity);
113+
const full = Math.floor(s.progress);
114+
115+
// Line
116+
ctx.strokeStyle = col;
117+
ctx.lineWidth = LINE_W;
118+
ctx.lineJoin = 'round';
119+
ctx.lineCap = 'round';
120+
ctx.beginPath();
121+
ctx.moveTo(s.pts[0].x, s.pts[0].y);
122+
for (let i = 1; i <= full; i++) {
123+
ctx.lineTo(s.pts[i].x, s.pts[i].y);
124+
}
125+
// Partial segment
126+
const frac = s.progress - full;
127+
if (frac > 0 && full + 1 < s.pts.length) {
128+
const a = s.pts[full];
129+
const b = s.pts[full + 1];
130+
ctx.lineTo(a.x + (b.x - a.x) * frac, a.y + (b.y - a.y) * frac);
131+
}
132+
ctx.stroke();
133+
134+
// Points
135+
ctx.fillStyle = col;
136+
for (let i = 0; i <= full; i++) {
137+
ctx.beginPath();
138+
ctx.arc(s.pts[i].x, s.pts[i].y, POINT_R, 0, Math.PI * 2);
139+
ctx.fill();
140+
}
141+
}
142+
143+
// --- Main loop ---
144+
let series = [];
145+
let nextSpawn = 0;
146+
let prev = 0;
147+
let paused = false;
148+
149+
function tick(now) {
150+
if (paused) { requestAnimationFrame(tick); return; }
151+
const dt = prev ? now - prev : 16;
152+
prev = now;
153+
154+
ctx.clearRect(0, 0, W, H);
155+
156+
// Spawn
157+
if (series.length < MAX_SERIES && now > nextSpawn) {
158+
series.push(createSeries());
159+
nextSpawn = now + rand(SPAWN_MIN, SPAWN_MAX);
160+
}
161+
162+
// Update & draw
163+
for (const s of series) {
164+
updateSeries(s, now, dt);
165+
drawSeries(s);
166+
}
167+
168+
// Prune
169+
series = series.filter(function (s) { return s.state !== 'dead'; });
170+
171+
requestAnimationFrame(tick);
172+
}
173+
174+
// --- Visibility ---
175+
document.addEventListener('visibilitychange', function () {
176+
paused = document.hidden;
177+
if (!paused) prev = 0; // reset dt to avoid jump
178+
});
179+
180+
// --- Start ---
181+
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
182+
requestAnimationFrame(tick);
183+
}
184+
})();

doc/index.qmd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,8 @@ Or try our [online playground](wasm/) to experience the syntax _right now_.
271271

272272
:::
273273

274+
```{=html}
275+
<canvas id="bg-lines" aria-hidden="true"></canvas>
276+
<script src="bg-lines.js"></script>
277+
```
278+

doc/styles.scss

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,20 @@ body:has(.hero-banner) {
4141
background-attachment: fixed;
4242
}
4343

44+
// Animated background line charts canvas
45+
#bg-lines {
46+
position: fixed;
47+
top: 0;
48+
left: 0;
49+
width: 100vw;
50+
height: 100vh;
51+
pointer-events: none;
52+
z-index: 0;
53+
}
54+
4455
.hero-banner {
56+
position: relative;
57+
z-index: 1;
4558
width: 100%;
4659
padding: 4rem 2rem;
4760
background: transparent;
@@ -102,6 +115,21 @@ body:has(.hero-banner) {
102115
text-align: left;
103116
}
104117

118+
// Blurred backdrop behind hero text to stay readable over animated lines
119+
h1, .hero-body > p {
120+
position: relative;
121+
122+
&::before {
123+
content: '';
124+
position: absolute;
125+
inset: -30px -40px;
126+
background: var(--brand-paleteal, #DEF1EB);
127+
filter: blur(20px);
128+
z-index: -1;
129+
border-radius: 50%;
130+
}
131+
}
132+
105133
.hero-body {
106134
display: flex;
107135
flex-direction: column;
@@ -162,6 +190,8 @@ body:has(.hero-banner) {
162190

163191

164192
.content-block {
193+
position: relative;
194+
z-index: 1;
165195
max-width: 1200px;
166196
margin: 2rem auto;
167197
padding: 4rem 2rem 2rem;
@@ -515,6 +545,20 @@ body:has(.hero-banner) {
515545
text-align: center;
516546
padding: 2rem 0;
517547

548+
h2, > p {
549+
position: relative;
550+
551+
&::before {
552+
content: '';
553+
position: absolute;
554+
inset: -30px -40px;
555+
background: var(--brand-paleteal, #DEF1EB);
556+
filter: blur(20px);
557+
z-index: -1;
558+
border-radius: 50%;
559+
}
560+
}
561+
518562
h2 {
519563
margin-bottom: 1rem;
520564
}

0 commit comments

Comments
 (0)