Migrate Vite SPA to Hugo (hugo-coder, dark) with WebGPU background

- Hugo static site at root: config, content (home/about/resume/blog + 5 projects),
  data/resume.yaml, custom layouts (gpu background, resume renderer, projects list),
  dark theme with canvas behind content
- gpu-bg/: extracted WebGPU/WebGL2 spiral engine as a Vite sub-package; slim bootstrap
  drops router/overlay/text renderer; fixed uniforms (scene0/dim0); self-contained engine.js
- archive/: retired Vite SPA preserved (router, overlay, data, engine/text orphaned)
- 3-stage Dockerfile (node -> hugo extended/dart-sass -> nginx), static nginx config
- DEPLOYMENT.md handover + README rewrite; pre-hugo rollback tag
This commit is contained in:
2026-08-21 03:39:42 +02:00
parent 3ad5cf55e3
commit 9ea85db5ce
74 changed files with 3170 additions and 75 deletions

154
archive/src/app.ts Normal file
View File

@@ -0,0 +1,154 @@
import { Renderer, UiState } from './engine/renderer'
import { GlRenderer } from './engine/gl-renderer'
import type { RendererLike } from './engine/types'
import { Input } from './ui/input'
import { DomOverlay } from './ui/overlay'
import { Router, Route } from './router'
import { PROJECTS_SCENE, RESUME_SCENE } from './data/pages'
import { SCENES } from './data/scenes'
const DPR_CAP = 2
export class App {
private canvas: HTMLCanvasElement
private renderer: RendererLike = new Renderer()
private input!: Input
private overlay!: DomOverlay
private router = new Router()
private dpr = 1
private res = new Float32Array(2)
private mouse = new Float32Array(2)
private scene = 0
private prevScene = -1
private startTime = 0
private rafId = 0
private running = false
private stemMultipliers = new Float32Array(32)
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
}
async start(): Promise<boolean> {
this.input = new Input()
this.overlay = new DomOverlay(SCENES, {
onNavClick: (s) => {
this.router.navigate(Router.sceneToBasePath(s))
},
onNavigate: (path) => {
this.router.navigate(path)
},
})
this.router.onChange((route) => this.applyRoute(route))
const initial = this.router.start()
this.applyRoute(initial)
this.router.replace(Router.sceneToBasePath(this.scene))
this.resize()
let ok = await this.renderer.init(this.canvas)
if (!ok) {
this.renderer = new GlRenderer()
ok = await this.renderer.init(this.canvas)
}
if (!ok) return false
this.startTime = performance.now()
this.running = true
this.loop()
return true
}
stop(): void {
this.running = false
cancelAnimationFrame(this.rafId)
}
// ---- routing ----
private applyRoute(route: Route): void {
this.scene = route.scene
this.overlay.setScene(this.scene)
if (route.scene === PROJECTS_SCENE && 'view' in route) {
if (route.view === 'category' && 'catId' in route && route.catId) {
this.overlay.showCategory(route.catId)
} else if (
route.view === 'detail' &&
'catId' in route && route.catId &&
'projectId' in route && route.projectId
) {
this.overlay.showProjectDetail(route.projectId, route.catId)
} else {
this.overlay.showCategoryGrid()
}
} else if (route.scene === RESUME_SCENE) {
this.overlay.showResume()
}
}
// ---- rendering ----
private resize(): void {
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
const w = Math.floor(window.innerWidth * this.dpr)
const h = Math.floor(window.innerHeight * this.dpr)
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w
this.canvas.height = h
this.res[0] = w
this.res[1] = h
if (this.renderer.ready) this.renderer.resize()
}
}
private computeStemMultipliers(time: number): void {
const sm = this.stemMultipliers
const colorBase = [0.0, 0.6, 1.2, 2.4]
const speedBase = [0.0, 0.2, 0.1, 0.15]
const scene = this.scene
sm[0] = speedBase[scene]
sm[2] = colorBase[scene] * 0.2
sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5)
sm[4] = colorBase[scene] * 0.4
sm[8] = 0.1
}
private loop = (): void => {
if (!this.running) return
this.rafId = requestAnimationFrame(this.loop)
const now = performance.now()
const time = (now - this.startTime) / 1000
this.resize()
const mx = this.input.x * this.dpr
const my = this.res[1] - this.input.y * this.dpr
this.mouse[0] = mx
this.mouse[1] = my
if (this.scene !== this.prevScene) {
this.prevScene = this.scene
}
this.computeStemMultipliers(time)
// dim the shader when on any non-home scene
const dim = this.scene === 0 ? 0.0 : 0.4
const state: UiState = {
resolution: this.res,
time,
scene: this.scene,
mouse: this.mouse,
dim,
stemMultipliers: this.stemMultipliers,
}
this.renderer.render(state)
}
}

61
archive/src/data/pages.ts Normal file
View File

@@ -0,0 +1,61 @@
// Single source of truth for every page on the site.
// Add a new entry here and routing + navigation wire up automatically.
//
// Slug conventions:
// '' (empty string) → home page at /
// 'about' → /about
// 'projects' → /projects (sub-routes handled by the router)
// 'resume' → /resume
//
// Scene index = position in the array. Keep scenes 0-1 as simple pages
// and reserve scene 2 for projects (which has sub-navigation via categories).
export interface PageDef {
slug: string
title: string
subtitle: string
body: string
}
export const PAGES: PageDef[] = [
{
slug: '',
title: 'Ahmed A. Mohamed',
subtitle: 'Creative Developer & Graphics Engineer',
body: "Welcome to my personal website, there's a lot going on in my life that I will be sharing here randomly over time so take a look.",
},
{
slug: 'about',
title: 'ABOUT',
subtitle: 'Who I am',
body: 'I build creative web experiences\nusing cutting-edge graphics technology.',
},
{
slug: 'projects',
title: 'PROJECTS',
subtitle: 'Things I have built',
body: 'A collection of work exploring\nWebGPU, shaders, and generative art.',
},
{
slug: 'resume',
title: 'RESUME',
subtitle: 'Ahmed Mohamed',
body: 'Graphics & Systems Engineer',
},
]
// ---- derived lookup maps (built once, used by the router) ----
/** path (e.g. '/about') → scene index */
export const PATH_TO_SCENE: ReadonlyMap<string, number> = new Map(
PAGES.map((p, i) => [p.slug === '' ? '/' : `/${p.slug}`, i]),
)
/** scene index → base path (e.g. 1 → '/about') */
export const SCENE_TO_PATH: ReadonlyMap<number, string> = new Map(
PAGES.map((_, i) => [i, PAGES[i].slug === '' ? '/' : `/${PAGES[i].slug}`]),
)
/** Scene indices resolved by slug — update PAGES and these auto-track. */
export const PROJECTS_SCENE = PAGES.findIndex((p) => p.slug === 'projects')
export const RESUME_SCENE = PAGES.findIndex((p) => p.slug === 'resume')

View File

@@ -0,0 +1,18 @@
import { ProjectPage } from './index'
const page: ProjectPage = {
id: 'auv-simulation',
name: 'AUV Simulation & Interfacing',
tagline: 'Full physics simulation + ROS + Qt ground control',
date: '2021',
tech: ['C++', 'ROS', 'Gazebo', 'Qt5'],
content: `
<p>Full AUV physics simulation in Gazebo with ROS nodes using GStreamer to pipeline
media from AUV/simulation to an external monitoring interface via a Qt application
for the ground control team.</p>
<p>Designed the simulator, developed the Gazebo plugins and the C++ ROS nodes.</p>
`,
}
export default page

View File

@@ -0,0 +1,20 @@
import { ProjectPage } from './index'
const page: ProjectPage = {
id: 'evol-engine',
name: 'Evol Game Engine',
tagline: 'Graduation Project — Vulkan-powered engine',
date: '07/2021',
tech: ['C', 'C++', 'Vulkan', 'OpenGL', 'ImGUI'],
content: `
<p>Developed a high-performance, Vulkan-powered game engine with real-time physics
simulation, applicable to vehicle simulation and digital twin technology.</p>
<p>Built a modular scene editor for real-time rendering and physics-based simulation.</p>
<p>Implemented the Vulkan renderer for the engine core and an OpenGL PBR renderer
for the editor.</p>
`,
}
export default page

View File

@@ -0,0 +1,17 @@
import { ProjectPage } from './index'
const page: ProjectPage = {
id: 'improv-gfx',
name: 'ImprovGFX',
tagline: 'From-scratch rasterizer with OpenCL GPU acceleration',
date: '2021',
tech: ['C++', 'OpenCL'],
content: `
<p>A renderer/rasterizer built from scratch to render .obj models and .tga textures,
with OpenCL providing GPU acceleration.</p>
<p>Built a clean API for constructing scenes and shaders.</p>
`,
}
export default page

View File

