Skip to content

Commit 5538197

Browse files
committed
Implement BlogCardPicture
1 parent 1381d13 commit 5538197

File tree

5 files changed

+388
-13
lines changed

5 files changed

+388
-13
lines changed
Lines changed: 348 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,355 @@
11
import { clsx } from "clsx"
2+
import { type ReactNode, useEffect, useRef } from "react"
23

4+
const PIXEL_SIZE = 16
5+
const MAX_DPR = 2
6+
const UINT32_MAX = 0xffffffff
7+
8+
interface BlogCardPictureProps {
9+
seed: string
10+
children?: ReactNode
11+
className?: string
12+
}
13+
14+
type RgbColor = [number, number, number]
15+
16+
interface GradientStop {
17+
offset: number
18+
color: RgbColor
19+
}
20+
21+
interface PreparedGradient {
22+
cos: number
23+
sin: number
24+
minProjection: number
25+
invProjectionRange: number
26+
stops: GradientStop[]
27+
}
28+
29+
// TODO: Animate nicer on load
30+
// TODO: Update seeding: The closer the post date the more different the gradient should be?
31+
// TODO: Think: Should the category colors actually be connected to the gradient, so the tag doesn't ever look jarring?
332
export function BlogCardPicture({
33+
seed,
434
children,
535
className,
36+
}: BlogCardPictureProps) {
37+
const containerRef = useRef<HTMLDivElement>(null)
38+
const canvasRef = useRef<HTMLCanvasElement>(null)
39+
40+
useEffect(() => {
41+
const canvas = canvasRef.current
42+
const container = containerRef.current
43+
if (!canvas || !container) {
44+
return
45+
}
46+
47+
let frame = 0
48+
49+
const draw = () => {
50+
const rect = container.getBoundingClientRect()
51+
const width = Math.max(0, Math.round(rect.width))
52+
const height = Math.max(0, Math.round(rect.height))
53+
54+
if (width === 0 || height === 0) {
55+
return
56+
}
57+
58+
const columns = Math.max(1, Math.ceil(width / PIXEL_SIZE))
59+
const rows = Math.max(1, Math.ceil(height / PIXEL_SIZE))
60+
const dpr = Math.min(
61+
typeof window === "undefined" ? 1 : (window.devicePixelRatio ?? 1),
62+
MAX_DPR,
63+
)
64+
const canvasWidth = Math.round(width * dpr)
65+
const canvasHeight = Math.round(height * dpr)
66+
67+
if (canvas.width !== canvasWidth) {
68+
canvas.width = canvasWidth
69+
}
70+
if (canvas.height !== canvasHeight) {
71+
canvas.height = canvasHeight
72+
}
73+
74+
const displayWidth = `${width}px`
75+
const displayHeight = `${height}px`
76+
if (canvas.style.width !== displayWidth) {
77+
canvas.style.width = displayWidth
78+
}
79+
if (canvas.style.height !== displayHeight) {
80+
canvas.style.height = displayHeight
81+
}
82+
83+
const context = canvas.getContext("2d")
84+
if (!context) {
85+
return
86+
}
87+
88+
context.setTransform(1, 0, 0, 1, 0, 0)
89+
context.scale(dpr, dpr)
90+
context.clearRect(0, 0, width, height)
91+
context.imageSmoothingEnabled = false
92+
93+
const random = createSeededRandom(seed)
94+
const angle = random() * Math.PI * 2
95+
const gradientStops = buildGradientStops(random)
96+
const gradient = prepareGradient({
97+
angle,
98+
stops: gradientStops,
99+
columns,
100+
rows,
101+
})
102+
const jitterPrefix = `${seed}|`
103+
104+
for (let row = 0; row < rows; row += 1) {
105+
for (let column = 0; column < columns; column += 1) {
106+
const color = sampleGradientColor({
107+
gradient,
108+
column,
109+
row,
110+
jitterPrefix,
111+
})
112+
113+
const x = column * PIXEL_SIZE
114+
const y = row * PIXEL_SIZE
115+
const cellWidth = Math.min(PIXEL_SIZE, width - x)
116+
const cellHeight = Math.min(PIXEL_SIZE, height - y)
117+
118+
context.fillStyle = toCssColor(color)
119+
context.fillRect(x, y, cellWidth, cellHeight)
120+
}
121+
}
122+
}
123+
124+
const drawWithAnimationFrame = () => {
125+
cancelAnimationFrame(frame)
126+
frame = window.requestAnimationFrame(draw)
127+
}
128+
129+
drawWithAnimationFrame()
130+
131+
const handleResize = () => {
132+
drawWithAnimationFrame()
133+
}
134+
135+
window.addEventListener("resize", handleResize)
136+
137+
return () => {
138+
cancelAnimationFrame(frame)
139+
window.removeEventListener("resize", handleResize)
140+
}
141+
}, [seed])
142+
143+
return (
144+
<div
145+
ref={containerRef}
146+
className={clsx(
147+
"relative isolate overflow-hidden bg-neu-50 dark:opacity-90",
148+
className,
149+
)}
150+
>
151+
<canvas
152+
ref={canvasRef}
153+
aria-hidden="true"
154+
className="pointer-events-none absolute inset-0 size-full"
155+
/>
156+
{children ? <div className="relative z-10 p-4">{children}</div> : null}
157+
</div>
158+
)
159+
}
160+
161+
function createSeededRandom(seed: string) {
162+
let state = hashString(seed) || 1
163+
return () => {
164+
state ^= state << 13
165+
state ^= state >>> 17
166+
state ^= state << 5
167+
state >>>= 0
168+
return state / UINT32_MAX
169+
}
170+
}
171+
172+
function prepareGradient({
173+
angle,
174+
stops,
175+
columns,
176+
rows,
6177
}: {
7-
children?: React.ReactNode
8-
className?: string
9-
}) {
10-
return <div className={clsx("bg-neu-50 p-4", className)}>{children}</div>
178+
angle: number
179+
stops: GradientStop[]
180+
columns: number
181+
rows: number
182+
}): PreparedGradient {
183+
const cos = Math.cos(angle)
184+
const sin = Math.sin(angle)
185+
186+
const corners: Array<[number, number]> = [
187+
[0, 0],
188+
[columns, 0],
189+
[0, rows],
190+
[columns, rows],
191+
]
192+
193+
let minProjection = Infinity
194+
let maxProjection = -Infinity
195+
196+
for (const [x, y] of corners) {
197+
const projection = x * cos + y * sin
198+
if (projection < minProjection) {
199+
minProjection = projection
200+
}
201+
if (projection > maxProjection) {
202+
maxProjection = projection
203+
}
204+
}
205+
206+
const range = Math.max(maxProjection - minProjection, 1e-6)
207+
208+
return {
209+
cos,
210+
sin,
211+
minProjection,
212+
invProjectionRange: 1 / range,
213+
stops,
214+
}
215+
}
216+
217+
function buildGradientStops(random: () => number): GradientStop[] {
218+
const baseHue = random() * 360
219+
const stopOffsets = [0, clamp(0.25, 0.75, 0.44 + (random() - 0.5) * 0.22), 1]
220+
221+
const hues = [
222+
normalizeHue(baseHue - 14 + (random() - 0.5) * 10),
223+
normalizeHue(baseHue + 10 + (random() - 0.5) * 14),
224+
normalizeHue(baseHue + 28 + (random() - 0.5) * 16),
225+
]
226+
227+
const saturations = [
228+
clamp(0.45, 0.65, 0.54 + (random() - 0.5) * 0.08),
229+
clamp(0.48, 0.7, 0.58 + (random() - 0.5) * 0.1),
230+
clamp(0.42, 0.62, 0.5 + (random() - 0.5) * 0.08),
231+
]
232+
233+
const lightness = [
234+
clamp(0.58, 0.75, 0.68 + (random() - 0.5) * 0.07),
235+
clamp(0.52, 0.7, 0.6 + (random() - 0.5) * 0.07),
236+
clamp(0.6, 0.78, 0.7 + (random() - 0.5) * 0.07),
237+
]
238+
239+
return stopOffsets.map((offset, index) => ({
240+
offset,
241+
color: hslToRgb(hues[index], saturations[index], lightness[index]),
242+
}))
243+
}
244+
245+
function sampleGradientColor({
246+
gradient,
247+
column,
248+
row,
249+
jitterPrefix,
250+
}: {
251+
gradient: PreparedGradient
252+
column: number
253+
row: number
254+
jitterPrefix: string
255+
}): RgbColor {
256+
const x = column + 0.5
257+
const y = row + 0.5
258+
259+
const projection = x * gradient.cos + y * gradient.sin
260+
const baseT =
261+
(projection - gradient.minProjection) * gradient.invProjectionRange
262+
const noise = hashString(`${jitterPrefix}${column}:${row}`) / UINT32_MAX
263+
const t = clamp(0, 1, baseT + (noise - 0.5) * 0.06)
264+
265+
return evaluateGradient(gradient.stops, t)
266+
}
267+
268+
function evaluateGradient(stops: GradientStop[], t: number): RgbColor {
269+
if (stops.length === 0) {
270+
return [200, 200, 200]
271+
}
272+
273+
if (t <= stops[0].offset) {
274+
return stops[0].color
275+
}
276+
277+
for (let index = 1; index < stops.length; index += 1) {
278+
const stop = stops[index]
279+
const previous = stops[index - 1]
280+
if (t <= stop.offset) {
281+
const span = Math.max(stop.offset - previous.offset, 1e-6)
282+
const amount = (t - previous.offset) / span
283+
return mixColors(previous.color, stop.color, amount)
284+
}
285+
}
286+
287+
return stops[stops.length - 1].color
288+
}
289+
290+
function mixColors(
291+
[r1, g1, b1]: RgbColor,
292+
[r2, g2, b2]: RgbColor,
293+
amount: number,
294+
): RgbColor {
295+
return [
296+
r1 + (r2 - r1) * amount,
297+
g1 + (g2 - g1) * amount,
298+
b1 + (b2 - b1) * amount,
299+
]
300+
}
301+
302+
function toCssColor([r, g, b]: RgbColor) {
303+
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
304+
}
305+
306+
function hslToRgb(h: number, s: number, l: number): RgbColor {
307+
const chroma = (1 - Math.abs(2 * l - 1)) * s
308+
const huePrime = h / 60
309+
const x = chroma * (1 - Math.abs((huePrime % 2) - 1))
310+
311+
let r = 0
312+
let g = 0
313+
let b = 0
314+
315+
if (huePrime >= 0 && huePrime < 1) {
316+
r = chroma
317+
g = x
318+
} else if (huePrime >= 1 && huePrime < 2) {
319+
r = x
320+
g = chroma
321+
} else if (huePrime >= 2 && huePrime < 3) {
322+
g = chroma
323+
b = x
324+
} else if (huePrime >= 3 && huePrime < 4) {
325+
g = x
326+
b = chroma
327+
} else if (huePrime >= 4 && huePrime < 5) {
328+
r = x
329+
b = chroma
330+
} else if (huePrime >= 5 && huePrime < 6) {
331+
r = chroma
332+
b = x
333+
}
334+
335+
const m = l - chroma / 2
336+
337+
return [(r + m) * 255, (g + m) * 255, (b + m) * 255]
338+
}
339+
340+
function normalizeHue(hue: number) {
341+
return ((hue % 360) + 360) % 360
342+
}
343+
344+
function clamp(min: number, max: number, value: number) {
345+
return Math.min(max, Math.max(min, value))
346+
}
347+
348+
function hashString(value: string) {
349+
let hash = 2166136261
350+
for (let index = 0; index < value.length; index += 1) {
351+
hash ^= value.charCodeAt(index)
352+
hash = Math.imul(hash, 16777619)
353+
}
354+
return hash >>> 0
11355
}

src/components/blog-page/blog-card.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ export function BlogCard({
3434
)}
3535
{...rest}
3636
>
37-
<BlogCardPicture className="aspect-[2.23]">
37+
<BlogCardPicture
38+
seed={frontMatter.title}
39+
// this weird selector sets background to tags so they look better on top of the picture
40+
className="aspect-[2.23] [&>*>*>*]:bg-neu-0"
41+
>
3842
<BlogTags tags={frontMatter.tags} />
3943
</BlogCardPicture>
4044
<div className="flex grow flex-col border border-neu-200 dark:border-neu-50">
@@ -92,3 +96,5 @@ export function BlogCardFooterContent({
9296
</span>
9397
)
9498
}
99+
100+
export { BlogCardPicture } from "./blog-card-picture"
-18.5 KB
Loading

0 commit comments

Comments
 (0)