This commit is contained in:
David Haz
2025-12-15 11:19:29 +02:00
parent 082b11daf2
commit 898db78b44
6 changed files with 204 additions and 107 deletions

View File

@@ -8,7 +8,8 @@
</div> </div>
<h2> <h2>
<template v-if="isMobile">100</template> <template v-if="isMobile">100</template>
<CountUp v-else :to="100" />% <CountUp v-else :to="100" />
%
</h2> </h2>
<h3>Free &amp; Open Source</h3> <h3>Free &amp; Open Source</h3>
<p>Loved by developers around the world</p> <p>Loved by developers around the world</p>
@@ -20,7 +21,8 @@
</div> </div>
<h2> <h2>
<template v-if="isMobile">90</template> <template v-if="isMobile">90</template>
<CountUp v-else :to="90" />+ <CountUp v-else :to="90" />
+
</h2> </h2>
<h3>Creative Components</h3> <h3>Creative Components</h3>
<p>Growing weekly &amp; only getting better</p> <p>Growing weekly &amp; only getting better</p>

View File

@@ -18,7 +18,10 @@
'--item-height': 'calc(var(--circ) / var(--segments-y))' '--item-height': 'calc(var(--circ) / var(--segments-y))'
}" }"
> >
<main ref="mainRef" class="absolute inset-0 grid place-items-center overflow-hidden touch-none select-none bg-transparent"> <main
ref="mainRef"
class="absolute inset-0 grid place-items-center overflow-hidden touch-none select-none bg-transparent"
>
<div <div
class="w-full h-full grid place-items-center contain-layout contain-paint contain-size" class="w-full h-full grid place-items-center contain-layout contain-paint contain-size"
:style="{ :style="{
@@ -392,11 +395,7 @@ const autoRotateStep = (now: number) => {
const deltaMs = now - lastAutoRotateTime; const deltaMs = now - lastAutoRotateTime;
lastAutoRotateTime = now; lastAutoRotateTime = now;
const canSpin = const canSpin = !isDragging.value && !isOpening.value && !focusedElement.value && inertiaAnimationFrame === null;
!isDragging.value &&
!isOpening.value &&
!focusedElement.value &&
inertiaAnimationFrame === null;
if (canSpin && deltaMs > 0) { if (canSpin && deltaMs > 0) {
const nextY = wrapAngleSigned(rotation.value.y + deltaMs * AUTO_ROTATE_SPEED_DEG_PER_MS); const nextY = wrapAngleSigned(rotation.value.y + deltaMs * AUTO_ROTATE_SPEED_DEG_PER_MS);
@@ -462,8 +461,8 @@ const onDragEnd = (e: MouseEvent | TouchEvent) => {
// Calculate velocity for inertia (simplified version) // Calculate velocity for inertia (simplified version)
if (hasMoved.value && startPosition.value) { if (hasMoved.value && startPosition.value) {
const clientX = 'touches' in e ? e.changedTouches?.[0]?.clientX ?? 0 : e.clientX; const clientX = 'touches' in e ? (e.changedTouches?.[0]?.clientX ?? 0) : e.clientX;
const clientY = 'touches' in e ? e.changedTouches?.[0]?.clientY ?? 0 : e.clientY; const clientY = 'touches' in e ? (e.changedTouches?.[0]?.clientY ?? 0) : e.clientY;
const dxTotal = clientX - startPosition.value.x; const dxTotal = clientX - startPosition.value.x;
const dyTotal = clientY - startPosition.value.y; const dyTotal = clientY - startPosition.value.y;
@@ -859,8 +858,7 @@ onUnmounted(() => {
}); });
// Watch for rotation changes // Watch for rotation changes
watch(rotation, (newRotation) => { watch(rotation, newRotation => {
applyTransform(newRotation.x, newRotation.y); applyTransform(newRotation.x, newRotation.y);
}); });
</script> </script>

View File

@@ -122,7 +122,11 @@ void main() {
`; `;
class Face { class Face {
constructor(public a: number, public b: number, public c: number) {} constructor(
public a: number,
public b: number,
public c: number
) {}
} }
class Vertex { class Vertex {
@@ -186,7 +190,7 @@ class Geometry {
} }
spherize(radius = 1): this { spherize(radius = 1): this {
this.vertices.forEach((vertex) => { this.vertices.forEach(vertex => {
vec3.normalize(vertex.normal, vertex.position); vec3.normalize(vertex.normal, vertex.position);
vec3.scale(vertex.position, vertex.normal, radius); vec3.scale(vertex.position, vertex.normal, radius);
}); });
@@ -198,24 +202,24 @@ class Geometry {
vertices: this.vertexData, vertices: this.vertexData,
indices: this.indexData, indices: this.indexData,
normals: this.normalData, normals: this.normalData,
uvs: this.uvData, uvs: this.uvData
}; };
} }
get vertexData(): Float32Array { get vertexData(): Float32Array {
return new Float32Array(this.vertices.flatMap((v) => Array.from(v.position))); return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));
} }
get normalData(): Float32Array { get normalData(): Float32Array {
return new Float32Array(this.vertices.flatMap((v) => Array.from(v.normal))); return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));
} }
get uvData(): Float32Array { get uvData(): Float32Array {
return new Float32Array(this.vertices.flatMap((v) => Array.from(v.uv))); return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));
} }
get indexData(): Uint16Array { get indexData(): Uint16Array {
return new Uint16Array(this.faces.flatMap((f) => [f.a, f.b, f.c])); return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));
} }
getMidPoint(ndxA: number, ndxB: number, cache: Record<string, number>): number { getMidPoint(ndxA: number, ndxB: number, cache: Record<string, number>): number {
@@ -227,11 +231,7 @@ class Geometry {
const b = this.vertices[ndxB].position; const b = this.vertices[ndxB].position;
const ndx = this.vertices.length; const ndx = this.vertices.length;
cache[cacheKey] = ndx; cache[cacheKey] = ndx;
this.addVertex( this.addVertex((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5);
(a[0] + b[0]) * 0.5,
(a[1] + b[1]) * 0.5,
(a[2] + b[2]) * 0.5
);
return ndx; return ndx;
} }
} }
@@ -241,14 +241,103 @@ class IcosahedronGeometry extends Geometry {
super(); super();
const t = Math.sqrt(5) * 0.5 + 0.5; const t = Math.sqrt(5) * 0.5 + 0.5;
this.addVertex( this.addVertex(
-1, t, 0, 1, t, 0, -1, -t, 0, 1, -t, 0, -1,
0, -1, t, 0, 1, t, 0, -1, -t, 0, 1, -t, t,
t, 0, -1, t, 0, 1, -t, 0, -1, -t, 0, 1 0,
1,
t,
0,
-1,
-t,
0,
1,
-t,
0,
0,
-1,
t,
0,
1,
t,
0,
-1,
-t,
0,
1,
-t,
t,
0,
-1,
t,
0,
1,
-t,
0,
-1,
-t,
0,
1
).addFace( ).addFace(
0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, 0,
1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, 11,
3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, 5,
4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1 0,
5,
1,
0,
1,
7,
0,
7,
10,
0,
10,
11,
1,
5,
9,
5,
11,
4,
11,
10,
2,
10,
7,
6,
7,
1,
8,
3,
9,
4,
3,
4,
2,
3,
2,
6,
3,
6,
8,
3,
8,
9,
4,
9,
5,
2,
4,
11,
6,
2,
10,
8,
6,
7,
9,
8,
1
); );
} }
} }
@@ -346,14 +435,7 @@ function makeVertexArray(
if (loc === -1) continue; if (loc === -1) continue;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(loc); gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer( gl.vertexAttribPointer(loc, numElem, gl.FLOAT, false, 0, 0);
loc,
numElem,
gl.FLOAT,
false,
0,
0
);
} }
if (indices) { if (indices) {
@@ -380,7 +462,11 @@ function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
return needResize; return needResize;
} }
function makeBuffer(gl: WebGL2RenderingContext, sizeOrData: number | ArrayBuffer | ArrayBufferView, usage: number): WebGLBuffer | null { function makeBuffer(
gl: WebGL2RenderingContext,
sizeOrData: number | ArrayBuffer | ArrayBufferView,
usage: number
): WebGLBuffer | null {
const buf = gl.createBuffer(); const buf = gl.createBuffer();
if (!buf) return null; if (!buf) return null;
@@ -428,12 +514,15 @@ class ArcballControl {
private _rotationVelocity = 0; private _rotationVelocity = 0;
private _combinedQuat = quat.create(); private _combinedQuat = quat.create();
constructor(private canvas: HTMLCanvasElement, private updateCallback?: (deltaTime: number) => void) { constructor(
private canvas: HTMLCanvasElement,
private updateCallback?: (deltaTime: number) => void
) {
this.setupEventListeners(); this.setupEventListeners();
} }
private setupEventListeners() { private setupEventListeners() {
this.canvas.addEventListener('pointerdown', (e) => { this.canvas.addEventListener('pointerdown', e => {
vec2.set(this.pointerPos, e.clientX, e.clientY); vec2.set(this.pointerPos, e.clientX, e.clientY);
vec2.copy(this.previousPointerPos, this.pointerPos); vec2.copy(this.previousPointerPos, this.pointerPos);
this.isPointerDown = true; this.isPointerDown = true;
@@ -447,7 +536,7 @@ class ArcballControl {
this.isPointerDown = false; this.isPointerDown = false;
}); });
this.canvas.addEventListener('pointermove', (e) => { this.canvas.addEventListener('pointermove', e => {
if (this.isPointerDown) { if (this.isPointerDown) {
vec2.set(this.pointerPos, e.clientX, e.clientY); vec2.set(this.pointerPos, e.clientX, e.clientY);
} }
@@ -573,8 +662,8 @@ class InfiniteGridMenu {
matrices: { matrices: {
view: mat4.create(), view: mat4.create(),
projection: mat4.create(), projection: mat4.create(),
inversProjection: mat4.create(), inversProjection: mat4.create()
}, }
}; };
private nearestVertexIndex: number | null = null; private nearestVertexIndex: number | null = null;
@@ -627,7 +716,7 @@ class InfiniteGridMenu {
aModelPosition: 0, aModelPosition: 0,
aModelNormal: 1, aModelNormal: 1,
aModelUvs: 2, aModelUvs: 2,
aInstanceMatrix: 3, aInstanceMatrix: 3
}); });
if (!this.discProgram) { if (!this.discProgram) {
@@ -655,21 +744,25 @@ class InfiniteGridMenu {
this.discVAO = makeVertexArray( this.discVAO = makeVertexArray(
this.gl, this.gl,
[ [
[makeBuffer(this.gl, this.discBuffers.vertices, this.gl.STATIC_DRAW)!, this.discLocations.aModelPosition as number, 3], [
[makeBuffer(this.gl, this.discBuffers.uvs, this.gl.STATIC_DRAW)!, this.discLocations.aModelUvs as number, 2], makeBuffer(this.gl, this.discBuffers.vertices, this.gl.STATIC_DRAW)!,
this.discLocations.aModelPosition as number,
3
],
[makeBuffer(this.gl, this.discBuffers.uvs, this.gl.STATIC_DRAW)!, this.discLocations.aModelUvs as number, 2]
], ],
this.discBuffers.indices as Uint16Array this.discBuffers.indices as Uint16Array
); );
this.icoGeo = new IcosahedronGeometry(); this.icoGeo = new IcosahedronGeometry();
this.icoGeo.subdivide(1).spherize(this.SPHERE_RADIUS); this.icoGeo.subdivide(1).spherize(this.SPHERE_RADIUS);
this.instancePositions = this.icoGeo.vertices.map((v) => v.position); this.instancePositions = this.icoGeo.vertices.map(v => v.position);
this.DISC_INSTANCE_COUNT = this.icoGeo.vertices.length; this.DISC_INSTANCE_COUNT = this.icoGeo.vertices.length;
this.initDiscInstances(this.DISC_INSTANCE_COUNT); this.initDiscInstances(this.DISC_INSTANCE_COUNT);
this.initTexture(); this.initTexture();
this.control = new ArcballControl(this.canvas, (deltaTime) => this.onControlUpdate(deltaTime)); this.control = new ArcballControl(this.canvas, deltaTime => this.onControlUpdate(deltaTime));
this.updateCameraMatrix(); this.updateCameraMatrix();
this.updateProjectionMatrix(); this.updateProjectionMatrix();
@@ -681,7 +774,13 @@ class InfiniteGridMenu {
private initTexture() { private initTexture() {
if (!this.gl) return; if (!this.gl) return;
this.tex = createAndSetupTexture(this.gl, this.gl.LINEAR, this.gl.LINEAR, this.gl.CLAMP_TO_EDGE, this.gl.CLAMP_TO_EDGE); this.tex = createAndSetupTexture(
this.gl,
this.gl.LINEAR,
this.gl.LINEAR,
this.gl.CLAMP_TO_EDGE,
this.gl.CLAMP_TO_EDGE
);
if (!this.tex) return; if (!this.tex) return;
const itemCount = Math.max(1, this.items.length); const itemCount = Math.max(1, this.items.length);
@@ -694,15 +793,18 @@ class InfiniteGridMenu {
canvas.width = this.atlasSize * cellSize; canvas.width = this.atlasSize * cellSize;
canvas.height = this.atlasSize * cellSize; canvas.height = this.atlasSize * cellSize;
Promise.all(this.items.map(item => Promise.all(
new Promise<HTMLImageElement>(resolve => { this.items.map(
const img = new Image(); item =>
img.crossOrigin = 'anonymous'; new Promise<HTMLImageElement>(resolve => {
img.onload = () => resolve(img); const img = new Image();
img.onerror = () => resolve(img); // Continue even if image fails img.crossOrigin = 'anonymous';
img.src = item.image; img.onload = () => resolve(img);
}) img.onerror = () => resolve(img); // Continue even if image fails
)).then(images => { img.src = item.image;
})
)
).then(images => {
images.forEach((img, i) => { images.forEach((img, i) => {
const x = (i % this.atlasSize) * cellSize; const x = (i % this.atlasSize) * cellSize;
const y = Math.floor(i / this.atlasSize) * cellSize; const y = Math.floor(i / this.atlasSize) * cellSize;
@@ -727,7 +829,7 @@ class InfiniteGridMenu {
this.discInstances = { this.discInstances = {
matricesArray: new Float32Array(count * 16), matricesArray: new Float32Array(count * 16),
matrices: [] as Float32Array[], matrices: [] as Float32Array[],
buffer: this.gl.createBuffer(), buffer: this.gl.createBuffer()
}; };
for (let i = 0; i < count; ++i) { for (let i = 0; i < count; ++i) {
@@ -745,14 +847,7 @@ class InfiniteGridMenu {
for (let j = 0; j < mat4AttribSlotCount; ++j) { for (let j = 0; j < mat4AttribSlotCount; ++j) {
const loc = (this.discLocations.aInstanceMatrix as number) + j; const loc = (this.discLocations.aInstanceMatrix as number) + j;
this.gl.enableVertexAttribArray(loc); this.gl.enableVertexAttribArray(loc);
this.gl.vertexAttribPointer( this.gl.vertexAttribPointer(loc, 4, this.gl.FLOAT, false, bytesPerMatrix, j * 4 * 4);
loc,
4,
this.gl.FLOAT,
false,
bytesPerMatrix,
j * 4 * 4
);
this.gl.vertexAttribDivisor(loc, 1); this.gl.vertexAttribDivisor(loc, 1);
} }
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
@@ -768,15 +863,11 @@ class InfiniteGridMenu {
this.animate(); this.animate();
this.render(); this.render();
animationId = requestAnimationFrame((t) => this.run(t)); animationId = requestAnimationFrame(t => this.run(t));
} }
resize() { resize() {
this.viewportSize = vec2.set( this.viewportSize = vec2.set(this.viewportSize || vec2.create(), this.canvas.clientWidth, this.canvas.clientHeight);
this.viewportSize || vec2.create(),
this.canvas.clientWidth,
this.canvas.clientHeight
);
if (!this.gl) return; if (!this.gl) return;
@@ -793,7 +884,7 @@ class InfiniteGridMenu {
this.control.update(this.deltaTime, this.TARGET_FRAME_DURATION); this.control.update(this.deltaTime, this.TARGET_FRAME_DURATION);
const positions = this.instancePositions.map((p) => vec3.transformQuat(vec3.create(), p, this.control.orientation)); const positions = this.instancePositions.map(p => vec3.transformQuat(vec3.create(), p, this.control.orientation));
const scale = 0.25; const scale = 0.25;
const SCALE_INTENSITY = 0.6; const SCALE_INTENSITY = 0.6;
@@ -832,7 +923,12 @@ class InfiniteGridMenu {
this.gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix); this.gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix);
this.gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view); this.gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view);
this.gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection); this.gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection);
this.gl.uniform3f(this.discLocations.uCameraPosition, this.camera.position[0], this.camera.position[1], this.camera.position[2]); this.gl.uniform3f(
this.discLocations.uCameraPosition,
this.camera.position[0],
this.camera.position[1],
this.camera.position[2]
);
this.gl.uniform4f( this.gl.uniform4f(
this.discLocations.uRotationAxisVelocity, this.discLocations.uRotationAxisVelocity,
this.control.rotationAxis[0], this.control.rotationAxis[0],
@@ -892,7 +988,7 @@ class InfiniteGridMenu {
if (this.camera.aspect > 1) { if (this.camera.aspect > 1) {
this.camera.fov = 2 * Math.atan(height / distance); this.camera.fov = 2 * Math.atan(height / distance);
} else { } else {
this.camera.fov = 2 * Math.atan((height / this.camera.aspect) / distance); this.camera.fov = 2 * Math.atan(height / this.camera.aspect / distance);
} }
mat4.perspective( mat4.perspective(
this.camera.matrices.projection, this.camera.matrices.projection,
@@ -989,8 +1085,10 @@ onMounted(() => {
canvasRef.value, canvasRef.value,
resolvedItems.value, resolvedItems.value,
handleActiveItem, handleActiveItem,
(moving) => { isMoving.value = moving; }, moving => {
(menu) => menu.run() isMoving.value = moving;
},
menu => menu.run()
); );
const handleResize = () => { const handleResize = () => {
@@ -1010,27 +1108,30 @@ onMounted(() => {
} }
}); });
watch(() => props.items, () => { watch(
// Reinitialize on items change () => props.items,
if (infiniteMenu && canvasRef.value) { () => {
infiniteMenu.destroy(); // Reinitialize on items change
infiniteMenu = new InfiniteGridMenu( if (infiniteMenu && canvasRef.value) {
canvasRef.value, infiniteMenu.destroy();
resolvedItems.value, infiniteMenu = new InfiniteGridMenu(
handleActiveItem, canvasRef.value,
(moving) => { isMoving.value = moving; }, resolvedItems.value,
(menu) => menu.run() handleActiveItem,
); moving => {
} isMoving.value = moving;
}, { deep: true }); },
menu => menu.run()
);
}
},
{ deep: true }
);
</script> </script>
<template> <template>
<div class="relative w-full h-full"> <div class="relative w-full h-full">
<canvas <canvas ref="canvasRef" class="w-full h-full cursor-grab active:cursor-grabbing outline-none overflow-hidden" />
ref="canvasRef"
class="w-full h-full cursor-grab active:cursor-grabbing outline-none overflow-hidden"
/>
<template v-if="activeItem"> <template v-if="activeItem">
<h2 <h2
@@ -1051,9 +1152,7 @@ watch(() => props.items, () => {
<p <p
:class="[ :class="[
'select-none absolute top-1/2 text-2xl text-white/80 transition-all ease-in-out hidden xl:block', 'select-none absolute top-1/2 text-2xl text-white/80 transition-all ease-in-out hidden xl:block',
isMoving isMoving ? 'pointer-events-none opacity-0 duration-100' : 'opacity-100 pointer-events-auto duration-500'
? 'pointer-events-none opacity-0 duration-100'
: 'opacity-100 pointer-events-auto duration-500'
]" ]"
:style="{ :style="{
right: '1%', right: '1%',
@@ -1068,9 +1167,7 @@ watch(() => props.items, () => {
@click="handleButtonClick" @click="handleButtonClick"
:class="[ :class="[
'absolute left-1/2 z-10 grid place-items-center bg-purple-600 rounded-full cursor-pointer border-4 border-black transition-all ease-in-out', 'absolute left-1/2 z-10 grid place-items-center bg-purple-600 rounded-full cursor-pointer border-4 border-black transition-all ease-in-out',
isMoving isMoving ? 'pointer-events-none opacity-0 duration-100' : 'opacity-100 pointer-events-auto duration-500'
? 'pointer-events-none opacity-0 duration-100'
: 'opacity-100 pointer-events-auto duration-500'
]" ]"
:style="{ :style="{
width: '60px', width: '60px',