@@ -0,0 +1,66 @@
// ---------- types ----------
export interface ProjectPage {
id: string
name: string
tagline: string
date: string
tech: string[]
content: string // free-form HTML
}
export interface ProjectCategory {
id: string
label: string
description: string
projects: ProjectPage[]
}
// ---------- project files ----------
import radianceCaching from './radiance-caching'
import pointCloud from './point-cloud'
import evolEngine from './evol-engine'
import auvSimulation from './auv-simulation'
import improvGfx from './improv-gfx'
// ---------- flat list (for lookups) ----------
export const PROJECTS: ProjectPage[] = [
radianceCaching,
pointCloud,
evolEngine,
auvSimulation,
improvGfx,
]
// ---------- categories ----------
// Add new categories here. Projects go in the `projects` array.
// Empty categories get a placeholder card on the grid.
export const CATEGORIES: ProjectCategory[] = [
{
id: 'programming',
label: 'Programming',
description: 'Graphics, engines, tools, and low-level systems.',
projects: [radianceCaching, pointCloud, evolEngine, improvGfx, auvSimulation],
},
{
id: 'hardware',
label: 'Hardware',
description: 'Embedded, FPGA, PCB, and physical builds.',
projects: [],
},
{
id: 'visuals',
label: 'Visuals',
description: 'Shaders, demoscene, generative art, and rendering experiments.',
projects: [],
},
{
id: 'music',
label: 'Music',
description: 'Production, sound design, tools, and performances.',
projects: [],
},
]

View File

@@ -0,0 +1,24 @@
import { ProjectPage } from './index'
const page: ProjectPage = {
id: 'point-cloud',
name: 'GPU-Driven Point Cloud Renderer',
tagline: 'TUM — 140M+ points at ~100 FPS',
date: '2024',
tech: ['C++', 'Vulkan', 'GLSL'],
content: `
<p>Implemented a real-time GPU-driven renderer for large point clouds (140M+ points
at ~100 FPS) using a custom 5-stage compute shader pipeline in Vulkan, based on
Schütz et al. (2021, 2022).</p>
<p>Created a software rasterization pipeline using atomic uint64 depth-color packing
and compute shaders, enabling fine-grained depth testing without a hardware depth
buffer.</p>
<p>Implemented batch-based frustum culling, Morton-code spatial sorting, and
screen-coverage LOD selection via 10-bit quantized position precision. Used Vulkan
subgroup extensions for warp-level optimizations.</p>
`,
}
export default page

View File

@@ -0,0 +1,21 @@
import { ProjectPage } from './index'
const page: ProjectPage = {
id: 'radiance-caching',
name: 'Radiance Caching for Real-Time Volumetric Rendering',
tagline: "Master's Thesis — TUM",
date: '12/2025 — 06/2026',
tech: ['C++', 'CUDA', 'Vulkan', 'OptiX'],
content: `
<p>Researched and implemented a real-time radiance cache for volumetric path tracing
using 3D Gaussian Splatting and a fused MLP as the cache representation.</p>
<p>Developed an online differentiable optimization pipeline in CUDA that adapts the
Gaussian cache to dynamic lighting and transfer function changes during rendering.</p>
<p>Built a spatial-hash-based fused MLP for radiance caching on volumetric
heterogeneous media. Supervised by Prof. Westermann.</p>
`,
}
export default page

130
archive/src/data/resume.ts Normal file
View File

@@ -0,0 +1,130 @@
// ---------- resume data types ----------
export interface ResumeEntry {
role: string
company: string
location: string
date: string
bullets: string[]
thesis?: string // education only
}
export interface SkillGroup {
label: string
skills: string[]
}
export interface ResumeData {
name: string
contact: { label: string; href?: string }[]
location: string
summary: string
education: ResumeEntry[]
work: ResumeEntry[]
skills: SkillGroup[]
}
// ---------- resume content ----------
export const RESUME: ResumeData = {
name: 'Ahmed Mohamed',
contact: [
{ label: 'ineditedofficial@gmail.com', href: 'mailto:ineditedofficial@gmail.com' },
{ label: 'linkedin.com/in/inedited/', href: 'https://linkedin.com/in/inedited/' },
{ label: 'github.com/inedited', href: 'https://github.com/inedited' },
],
location: 'München, Germany',
summary:
'Experienced software developer with a strong background in graphics programming, real-time rendering, XR development, Game development and AI engineering. Experienced in developing high-performance applications and specializing in low-level optimizations.',
education: [
{
role: 'Master of Science in Informatics: Games Engineering',
company: 'Technical University of Munich (TUM)',
location: 'Munich, Germany',
date: '04/2024 \u2014 06/2026',
thesis:
'Thesis: Real-time volume path tracing using neural radiance caching and 3DGS Radiance cache.',
bullets: [],
},
{
role: 'Bachelor of Science in Software Systems and Computer Engineering',
company: 'Ain Shams University',
location: 'Cairo, Egypt',
date: '07/2016 \u2014 07/2021',
thesis: 'Thesis: 3D game engine in C.',
bullets: [],
},
{
role: 'Dual Bachelor of Science in Software Systems and Computer Engineering',
company: 'University of East London',
location: 'London, United Kingdom',
date: '07/2018 \u2014 07/2021',
bullets: [],
},
],
work: [
{
role: 'Software Developer (Working Student)',
company: 'Siemens AG',
location: 'Munich, Germany',
date: '04/2025 \u2014 Present',
bullets: [
'Led full cycle development across Physical AI, simulation, XR, and robotics integration projects within the Industrial Metaverse Lab.',
'Worked on technical collaboration projects with NVIDIA, Meta, and Amazon AWS for Industrial AI applications.',
'Built XR applications for Meta Quest, Meta RayBan, and Apple Vision Pro, implementing real time XR AI assisted solutions for industrial environments.',
'Worked on robotics simulation and training using NVIDIA IsaacSim, developed ROS integrations, Teleoperation protocols, and deployed trained models to physical robots.',
'Deployed and fine-tuned LLM models for industrial applications and internal tooling.',
],
},
{
role: 'Software Developer',
company: 'The Forge ConfettiFX',
location: 'California (Remote), USA',
date: '03/2022 \u2014 08/2023',
bullets: [
'Contributed to real-time rendering engine for <em>Forza Motorsport 8</em> with Turn10 studio, optimizing rendering performance and rewriting shadow rendering system and asset pipelines.',
'Improved The Forge rendering framework visibility buffer implementation, optimizing the graphics pipeline for real time rendering and testing across different consoles.',
'Developed and optimized Linux ports for The Forge rendering framework and optimized the framework for Steam Deck.',
'Redesigned the CI/CD testing pipeline for a Linux-based rendering framework, reducing testing time.',
],
},
{
role: 'Software Developer',
company: 'SciChart',
location: 'London (Remote), United Kingdom',
date: '01/2022 \u2014 01/2025',
bullets: [
'Developed and optimized the SciChart 3D graphics engine for high performance data visualization, simulation, telemetry, and real time analytics.',
'Ported the 3D graphics engine and WPF Desktop application to Linux.',
'Worked on the development of JavaScript/WebGL version of the 3D graphics engine on top of the C++ core engine.',
'Implemented a Vulkan rendering backend, increasing performance on large scale datasets by about 20% across multiple platforms.',
],
},
{
role: 'Software Developer',
company: '412 Labs',
location: 'Cairo, Egypt',
date: '03/2021 \u2014 12/2021',
bullets: [
'Developed VR applications using Unity for different client projects for architectural visualization and internal designs.',
'Taught a class of 20 students on VR development fundamentals using Unity.',
'Built and deployed multiple full stack solutions for 412 Labs projects and clients.',
],
},
],
skills: [
{
label: 'Languages',
skills: ['C', 'C++', 'C#', 'Python','Swift','TypeScript','Kotlin', 'GLSL', 'HLSL'],
},
{
label: 'Graphics & HPC',
skills: ['Vulkan', 'OpenGL', 'DirectX', 'CUDA', 'WebGpu', 'Volume Rendering', 'Path Tracing'],
},
{
label: 'Other',
skills: ['CI/CD', 'AI Agents', 'LLM', 'ROS', 'Unity', 'Unreal Engine', 'Git', 'Docker'],
},
],
}

View File

@@ -0,0 +1,31 @@
import type { SceneContent } from './index'
export const about: SceneContent = {
title: 'ABOUT',
subtitle: 'Who I am and what I do',
body: `\
<p>My name is Ahmed Alaa Ahmed Mohamed (aka Djoser / Djosen / Inedited). I'm a passionate creative software engineer and game developer.</p>
<p>I hold a master's degree in games engineering from TUM, with a strong background in graphics programming, real-time rendering, XR development, game development, and AI engineering.</p>
<p>I have experience working on AAA titles (Forza Motorsport 8) with Turn10 Games, as well as indie games and other personal projects. If it involves computers and is fun, I'm in.</p>
<p>Recently I've been getting into AI Engineering during my work at Siemens, and have developed workflows with Nvidia and Meta XR-AI for industrial operations.</p>
<p>Besides my professional work, I do some other fun stuff:</p>
<ul>
<li>Video games</li>
<li>Music production and sound design</li>
<li>Audio reactive visualizations</li>
<li>Shader programming</li>
<li>Sim racing</li>
<li>Hardware tinkering</li>
<li>Making more video games and whatnot</li>
</ul>
<p>You can reach me at <a href="mailto:ineditedofficial@gmail.com">ineditedofficial@gmail.com</a> or <a href="mailto:djoser@djosen.one">djoser@djosen.one</a> or "inedited" on discord. </p>
<p>It's a little hard to try to dumb everything about me in one page so just reach out to me, let's have a chat!</p>
`
,
}

View File

@@ -0,0 +1,7 @@
import type { SceneContent } from './index'
export const contact: SceneContent = {
title: 'CONTACT',
subtitle: 'Get in touch',
body: 'Reach me at hello@example.com\nor find me on GitHub.',
}

View File

@@ -0,0 +1,10 @@
import type { SceneContent } from './index'
export const hero: SceneContent = {
title: 'Ahmed A. Mohamed',
subtitle: 'Creative Developer | Game Developer | Software Engineer',
body: `\
<p>Welcome to my personal website. There's a lot going on in my life that I'll be sharing here over time, professional and not.</p>
<p>Everything is still under construction, I'll be adding updates as I go.</p>`,
}

View File

@@ -0,0 +1,18 @@
// ---------- types ----------
export interface SceneContent {
title: string
subtitle: string
body: string
}
// ---------- scene files ----------
import { hero } from './hero'
import { about } from './about'
import { projects } from './projects'
import { resume } from './resume'
// ---------- scene registry ----------
export const SCENES: SceneContent[] = [hero, about, projects, resume]

View File

@@ -0,0 +1,7 @@
import type { SceneContent } from './index'
export const projects: SceneContent = {
title: 'PROJECTS',
subtitle: 'Things I have built',
body: 'This is not an exhaustive list or an indepth, it\'s more of an attempt to try to document the fact that I build a lot of cool shit that I can\'t always talk about anywhere else. Some of these are personal projects, some are professional, some are just experiments and prototypes. I will try to keep this updated as I go.',
}

View File

@@ -0,0 +1,7 @@
import type { SceneContent } from './index'
export const resume: SceneContent = {
title: 'RESUME',
subtitle: 'Ahmed Mohamed',
body: 'Graphics & Systems Engineer',
}

View File

@@ -0,0 +1,127 @@
import glslSource from './shaders/fullscreen.glsl?raw'
import type { UiState } from './renderer'
import type { RendererLike } from './types'
const UNIFORM_FLOATS = 48
const UNIFORM_BYTES = UNIFORM_FLOATS * 4
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('GL shader compile error:', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(gl: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('GL program link error:', gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
return program
}
function splitShaderSource(source: string): { vs: string; fs: string } {
const parts = source.split('//---FRAGMENT---')
if (parts.length !== 2) throw new Error('GLSL source must contain //---VERTEX--- and //---FRAGMENT--- markers')
const vs = parts[0].replace('//---VERTEX---', '').trim()
const fs = parts[1].trim()
return { vs, fs }
}
export class GlRenderer implements RendererLike {
private gl: WebGL2RenderingContext | null = null
private program: WebGLProgram | null = null
private ubo: WebGLBuffer | null = null
private uniformData = new Float32Array(UNIFORM_FLOATS)
private _ready = false
get ready(): boolean {
return this._ready
}
async init(canvas: HTMLCanvasElement): Promise<boolean> {
const gl = canvas.getContext('webgl2', {
alpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false,
})
if (!gl) return false
this.gl = gl
const { vs: vsSrc, fs: fsSrc } = splitShaderSource(glslSource)
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc)
if (!vs) return false
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc)
if (!fs) {
gl.deleteShader(vs)
return false
}
const program = linkProgram(gl, vs, fs)
gl.deleteShader(vs)
gl.deleteShader(fs)
if (!program) return false
this.program = program
const ubo = gl.createBuffer()
if (!ubo) return false
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo)
gl.bufferData(gl.UNIFORM_BUFFER, UNIFORM_BYTES, gl.DYNAMIC_DRAW)
const blockIndex = gl.getUniformBlockIndex(program, 'Uniforms')
if (blockIndex === gl.INVALID_INDEX) {
console.error('Uniform block "Uniforms" not found in GLSL program')
return false
}
gl.uniformBlockBinding(program, blockIndex, 0)
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo)
this.ubo = ubo
this._ready = true
return true
}
resize(): void {
if (!this.gl) return
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
}
render(state: UiState): void {
const gl = this.gl
if (!gl || !this.ubo || !this._ready) return
const d = this.uniformData
d[0] = state.resolution[0]
d[1] = state.resolution[1]
d[2] = state.time
d[3] = state.scene
d[4] = state.mouse[0]
d[5] = state.mouse[1]
d[8] = state.dim
d.set(state.stemMultipliers, 16)
gl.bindBuffer(gl.UNIFORM_BUFFER, this.ubo)
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, this.uniformData)
gl.useProgram(this.program)
gl.drawArrays(gl.TRIANGLES, 0, 3)
}
}

View File

@@ -0,0 +1,105 @@
import shaderSource from './shaders/fullscreen.wgsl?raw'
const UNIFORM_FLOATS = 48 // res(2), time, scene, mouse(2), pad(2), date(4), sampleRate(4), stemMultipliers(32)
const UNIFORM_BYTES = UNIFORM_FLOATS * 4
export interface UiState {
resolution: Float32Array
time: number
scene: number
mouse: Float32Array
dim: number
stemMultipliers: Float32Array
}
export class Renderer {
private device!: GPUDevice
private context!: GPUCanvasContext
private format!: GPUTextureFormat
private pipeline!: GPURenderPipeline
private uniformBuffer!: GPUBuffer
private bindGroup!: GPUBindGroup
private uniformData = new Float32Array(UNIFORM_FLOATS)
private configured = false
get ready(): boolean {
return this.configured
}
async init(canvas: HTMLCanvasElement): Promise<boolean> {
if (!navigator.gpu) return false
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) return false
this.device = await adapter.requestDevice()
this.context = canvas.getContext('webgpu') as GPUCanvasContext
this.format = navigator.gpu.getPreferredCanvasFormat()
this.configure()
const module = this.device.createShaderModule({ code: shaderSource })
this.pipeline = this.device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format: this.format }] },
primitive: { topology: 'triangle-list' }
})
this.uniformBuffer = this.device.createBuffer({
size: UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
this.bindGroup = this.device.createBindGroup({
layout: this.pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
})
this.device.lost.then((info) => { console.error('WebGPU device lost:', info); this.configured = false })
this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => {
console.error('WebGPU uncaptured error:', ev.error)
}
this.configured = true
return true
}
configure(): void {
this.context.configure({
device: this.device,
format: this.format,
alphaMode: 'opaque'
})
}
resize(): void {
this.configure()
}
render(state: UiState): void {
if (!this.configured) return
const d = this.uniformData
d[0] = state.resolution[0]
d[1] = state.resolution[1]
d[2] = state.time
d[3] = state.scene
d[4] = state.mouse[0]
d[5] = state.mouse[1]
// d[6], d[7]: padding — left as zero
d[8] = state.dim // u.date.x — dim factor for non-home scenes
// d[12]-d[15]: sampleRate — left as zero
d.set(state.stemMultipliers, 16)
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData)
const encoder = this.device.createCommandEncoder()
const view = this.context.getCurrentTexture().createView()
const bgPass = encoder.beginRenderPass({
colorAttachments: [
{ view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }
]
})
bgPass.setPipeline(this.pipeline)
bgPass.setBindGroup(0, this.bindGroup)
bgPass.draw(3)
bgPass.end()
this.device.queue.submit([encoder.finish()])
}
}

View File

@@ -0,0 +1,110 @@
//---VERTEX---
#version 300 es
const vec2 POSITIONS[3] = vec2[](
vec2(-1.0, -1.0),
vec2(-1.0, 3.0),
vec2( 3.0, -1.0)
);
out vec2 vUv;
void main() {
vec2 pos = POSITIONS[gl_VertexID];
gl_Position = vec4(pos, 0.0, 1.0);
vUv = pos;
}
//---FRAGMENT---
#version 300 es
precision highp float;
layout(std140) uniform Uniforms {
vec2 resolution;
float time;
float scene;
vec2 mouse;
vec4 date;
vec4 sampleRate;
vec4 stemMultipliers[8];
};
in vec2 vUv;
out vec4 outColor;
void main() {
vec2 fragCoord = (vUv * 0.5 + 0.5) * resolution;
vec2 uv = (fragCoord - 0.5 * resolution) / resolution.y;
float slowTime = time * 0.2;
vec3 bgGrey = vec3(0.11, 0.11, 0.12);
vec3 cardGrey = vec3(0.30, 0.32, 0.36);
vec3 coreWhite = vec3(0.90, 0.95, 0.90);
vec3 scene0 = vec3(0.20, 0.75, 0.20);
vec3 scene1 = vec3(0.85, 0.55, 0.20);
vec3 scene2 = vec3(0.25, 0.60, 0.95);
vec3 scene3 = vec3(0.90, 0.35, 0.55);
int sFloor = int(clamp(floor(scene), 0.0, 3.0));
int sCeil = int(clamp(ceil(scene), 0.0, 3.0));
vec3 c0;
if (sFloor == 0) c0 = scene0;
else if (sFloor == 1) c0 = scene1;
else if (sFloor == 2) c0 = scene2;
else c0 = scene3;
vec3 c1;
if (sCeil == 0) c1 = scene0;
else if (sCeil == 1) c1 = scene1;
else if (sCeil == 2) c1 = scene2;
else c1 = scene3;
float tBlend = scene - floor(scene);
float smoothT = tBlend * tBlend * (3.0 - 2.0 * tBlend);
vec3 sceneAccent = mix(c0, c1, smoothT);
vec2 mousePos = (mouse - 0.5 * resolution) / resolution.y;
float distMouse = length(uv - mousePos);
float influence = exp(-distMouse * 7.0);
uv += (mousePos - uv) * influence * 0.12;
float angle = atan(uv.y, uv.x);
float radius = length(uv);
float baseSpiral = angle + (radius * 8.0) - slowTime;
float ripple = sin(radius * 15.0 - (time * 0.5)) * 0.08;
float distortedSpiral = baseSpiral + ripple;
float armCount = 2.0;
float rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount)));
float gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount)));
float bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount)));
vec3 patternChannels = vec3(rChannel, gChannel, bChannel);
vec3 coreNeon = vec3(0.02 / (patternChannels + 0.025));
vec3 spiralColor = mix(cardGrey, sceneAccent, patternChannels.g);
vec3 primarySpiral = coreNeon * spiralColor;
float ringWave = sin(radius * 12.0 - (time * 1.5));
float sharpRing = smoothstep(0.85, 0.95, ringWave);
float ringMask = sharpRing * smoothstep(0.7, 0.1, radius);
vec3 secondaryRings = cardGrey * ringMask * 0.4;
float vignette = smoothstep(0.95, 0.4, radius);
vec3 finalColor = bgGrey + secondaryRings;
finalColor = mix(finalColor, primarySpiral, 0.85);
float centerLight = 0.015 / (radius + 0.02);
finalColor += centerLight * coreWhite;
finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
float dim = date.x;
finalColor = mix(finalColor, vec3(0.0), dim);
outColor = vec4(finalColor * vignette, 1.0);
}

View File

@@ -0,0 +1,108 @@
struct Uniforms {
resolution: vec2<f32>,
time: f32,
scene: f32,
mouse: vec2<f32>,
date: vec4<f32>,
sampleRate: vec4<f32>,
stemMultipliers: array<vec4<f32>, 8>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VsOut {
let p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -3.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>(3.0, 1.0),
);
let clip = p[i];
var out: VsOut;
out.pos = vec4<f32>(clip, 0.0, 1.0);
out.uv = clip;
return out;
}
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
let res = u.resolution;
let fragCoord = (in.uv * vec2<f32>(0.5) + vec2<f32>(0.5)) * res;
var uv = (fragCoord - 0.5 * res) / res.y;
let slowTime = u.time * 0.2;
let bgGrey = vec3<f32>(0.11, 0.11, 0.12);
let cardGrey = vec3<f32>(0.30, 0.32, 0.36);
let coreWhite = vec3<f32>(0.90, 0.95, 0.90);
// ---------- palette keyed by scene ----------
let scene0 = vec3<f32>(0.20, 0.75, 0.20); // HERO – emerald
let scene1 = vec3<f32>(0.85, 0.55, 0.20); // ABOUT – amber
let scene2 = vec3<f32>(0.25, 0.60, 0.95); // PROJECTS – cobalt
let scene3 = vec3<f32>(0.90, 0.35, 0.55); // CONTACT – rose
let sFloor = i32(clamp(floor(u.scene), 0.0, 3.0));
let sCeil = i32(clamp(ceil(u.scene), 0.0, 3.0));
var c0 = scene0;
c0 = select(c0, scene1, sFloor == 1);
c0 = select(c0, scene2, sFloor == 2);
c0 = select(c0, scene3, sFloor == 3);
var c1 = scene0;
c1 = select(c1, scene1, sCeil == 1);
c1 = select(c1, scene2, sCeil == 2);
c1 = select(c1, scene3, sCeil == 3);
let tBlend = u.scene - floor(u.scene);
let sceneAccent = mix(c0, c1, tBlend * tBlend * (3.0 - 2.0 * tBlend));
// ---------- mouse-reactive distortion ----------
let mousePos = (u.mouse - 0.5 * res) / res.y;
let distMouse = length(uv - mousePos);
let influence = exp(-distMouse * 7.0);
uv += (mousePos - uv) * influence * 0.12;
let angle = atan2(uv.y, uv.x);
let radius = length(uv);
let baseSpiral = angle + (radius * 8.0) - slowTime;
let ripple = sin(radius * 15.0 - (u.time * 0.5)) * 0.08;
let distortedSpiral = baseSpiral + ripple;
let armCount = 2.0;
let rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount)));
let gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount)));
let bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount)));
let patternChannels = vec3<f32>(rChannel, gChannel, bChannel);
let coreNeon = vec3<f32>(0.02 / (patternChannels + 0.025));
let spiralColor = mix(cardGrey, sceneAccent, patternChannels.g);
let primarySpiral = coreNeon * spiralColor;
let ringWave = sin(radius * 12.0 - (u.time * 1.5));
let sharpRing = smoothstep(0.85, 0.95, ringWave);
let ringMask = sharpRing * smoothstep(0.7, 0.1, radius);
let secondaryRings = cardGrey * ringMask * 0.4;
let vignette = smoothstep(0.95, 0.4, radius);
var finalColor = bgGrey + secondaryRings;
finalColor = mix(finalColor, primarySpiral, 0.85);
let centerLight = 0.015 / (radius + 0.02);
finalColor += centerLight * coreWhite;
// soft cursor glow
finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
// ---------- dim when not on the home page (u.date.x controls it) ----------
let dim = u.date.x;
finalColor = mix(finalColor, vec3<f32>(0.0), dim);
return vec4<f32>(finalColor * vignette, 1.0);
}

View File

@@ -0,0 +1,352 @@
import type { Font, Glyph, PathCommand } from 'opentype.js'
const TEXTURE_WIDTH = 4096
export interface CodePointData {
codePoint: number
width: number
height: number
advanceWidth: number
bearingX: number
bearingY: number
bandCount: number
bandDimX: number
bandDimY: number
bandsTexCoordX: number
bandsTexCoordY: number
}
export interface SlugData {
codePoints: Map<number, CodePointData>
curvesData: Float32Array
bandsData: Uint32Array
curvesWidth: number
curvesHeight: number
bandsWidth: number
bandsHeight: number
ascender: number
descender: number
lineGap: number
unitsPerEm: number
}
interface Curve {
first: boolean
x1: number
y1: number
x2: number
y2: number
x3: number
y3: number
texelIndex: number
}
export interface GeneratorOptions {
bandCount?: number
fullRange?: boolean
whitelist?: number[] | null
}
export class SlugGenerator {
private bandCount: number
private fullRange: boolean
private whitelist: number[] | null
constructor(options: GeneratorOptions = {}) {
this.bandCount = options.bandCount ?? 16
this.fullRange = options.fullRange ?? false
this.whitelist = options.whitelist ?? null
}
async generateFromUrl(url: string): Promise<SlugData> {
const opentype = await import('opentype.js')
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch font: ${response.status}`)
const buffer = await response.arrayBuffer()
const font = opentype.parse(buffer)
return this.generate(font)
}
async generateFromBuffer(buffer: ArrayBuffer): Promise<SlugData> {
const opentype = await import('opentype.js')
const font = opentype.parse(buffer)
return this.generate(font)
}
generate(font: Font): SlugData {
const curvesTexData: number[] = []
const bandsTexBandOffsets: number[] = []
const bandsTexCurveOffsets: number[] = []
const codePointsData: CodePointData[] = []
for (let i = 0; i < font.glyphs.length; i++) {
const glyph: Glyph = font.glyphs.get(i)
let cp = glyph.unicode
if (cp === undefined) {
if (i === 0) cp = -1
else continue
}
if (!this.fullRange && i !== 0) {
if (this.whitelist) {
if (!this.whitelist.includes(cp)) continue
} else if (cp < 32 || cp > 126) {
continue
}
}
const path = glyph.path
const bbox = glyph.getBoundingBox()
if (bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2) {
codePointsData.push({
codePoint: cp,
width: 0,
height: 0,
advanceWidth: Math.floor(glyph.advanceWidth || 0),
bearingX: 0,
bearingY: 0,
bandCount: 0,
bandDimX: 0,
bandDimY: 0,
bandsTexCoordX: 0,
bandsTexCoordY: 0
})
continue
}
const gx1 = bbox.x1
const gy1 = bbox.y1
const gx2 = bbox.x2
const gy2 = bbox.y2
const curves = this.buildCurves(path.commands, gx1, gy1)
if (!curves || curves.length === 0) continue
this.fixDegenerateCurves(curves)
const bandsTexelIndex = Math.floor(bandsTexBandOffsets.length / 2)
this.packCurves(curves, curvesTexData)
const sizeX = 1 + (gx2 - gx1)
const sizeY = 1 + (gy2 - gy1)
let bCount = this.bandCount
if (sizeX < bCount || sizeY < bCount) {
bCount = Math.floor(Math.min(sizeX, sizeY) / 2)
if (bCount < 1) bCount = 1
}
const bandDimY = Math.ceil(sizeY / bCount)
const bandDimX = Math.ceil(sizeX / bCount)
// Horizontal bands: sort by max X descending
const hSorted = [...curves].sort((a, b) => Math.max(b.x1, b.x2, b.x3) - Math.max(a.x1, a.x2, a.x3))
let bandMinY = 0
let bandMaxY = bandDimY
for (let b = 0; b < bCount; b++) {
const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2)
let curveCount = 0
for (const c of hSorted) {
if (c.y1 === c.y2 && c.y2 === c.y3) continue
const curveMinY = Math.min(c.y1, c.y2, c.y3)
const curveMaxY = Math.max(c.y1, c.y2, c.y3)
if (curveMinY > bandMaxY || curveMaxY < bandMinY) continue
const curveOffsetX = c.texelIndex % TEXTURE_WIDTH
const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH)
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY)
curveCount++
}
bandsTexBandOffsets.push(curveCount, bandTexelOffset)
bandMinY += bandDimY
bandMaxY += bandDimY
}
// Vertical bands: sort by max Y descending
const vSorted = [...curves].sort((a, b) => Math.max(b.y1, b.y2, b.y3) - Math.max(a.y1, a.y2, a.y3))
let bandMinX = 0
let bandMaxX = bandDimX
for (let b = 0; b < bCount; b++) {
const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2)
let curveCount = 0
for (const c of vSorted) {
if (c.x1 === c.x2 && c.x2 === c.x3) continue
const curveMinX = Math.min(c.x1, c.x2, c.x3)
const curveMaxX = Math.max(c.x1, c.x2, c.x3)
if (curveMinX > bandMaxX || curveMaxX < bandMinX) continue
const curveOffsetX = c.texelIndex % TEXTURE_WIDTH
const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH)
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY)
curveCount++
}
bandsTexBandOffsets.push(curveCount, bandTexelOffset)
bandMinX += bandDimX
bandMaxX += bandDimX
}
codePointsData.push({
codePoint: cp,
width: Math.floor(gx2 - gx1),
height: Math.floor(gy2 - gy1),
advanceWidth: Math.floor(glyph.advanceWidth || 0),
bearingX: Math.floor(gx1),
bearingY: Math.floor(gy1),
bandCount: bCount,
bandDimX,
bandDimY,
bandsTexCoordX: bandsTexelIndex % TEXTURE_WIDTH,
bandsTexCoordY: Math.floor(bandsTexelIndex / TEXTURE_WIDTH)
})
}
// Post-processing: offset curve offsets by band header texel count
const bandHeaderTexels = Math.floor(bandsTexBandOffsets.length / 2)
for (let i = 1; i < bandsTexBandOffsets.length; i += 2) {
bandsTexBandOffsets[i] += bandHeaderTexels
}
return this.buildOutput(codePointsData, curvesTexData, bandsTexBandOffsets, bandsTexCurveOffsets, font)
}
private buildCurves(
commands: PathCommand[],
gx1: number,
gy1: number
): Curve[] | null {
const curves: Curve[] = []
let currentX = 0
let currentY = 0
let firstCurve = false
let startOfShapeX = 0
let startOfShapeY = 0
for (const cmd of commands) {
if (cmd.type === 'M') {
firstCurve = true
currentX = cmd.x - gx1
currentY = cmd.y - gy1
startOfShapeX = currentX
startOfShapeY = currentY
} else if (cmd.type === 'L') {
const nextX = cmd.x - gx1
const nextY = cmd.y - gy1
curves.push({
first: firstCurve,
x1: currentX, y1: currentY,
x2: (currentX + nextX) / 2.0,
y2: (currentY + nextY) / 2.0,
x3: nextX, y3: nextY,
texelIndex: 0
})
firstCurve = false
currentX = nextX
currentY = nextY
} else if (cmd.type === 'Q') {
const nextX = cmd.x - gx1
const nextY = cmd.y - gy1
curves.push({
first: firstCurve,
x1: currentX, y1: currentY,
x2: cmd.x1 - gx1, y2: cmd.y1 - gy1,
x3: nextX, y3: nextY,
texelIndex: 0
})
firstCurve = false
currentX = nextX
currentY = nextY
} else if (cmd.type === 'C') {
return null
} else if (cmd.type === 'Z') {
if (currentX !== startOfShapeX || currentY !== startOfShapeY) {
curves.push({
first: firstCurve,
x1: currentX, y1: currentY,
x2: (currentX + startOfShapeX) / 2.0,
y2: (currentY + startOfShapeY) / 2.0,
x3: startOfShapeX, y3: startOfShapeY,
texelIndex: 0
})
firstCurve = false
}
currentX = startOfShapeX
currentY = startOfShapeY
}
}
return curves
}
private fixDegenerateCurves(curves: Curve[]): void {
for (const c of curves) {
if ((c.x2 === c.x1 && c.y2 === c.y1) || (c.x2 === c.x3 && c.y2 === c.y3)) {
c.x2 = (c.x1 + c.x3) / 2.0
c.y2 = (c.y1 + c.y3) / 2.0
}
}
}
private packCurves(curves: Curve[], curvesTexData: number[]): void {
for (const c of curves) {
if (c.first && curvesTexData.length % 4 !== 0) {
const toAdd = 4 - (curvesTexData.length % 4)
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0)
}
const texelCount = Math.floor(curvesTexData.length / 4)
const col = texelCount % TEXTURE_WIDTH
const newRow = col === TEXTURE_WIDTH - 1
if (newRow) {
const toAdd = 8 - (curvesTexData.length % 4)
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0)
}
if (c.first || newRow) {
c.texelIndex = Math.floor(curvesTexData.length / 4)
curvesTexData.push(c.x1, c.y1)
} else {
c.texelIndex = Math.floor((Math.floor(curvesTexData.length / 2) - 1) / 2)
}
curvesTexData.push(c.x2, c.y2)
curvesTexData.push(c.x3, c.y3)
}
}
private buildOutput(
codePoints: CodePointData[],
curvesList: number[],
bandOffsets: number[],
curveOffsets: number[],
font: Font
): SlugData {
const map = new Map<number, CodePointData>()
codePoints.forEach(cp => map.set(cp.codePoint, cp))
const curvesTexels = Math.ceil(curvesList.length / 4)
const curvesTexHeight = Math.max(1, Math.ceil(curvesTexels / TEXTURE_WIDTH))
const curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4)
curvesFloatArray.fill(-1.0)
curvesFloatArray.set(curvesList)
const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2)
const bandsTexHeight = Math.max(1, Math.ceil(bandsTexels / TEXTURE_WIDTH))
const bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2)
bandsUintArray.set(bandOffsets, 0)
bandsUintArray.set(curveOffsets, bandOffsets.length)
return {
codePoints: map,
curvesData: curvesFloatArray,
bandsData: bandsUintArray,
curvesWidth: TEXTURE_WIDTH,
curvesHeight: curvesTexHeight,
bandsWidth: TEXTURE_WIDTH,
bandsHeight: bandsTexHeight,
ascender: font.ascender || 0,
descender: font.descender || 0,
lineGap: (font as unknown as { lineGap?: number }).lineGap || 0,
unitsPerEm: font.unitsPerEm || 0
}
}
}

View File

@@ -0,0 +1,177 @@
import type { SlugData, CodePointData } from './slug-generator'
export interface TextOptions {
fontScale?: number
startX?: number
startY?: number
lineHeight?: number
justify?: 'left' | 'center' | 'right'
}
export interface TextLayoutResult {
/** per-instance: (sx, sy, cx, cy) float */
scaleBias: Float32Array
/** per-instance: (glyphWidth, glyphHeight, bandScaleX, bandScaleY) float */
glyphBandScale: Float32Array
/** per-instance: (bandMax, bandMax, bandsTexCoordX, bandsTexCoordY) uint32 */
bandMaxTexCoords: Uint32Array
glyphCount: number
}
export function layoutText(
text: string,
slugData: SlugData,
options: TextOptions = {}
): TextLayoutResult {
const ascender = slugData.ascender
const descender = slugData.descender
const lineGap = slugData.lineGap
const defaultLineHeight = slugData.unitsPerEm > 0
? (ascender - descender + lineGap)
: 2000
const {
fontScale = 0.5,
lineHeight = defaultLineHeight * fontScale,
startX = 0,
startY = 0,
justify = 'left'
} = options
const maxGlyphs = countGlyphs(text)
const result: TextLayoutResult = {
scaleBias: new Float32Array(maxGlyphs * 4),
glyphBandScale: new Float32Array(maxGlyphs * 4),
bandMaxTexCoords: new Uint32Array(maxGlyphs * 4),
glyphCount: 0
}
const lines = text.split('\n')
let currentY = startY
for (const line of lines) {
let lineWidth = 0
let j = 0
while (j < line.length) {
const charCode = line.codePointAt(j)!
j += charCode > 0xffff ? 2 : 1
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
if (data) {
lineWidth += data.advanceWidth * fontScale
} else if (line[j - 1] === ' ') {
lineWidth += 600 * fontScale
}
}
let currentX = startX
if (justify === 'center') currentX -= lineWidth / 2.0
else if (justify === 'right') currentX -= lineWidth
let k = 0
while (k < line.length) {
const charCode = line.codePointAt(k)!
k += charCode > 0xffff ? 2 : 1
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
if (data) {
if (data.width > 0 && data.height > 0) {
addGlyph(result, data, currentX, currentY, fontScale)
}
currentX += data.advanceWidth * fontScale
} else if (line[k - 1] === ' ') {
currentX += 600 * fontScale
}
}
currentY -= lineHeight
}
return result
}
export function measureText(
text: string,
slugData: SlugData,
fontScale: number
): number {
let width = 0
let j = 0
while (j < text.length) {
const charCode = text.codePointAt(j)!
j += charCode > 0xffff ? 2 : 1
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
if (data) {
width += data.advanceWidth * fontScale
} else if (text[j - 1] === ' ') {
width += 600 * fontScale
}
}
return width
}
export function mergeLayouts(layouts: TextLayoutResult[]): TextLayoutResult {
const total = layouts.reduce((s, l) => s + l.glyphCount, 0)
const merged: TextLayoutResult = {
scaleBias: new Float32Array(total * 4),
glyphBandScale: new Float32Array(total * 4),
bandMaxTexCoords: new Uint32Array(total * 4),
glyphCount: 0
}
for (const layout of layouts) {
const n = layout.glyphCount
if (n === 0) continue
const off = merged.glyphCount
merged.scaleBias.set(layout.scaleBias.subarray(0, n * 4), off * 4)
merged.glyphBandScale.set(layout.glyphBandScale.subarray(0, n * 4), off * 4)
merged.bandMaxTexCoords.set(layout.bandMaxTexCoords.subarray(0, n * 4), off * 4)
merged.glyphCount += n
}
return merged
}
function countGlyphs(text: string): number {
let count = 0
let j = 0
while (j < text.length) {
const charCode = text.codePointAt(j)!
j += charCode > 0xffff ? 2 : 1
count++
}
return count
}
function addGlyph(
result: TextLayoutResult,
cpData: CodePointData,
x: number,
y: number,
fontScale: number
): void {
const i = result.glyphCount
const quadW = cpData.width * fontScale
const quadH = cpData.height * fontScale
const px = x + cpData.bearingX * fontScale
const py = y + cpData.bearingY * fontScale
const sx = quadW / 2.0
const sy = quadH / 2.0
const cx = px + sx
const cy = py + sy
result.scaleBias[i * 4 + 0] = sx
result.scaleBias[i * 4 + 1] = sy
result.scaleBias[i * 4 + 2] = cx
result.scaleBias[i * 4 + 3] = cy
result.glyphBandScale[i * 4 + 0] = cpData.width
result.glyphBandScale[i * 4 + 1] = cpData.height
result.glyphBandScale[i * 4 + 2] = cpData.width / cpData.bandDimX
result.glyphBandScale[i * 4 + 3] = cpData.height / cpData.bandDimY
result.bandMaxTexCoords[i * 4 + 0] = cpData.bandCount - 1
result.bandMaxTexCoords[i * 4 + 1] = cpData.bandCount - 1
result.bandMaxTexCoords[i * 4 + 2] = cpData.bandsTexCoordX
result.bandMaxTexCoords[i * 4 + 3] = cpData.bandsTexCoordY
result.glyphCount++
}

View File

@@ -0,0 +1,205 @@
import shaderSource from './text.wgsl?raw'
import type { SlugData } from './slug-generator'
import type { TextLayoutResult } from './text-layout'
const TEXT_UNIFORM_FLOATS = 8 // resolution(2) + pad(2) + color(4)
const TEXT_UNIFORM_BYTES = TEXT_UNIFORM_FLOATS * 4
const QUAD_VERTICES = new Float32Array([
-1.0, -1.0,
-1.0, 1.0,
1.0, 1.0,
-1.0, -1.0,
1.0, 1.0,
1.0, -1.0
])
const MAX_GLYPHS = 4096
const INSTANCE_STRIDE = 4 * 4
export class TextRenderer {
private device!: GPUDevice
private pipeline!: GPURenderPipeline
private bindGroup!: GPUBindGroup
private uniformBuffer!: GPUBuffer
private uniformData = new Float32Array(TEXT_UNIFORM_FLOATS)
private quadBuffer!: GPUBuffer
private scaleBiasBuffer!: GPUBuffer
private glyphBandScaleBuffer!: GPUBuffer
private bandMaxTexCoordsBuffer!: GPUBuffer
private curvesTex!: GPUTexture
private bandsTex!: GPUTexture
private glyphCount = 0
private initialized = false
get ready(): boolean {
return this.initialized
}
init(device: GPUDevice, format: GPUTextureFormat, slugData: SlugData): void {
this.device = device
this.createTextures(slugData)
const module = device.createShaderModule({ code: shaderSource })
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'unfilterable-float' } },
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'uint' } }
]
})
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] })
this.pipeline = device.createRenderPipeline({
layout: pipelineLayout,
vertex: {
module,
entryPoint: 'vs_main',
buffers: [
{
arrayStride: 2 * 4,
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
},
{
arrayStride: INSTANCE_STRIDE,
stepMode: 'instance',
attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x4' }]
},
{
arrayStride: INSTANCE_STRIDE,
stepMode: 'instance',
attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x4' }]
},
{
arrayStride: INSTANCE_STRIDE,
stepMode: 'instance',
attributes: [{ shaderLocation: 3, offset: 0, format: 'uint32x4' }]
}
]
},
fragment: {
module,
entryPoint: 'fs_main',
targets: [{
format,
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
}
}]
},
primitive: { topology: 'triangle-list' }
})
this.uniformBuffer = device.createBuffer({
size: TEXT_UNIFORM_BYTES,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
this.quadBuffer = device.createBuffer({
size: QUAD_VERTICES.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
device.queue.writeBuffer(this.quadBuffer, 0, QUAD_VERTICES)
this.scaleBiasBuffer = device.createBuffer({
size: MAX_GLYPHS * INSTANCE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.glyphBandScaleBuffer = device.createBuffer({
size: MAX_GLYPHS * INSTANCE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.bandMaxTexCoordsBuffer = device.createBuffer({
size: MAX_GLYPHS * INSTANCE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniformBuffer } },
{ binding: 1, resource: this.curvesTex.createView() },
{ binding: 2, resource: this.bandsTex.createView() }
]
})
this.initialized = true
}
private createTextures(slugData: SlugData): void {
this.curvesTex = this.device.createTexture({
size: { width: slugData.curvesWidth, height: slugData.curvesHeight },
format: 'rgba32float',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
})
this.device.queue.writeTexture(
{ texture: this.curvesTex },
slugData.curvesData.buffer as BufferSource,
{ bytesPerRow: slugData.curvesWidth * 4 * 4 },
{ width: slugData.curvesWidth, height: slugData.curvesHeight }
)
this.bandsTex = this.device.createTexture({
size: { width: slugData.bandsWidth, height: slugData.bandsHeight },
format: 'rg32uint',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
})
this.device.queue.writeTexture(
{ texture: this.bandsTex },
slugData.bandsData.buffer as BufferSource,
{ bytesPerRow: slugData.bandsWidth * 2 * 4 },
{ width: slugData.bandsWidth, height: slugData.bandsHeight }
)
}
updateText(layout: TextLayoutResult): void {
if (!this.initialized) return
const count = Math.min(layout.glyphCount, MAX_GLYPHS)
this.glyphCount = count
if (count === 0) return
const sb = layout.scaleBias.subarray(0, count * 4)
this.device.queue.writeBuffer(this.scaleBiasBuffer, 0, sb.buffer as BufferSource, sb.byteOffset, sb.byteLength)
const gb = layout.glyphBandScale.subarray(0, count * 4)
this.device.queue.writeBuffer(this.glyphBandScaleBuffer, 0, gb.buffer as BufferSource, gb.byteOffset, gb.byteLength)
const bm = layout.bandMaxTexCoords.subarray(0, count * 4)
this.device.queue.writeBuffer(this.bandMaxTexCoordsBuffer, 0, bm.buffer as BufferSource, bm.byteOffset, bm.byteLength)
}
renderPass(
encoder: GPUCommandEncoder,
canvasView: GPUTextureView,
resolution: Float32Array,
textColor: Float32Array
): void {
if (!this.initialized || this.glyphCount === 0) return
this.uniformData[0] = resolution[0]
this.uniformData[1] = resolution[1]
this.uniformData[4] = textColor[0]
this.uniformData[5] = textColor[1]
this.uniformData[6] = textColor[2]
this.uniformData[7] = textColor[3]
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData)
const pass = encoder.beginRenderPass({
colorAttachments: [
{ view: canvasView, loadOp: 'load', storeOp: 'store' }
]
})
pass.setPipeline(this.pipeline)
pass.setBindGroup(0, this.bindGroup)
pass.setVertexBuffer(0, this.quadBuffer)
pass.setVertexBuffer(1, this.scaleBiasBuffer)
pass.setVertexBuffer(2, this.glyphBandScaleBuffer)
pass.setVertexBuffer(3, this.bandMaxTexCoordsBuffer)
pass.draw(6, this.glyphCount)
pass.end()
}
}

View File

@@ -0,0 +1,159 @@
// Text pass: Slug algorithm ported from JSlug (GLSL3 → WGSL).
// Evaluates TrueType quadratic Bézier curves directly in the fragment
// shader for resolution-independent, anti-aliased text rendering.
// No glyph atlas — curves are packed into a float texture, spatially
// banded into a uint texture, and ray-traced per fragment.
const TEXTURE_WIDTH = 4096u;
const EPSILON = 0.0001;
struct TextUniforms {
resolution: vec2f,
textColor: vec4f,
}
@group(0) @binding(0) var<uniform> u: TextUniforms;
@group(0) @binding(1) var curvesTex: texture_2d<f32>;
@group(0) @binding(2) var bandsTex: texture_2d<u32>;
struct VsIn {
@location(0) position: vec2f,
@location(1) aScaleBias: vec4f,
@location(2) aGlyphBandScale: vec4f,
@location(3) aBandMaxTexCoords: vec4u,
}
struct VsOut {
@builtin(position) pos: vec4f,
@location(0) vTexCoords: vec2f,
@interpolate(flat) @location(1) vGlyphBandScale: vec4f,
@interpolate(flat) @location(2) vBandMaxTexCoords: vec4u,
@interpolate(flat) @location(3) vPixelsPerEm: vec2f,
}
@vertex
fn vs_main(in: VsIn) -> VsOut {
let px = in.position.x * in.aScaleBias.x + in.aScaleBias.z;
let py = in.position.y * in.aScaleBias.y + in.aScaleBias.w;
var out: VsOut;
out.pos = vec4f(
px / u.resolution.x * 2.0 - 1.0,
py / u.resolution.y * 2.0 - 1.0,
0.0, 1.0
);
out.vTexCoords = in.position * 0.5 + vec2f(0.5);
out.vGlyphBandScale = in.aGlyphBandScale;
out.vBandMaxTexCoords = in.aBandMaxTexCoords;
let ppe = vec2f(2.0 * in.aScaleBias.x, 2.0 * in.aScaleBias.y);
out.vPixelsPerEm = clamp(ppe, vec2f(1.0), vec2f(200.0));
return out;
}
// ---------- Slug ray tracer ----------
fn trace_ray_curve_h(p1: vec2f, p2: vec2f, p3: vec2f, pixels_per_em: f32) -> f32 {
if (max(max(p1.x, p2.x), p3.x) * pixels_per_em < -0.5) {
return 0.0;
}
let y_flags = select(0u, 2u, p1.y > 0.0)
+ select(0u, 4u, p2.y > 0.0)
+ select(0u, 8u, p3.y > 0.0);
let code = (0x2E74u >> y_flags) & 3u;
if (code == 0u) {
return 0.0;
}
let a = p1 - p2 * 2.0 + p3;
let b = p1 - p2;
let c = p1.y;
let ayr = 1.0 / a.y;
let disc = max(b.y * b.y - a.y * c, 0.0);
let d = sqrt(disc);
var t1 = (b.y - d) * ayr;
var t2 = (b.y + d) * ayr;
if (abs(a.y) < EPSILON) {
t1 = c / (2.0 * b.y);
t2 = t1;
}
var coverage = 0.0;
if ((code & 1u) != 0u) {
let x1 = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
coverage += clamp(x1 * pixels_per_em + 0.5, 0.0, 1.0);
}
if (code > 1u) {
let x2 = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
coverage -= clamp(x2 * pixels_per_em + 0.5, 0.0, 1.0);
}
return coverage;
}
fn trace_ray_band_h(
band_data: vec2u,
pixels_per_em: f32,
v_tex: vec2f,
glyph_scale: vec2f
) -> f32 {
var coverage = 0.0;
for (var curve = 0u; curve < band_data.x; curve++) {
let curve_offset = band_data.y + curve;
let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy;
let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex);
let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex;
coverage += trace_ray_curve_h(p12.xy, p12.zw, p3.xy, pixels_per_em);
}
return coverage;
}
fn trace_ray_band_v(
band_data: vec2u,
pixels_per_em: f32,
v_tex: vec2f,
glyph_scale: vec2f
) -> f32 {
var coverage = 0.0;
for (var curve = 0u; curve < band_data.x; curve++) {
let curve_offset = band_data.y + curve;
let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy;
let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex);
let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex;
coverage += trace_ray_curve_h(p12.yx, p12.wz, p3.yx, pixels_per_em);
}
return coverage;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4f {
let glyph_scale = in.vGlyphBandScale.xy;
let band_scale = in.vGlyphBandScale.zw;
let band_max = in.vBandMaxTexCoords.xy;
let bands_tex_coords = in.vBandMaxTexCoords.zw;
let v_tex = in.vTexCoords;
let pixels_per_em = in.vPixelsPerEm;
let band_index = clamp(
vec2u(v_tex * band_scale),
vec2u(0u, 0u),
band_max
);
let h_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_index.y;
let h_band_data = textureLoad(bandsTex, vec2u(h_band_offset & 0xFFFu, h_band_offset >> 12u), 0).xy;
let v_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_max.y + 1u + band_index.x;
let v_band_data = textureLoad(bandsTex, vec2u(v_band_offset & 0xFFFu, v_band_offset >> 12u), 0).xy;
var coverage_x = trace_ray_band_h(h_band_data, pixels_per_em.x, v_tex, glyph_scale);
var coverage_y = trace_ray_band_v(v_band_data, pixels_per_em.y, v_tex, glyph_scale);
coverage_x = min(abs(coverage_x), 1.0);
coverage_y = min(abs(coverage_y), 1.0);
let slug_alpha = (coverage_x + coverage_y) * 0.5;
return vec4f(u.textColor.rgb, u.textColor.a * slug_alpha);
}

View File

@@ -0,0 +1,8 @@
import type { UiState } from './renderer'
export interface RendererLike {
readonly ready: boolean
init(canvas: HTMLCanvasElement): Promise<boolean>
render(state: UiState): void
resize(): void
}

16
archive/src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
import { App } from './app'
const canvas = document.getElementById('gpu') as HTMLCanvasElement
const fallback = document.getElementById('fallback') as HTMLElement
async function boot(): Promise<void> {
const app = new App(canvas)
const ok = await app.start()
if (!ok) {
fallback.style.display = 'flex'
canvas.style.display = 'none'
return
}
}
void boot()

146
archive/src/router.ts Normal file
View File

@@ -0,0 +1,146 @@
// Minimal client-side router using the History API.
// Maps are derived dynamically from src/data/pages.ts — add a page there
// and routing wires up automatically.
//
// The nginx config already has try_files → /index.html so server-side
// everything is ready for clean paths like /resume, /projects/graphics, etc.
import { PATH_TO_SCENE, SCENE_TO_PATH } from './data/pages'
// ---------- route types ----------
export interface SimpleRoute {
scene: number
path: string
}
export interface ProjectsGridRoute {
scene: number
view: 'grid'
path: string
}
export interface ProjectsCategoryRoute {
scene: number
view: 'category'
catId: string
path: string
}
export interface ProjectsDetailRoute {
scene: number
view: 'detail'
catId: string
projectId: string
path: string
}
export type Route =
| SimpleRoute
| ProjectsGridRoute
| ProjectsCategoryRoute
| ProjectsDetailRoute
export type RouteChangeHandler = (route: Route) => void
// ---- resolve the projects scene index dynamically ----
const PROJECTS_SCENE = PATH_TO_SCENE.get('/projects')
const PROJECTS_BASE = '/projects'
// ---------- Router ----------
export class Router {
private handler: RouteChangeHandler | null = null
constructor() {
window.addEventListener('popstate', () => {
const route = Router.parse(window.location.pathname)
this.handler?.(route)
})
}
/** Register the single route-change callback. */
onChange(handler: RouteChangeHandler): void {
this.handler = handler
}
/** Parse the current URL and return the initial route. */
start(): Route {
return Router.parse(window.location.pathname)
}
/** Push a new history entry and fire the route change. No-op if same path. */
navigate(path: string): void {
if (window.location.pathname === path) return
history.pushState(null, '', path)
const route = Router.parse(path)
this.handler?.(route)
}
/** Replace the current history entry (used for redirects). */
replace(path: string): void {
history.replaceState(null, '', path)
}
// ---- static helpers ----
/** Parse a pathname into a Route. Unknown paths → home redirect. */
static parse(pathname: string): Route {
const path = pathname === '/' ? '/' : pathname.replace(/\/+$/, '')
const parts = path.split('/').filter(Boolean)
if (parts.length === 0) {
return { scene: 0, path: '/' }
}
const base = '/' + parts[0]
const scene = PATH_TO_SCENE.get(base)
if (scene === undefined) {
return { scene: 0, path: '/' }
}
// Projects sub-routes: /projects[/:catId[/:projectId]]
if (scene === PROJECTS_SCENE && base === PROJECTS_BASE) {
if (parts.length === 1) {
return { scene, view: 'grid', path: PROJECTS_BASE }
}
if (parts.length === 2) {
return { scene, view: 'category', catId: parts[1], path: `/${parts.join('/')}` }
}
return {
scene,
view: 'detail',
catId: parts[1],
projectId: parts[2],
path: `/${parts.join('/')}`,
}
}
return { scene, path: base }
}
/** Build a path string from a route. */
static toPath(route: Route): string {
if (
route.scene === PROJECTS_SCENE &&
'view' in route
) {
if (route.view === 'category' && 'catId' in route && route.catId) {
return `/projects/${route.catId}`
}
if (route.view === 'detail' && 'catId' in route && route.catId &&
'projectId' in route && route.projectId) {
return `/projects/${route.catId}/${route.projectId}`
}
return '/projects'
}
return SCENE_TO_PATH.get(route.scene) ?? '/'
}
/** Scene index → base path (resets any project sub-navigation). */
static sceneToBasePath(scene: number): string {
return SCENE_TO_PATH.get(scene) ?? '/'
}
}

14
archive/src/ui/input.ts Normal file
View File

@@ -0,0 +1,14 @@
// Window-level pointer position capture. Discrete clicks and hover are
// handled by the DOM overlay elements directly; this class only feeds the
// continuous pointer position that the background shader consumes.
export class Input {
x = 0
y = 0
constructor() {
window.addEventListener('pointermove', (e) => {
this.x = e.clientX
this.y = e.clientY
})
}
}

244
archive/src/ui/overlay.ts Normal file
View File

@@ -0,0 +1,244 @@
import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume'
import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index'
import { PROJECTS_SCENE, RESUME_SCENE } from '../data/pages'
interface SceneContent {
title: string
subtitle: string
body: string
}
export interface OverlayCallbacks {
onNavClick: (scene: number) => void
onNavigate: (path: string) => void
}
// ---------- HTML builders ----------
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function buildContact(data: ResumeData): string {
return data.contact
.map((c, i) => {
const sep = i > 0 ? '<span class="sep">·</span>' : ''
if (c.href) return `${sep}<a href="${esc(c.href)}">${esc(c.label)}</a>`
return `${sep}<span>${esc(c.label)}</span>`
})
.join('\n')
}
function buildEntryCard(e: ResumeEntry): string {
const bullets = e.bullets.length
? `<ul>${e.bullets.map((b) => `<li>${b}</li>`).join('')}</ul>`
: ''
const thesis = e.thesis ? `<div class="card-thesis">${esc(e.thesis)}</div>` : ''
return `<div class="resume-card">
<div class="card-date">${esc(e.date)}</div>
<div class="card-role">${esc(e.role)}</div>
<div class="card-company">${esc(e.company)} — ${esc(e.location)}</div>
${thesis}${bullets}
</div>`
}
function buildSkillGroup(g: SkillGroup): string {
const pills = g.skills.map((s) => `<span class="pill">${esc(s)}</span>`).join('')
return `<div class="skills-group">
<h4>${esc(g.label)}</h4>
<div class="skill-pills">${pills}</div>
</div>`
}
function buildResumeHTML(data: ResumeData): string {
const workCards = data.work.map(buildEntryCard).join('')
const eduCards = data.education.map(buildEntryCard).join('')
const skillGroups = data.skills.map(buildSkillGroup).join('')
return `<div class="resume-container">
<header class="resume-header">
<h2>${esc(data.name)}</h2>
<div class="resume-contact">${buildContact(data)}</div>
<div class="resume-location">${esc(data.location)}</div>
</header>
<section class="resume-section">
<h3 class="section-title">Summary</h3>
<p class="summary-text">${esc(data.summary)}</p>
</section>
<section class="resume-section">
<h3 class="section-title">Work Experience</h3>
${workCards}
</section>
<section class="resume-section">
<h3 class="section-title">Education</h3>
${eduCards}
</section>
<section class="resume-section">
<h3 class="section-title">Skills</h3>
${skillGroups}
</section>
</div>`
}
// ---------- project HTML builders ----------
function buildCategoryGridHTML(categories: ProjectCategory[], intro?: string): string {
const cards = categories
.map(
(c) => `<div class="cat-card" data-cat-id="${esc(c.id)}">
<h3 class="cat-label">${esc(c.label)}</h3>
<p class="cat-desc">${esc(c.description)}</p>
<span class="cat-count">${c.projects.length} project${c.projects.length === 1 ? '' : 's'}</span>
</div>`
)
.join('')
const introHtml = intro ? `<p class="projects-intro">${esc(intro)}</p>\n` : ''
return `<div class="projects-container">
${introHtml} <div class="cat-grid">${cards}</div>
</div>`
}
function buildCategoryListHTML(cat: ProjectCategory): string {
if (cat.projects.length === 0) {
return `<div class="cat-list">
<button class="project-back" data-back="grid">← Categories</button>
<p class="cat-intro">${esc(cat.description)}</p>
<p class="cat-empty">Nothing here yet.</p>
</div>`
}
const cards = cat.projects
.map(
(p) => `<div class="project-card" data-project-id="${esc(p.id)}">
<div class="project-date">${esc(p.date)}</div>
<h3 class="project-name">${esc(p.name)}</h3>
<div class="project-tagline">${esc(p.tagline)}</div>
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
</div>`
)
.join('')
return `<div class="cat-list">
<button class="project-back" data-back="grid">← Categories</button>
<p class="cat-intro">${esc(cat.description)}</p>
${cards}
</div>`
}
function buildProjectDetailHTML(p: ProjectPage, catId: string): string {
return `<div class="project-detail">
<button class="project-back" data-back="cat" data-cat-id="${esc(catId)}">← Back</button>
<div class="project-date">${esc(p.date)}</div>
<h2 class="project-name">${esc(p.name)}</h2>
<div class="project-tagline">${esc(p.tagline)}</div>
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
<div class="project-body">${p.content}</div>
</div>`
}
// ---------- overlay ----------
export class DomOverlay {
private readonly navItems: HTMLElement[]
private readonly titleEl: HTMLElement
private readonly subtitleEl: HTMLElement
private readonly bodyEl: HTMLElement
private readonly sceneContentEl: HTMLElement
private readonly projectsSectionEl: HTMLElement
private readonly resumeSectionEl: HTMLElement
private readonly scenes: SceneContent[]
constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) {
this.scenes = scenes
this.navItems = Array.from(document.querySelectorAll<HTMLElement>('[data-nav]'))
this.titleEl = document.getElementById('scene-title') as HTMLElement
this.subtitleEl = document.getElementById('scene-subtitle') as HTMLElement
this.bodyEl = document.getElementById('scene-body') as HTMLElement
this.sceneContentEl = document.getElementById('scene-content') as HTMLElement
this.projectsSectionEl = document.getElementById('projects-section') as HTMLElement
this.resumeSectionEl = document.getElementById('resume-section') as HTMLElement
this.navItems.forEach((item, i) => {
item.addEventListener('click', () => callbacks.onNavClick(i))
})
// delegate clicks inside projects section → onNavigate callback
this.projectsSectionEl.addEventListener('click', (e) => {
const target = e.target as HTMLElement
// category card click
const catCard = target.closest<HTMLElement>('.cat-card')
if (catCard?.dataset.catId) {
callbacks.onNavigate(`/projects/${catCard.dataset.catId}`)
return
}
// project card click
const projCard = target.closest<HTMLElement>('.project-card')
if (projCard?.dataset.projectId) {
const cat = CATEGORIES.find((c) =>
c.projects.some((p) => p.id === projCard.dataset.projectId)
)
if (cat) {
callbacks.onNavigate(`/projects/${cat.id}/${projCard.dataset.projectId}`)
}
return
}
// back buttons
const back = target.closest<HTMLElement>('.project-back')
if (back?.dataset.back === 'grid') {
callbacks.onNavigate('/projects')
return
}
if (back?.dataset.back === 'cat' && back.dataset.catId) {
callbacks.onNavigate(`/projects/${back.dataset.catId}`)
return
}
})
}
showCategoryGrid(): void {
const intro = this.scenes[PROJECTS_SCENE]?.body
this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES, intro)
}
showCategory(id: string): void {
const cat = CATEGORIES.find((c) => c.id === id)
if (!cat) return
this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat)
}
showProjectDetail(projectId: string, catId: string): void {
const p = PROJECTS.find((p) => p.id === projectId)
if (!p) return
this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId)
}
showResume(): void {
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
}
setScene(scene: number): void {
const content = this.scenes[scene]
if (content) {
this.titleEl.textContent = content.title
this.subtitleEl.textContent = content.subtitle
this.bodyEl.innerHTML = content.body
}
// default: show scene-content, hide special sections
this.sceneContentEl.classList.remove('hidden')
this.projectsSectionEl.classList.remove('visible')
this.resumeSectionEl.classList.remove('visible')
if (scene === PROJECTS_SCENE) {
this.sceneContentEl.classList.add('hidden')
this.projectsSectionEl.classList.add('visible')
} else if (scene === RESUME_SCENE) {
this.sceneContentEl.classList.add('hidden')
this.resumeSectionEl.classList.add('visible')
}
this.navItems.forEach((item, i) => {
item.classList.toggle('active', i === scene)
})
}
}