From 07a76333bd9540e01c922665714ce8361c2f643f Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Tue, 19 May 2026 03:22:22 -0700 Subject: [PATCH] Refactor build process in package.json to clean up CSS files before building, enhance particle background implementation with new tree structure and color settings, and add new styles for tree call-to-action components in SASS files. --- frontend/src/lib/particle-background.ts | 497 +- package.json | 2 +- spec.md | 4 +- themes/openfreebooks/sass/_components.scss | 39 + themes/openfreebooks/static/js/bundle.js | 2 +- .../static/js/particle-background-BIfD0E0p.js | 4235 +++++++++++++++++ .../static/js/particle-background-Bs9rbJB8.js | 4235 +++++++++++++++++ .../static/js/particle-background-BvCC-uK-.js | 4210 ++++++++++++++++ .../static/js/particle-background-C1I-pKlL.js | 4235 +++++++++++++++++ .../static/js/particle-background-CNFSCvn8.js | 4235 +++++++++++++++++ .../static/js/particle-background-CgsT97LC.js | 4235 +++++++++++++++++ .../static/js/particle-background-DE20wQLx.js | 4235 +++++++++++++++++ .../static/js/particle-background-FPd0A9Hn.js | 4235 +++++++++++++++++ .../static/js/particle-background-NU84UE0i.js | 4235 +++++++++++++++++ .../static/js/particle-background-jF071Y02.js | 4235 +++++++++++++++++ themes/openfreebooks/templates/index.html | 12 + 16 files changed, 42854 insertions(+), 27 deletions(-) create mode 100644 themes/openfreebooks/static/js/particle-background-BIfD0E0p.js create mode 100644 themes/openfreebooks/static/js/particle-background-Bs9rbJB8.js create mode 100644 themes/openfreebooks/static/js/particle-background-BvCC-uK-.js create mode 100644 themes/openfreebooks/static/js/particle-background-C1I-pKlL.js create mode 100644 themes/openfreebooks/static/js/particle-background-CNFSCvn8.js create mode 100644 themes/openfreebooks/static/js/particle-background-CgsT97LC.js create mode 100644 themes/openfreebooks/static/js/particle-background-DE20wQLx.js create mode 100644 themes/openfreebooks/static/js/particle-background-FPd0A9Hn.js create mode 100644 themes/openfreebooks/static/js/particle-background-NU84UE0i.js create mode 100644 themes/openfreebooks/static/js/particle-background-jF071Y02.js diff --git a/frontend/src/lib/particle-background.ts b/frontend/src/lib/particle-background.ts index 09bb80f..54fe30b 100644 --- a/frontend/src/lib/particle-background.ts +++ b/frontend/src/lib/particle-background.ts @@ -17,12 +17,12 @@ import { } from "three"; import { resolveTheme, type ThemeChoice } from "../theme"; -const DESKTOP_COUNT = 60000; -const MOBILE_COUNT = 20000; -const SHAPE_COUNT = 4; +const DESKTOP_COUNT = 150000; +const MOBILE_COUNT = 45000; const MORPH_MAX = 4; -const MORPH_LOOP_EPS = 0.001; const SHAPE_SCALE = 0.8; +const GROUND_Y = -4; +const BRANCH_MIN_Y = -1.25; type ShapeColors = [number, number, number]; @@ -31,6 +31,8 @@ type ThemePalette = { sphere: ShapeColors; cube: ShapeColors; helix: ShapeColors; + treeTrunk: ShapeColors; + treeFoliage: ShapeColors; clear: string; glow: number; particleAlpha: number; @@ -44,6 +46,8 @@ const LIGHT_PALETTE: ThemePalette = { sphere: [0.58, 0.34, 0.02], cube: [0.03, 0.34, 0.16], helix: [0.34, 0.44, 0.06], + treeTrunk: [0.4, 0.26, 0.08], + treeFoliage: [0.06, 0.36, 0.18], clear: "#f5f4f1", glow: 0.72, particleAlpha: 1, @@ -57,6 +61,8 @@ const DARK_PALETTE: ThemePalette = { sphere: [0.85, 0.58, 0.22], cube: [0.28, 0.78, 0.46], helix: [0.62, 0.82, 0.34], + treeTrunk: [0.62, 0.42, 0.16], + treeFoliage: [0.18, 0.62, 0.32], clear: "#1a1917", glow: 0.82, particleAlpha: 0.78, @@ -156,6 +162,388 @@ function generateHelix(count: number, positions: Float32Array, colors: Float32Ar scalePositions(positions, SHAPE_SCALE); } +function setColorExact(arr: Float32Array, index: number, r: number, g: number, b: number) { + arr[index * 3] = r; + arr[index * 3 + 1] = g; + arr[index * 3 + 2] = b; +} + +type TreeBranch = { + sx: number; + sy: number; + sz: number; + cx: number; + cy: number; + cz: number; + ex: number; + ey: number; + ez: number; + thickStart: number; + thickEnd: number; + isTerminal: boolean; +}; + +type RootSegment = { + sx: number; + sz: number; + ex: number; + ez: number; + bend: number; + thickStart: number; + thickEnd: number; + depth: number; +}; + +function evalBezier( + sx: number, + sy: number, + sz: number, + cx: number, + cy: number, + cz: number, + ex: number, + ey: number, + ez: number, + t: number +) { + const omt = 1 - t; + return { + x: omt * omt * sx + 2 * omt * t * cx + t * t * ex, + y: omt * omt * sy + 2 * omt * t * cy + t * t * ey, + z: omt * omt * sz + 2 * omt * t * cz + t * t * ez, + }; +} + +function trunkSurface(h: number, theta: number) { + const y = GROUND_Y + h * 4.5; + const baseR = 0.8; + const flare = Math.exp(-h * 15) * 2; + const taper = 1 - h * 0.6; + let radius = (baseR + flare) * taper; + const ridges = Math.abs(Math.sin(theta * 8)) * 0.12 + Math.sin(theta * 15) * 0.05; + radius += ridges * (1 - Math.min(1, flare * 0.5)); + const bendX = Math.sin(y * 0.6) * 0.3; + const bendZ = Math.cos(y * 0.4) * 0.3; + return { + x: bendX + radius * Math.cos(theta), + y, + z: bendZ + radius * Math.sin(theta), + radius, + bendX, + bendZ, + }; +} + +function depositTrunkParticle( + positions: Float32Array, + colors: Float32Array, + isFlower: Float32Array, + index: number, + h: number, + theta: number, + palette: ThemePalette, + radialJitter = 1 +) { + const surface = trunkSurface(h, theta); + const jitter = (Math.random() - 0.5) * 0.08 * radialJitter; + positions[index * 3] = surface.x + Math.cos(theta) * jitter; + positions[index * 3 + 1] = surface.y + (Math.random() - 0.5) * 0.04; + positions[index * 3 + 2] = surface.z + Math.sin(theta) * jitter; + setColor(colors, index, palette.treeTrunk, 0.06); + isFlower[index] = 0; +} + +function generateDetailedTree( + count: number, + positions: Float32Array, + colors: Float32Array, + isFlower: Float32Array, + palette: ThemePalette +) { + const landCount = Math.floor(count * (25000 / 150000)); + const rootCount = Math.floor(count * (15000 / 150000)); + const trunkCount = Math.floor(count * (3000 / 150000)); + const branchParticleCount = Math.floor(count * (55000 / 150000)); + const junctionBudget = Math.floor(count * (4000 / 150000)); + const flowerPlotCount = Math.max(120, Math.floor(count * (1000 / 150000))); + + let pIdx = 0; + + for (let i = 0; i < landCount && pIdx < count; i += 1, pIdx += 1) { + const r = 10 * Math.sqrt(Math.random()); + const theta = Math.random() * Math.PI * 2; + positions[pIdx * 3] = r * Math.cos(theta); + positions[pIdx * 3 + 1] = GROUND_Y; + positions[pIdx * 3 + 2] = r * Math.sin(theta); + setColor(colors, pIdx, palette.treeFoliage, 0.03); + isFlower[pIdx] = 0; + } + + const rootSegments: RootSegment[] = []; + + function addRootSegment( + sx: number, + sz: number, + angle: number, + length: number, + thickStart: number, + depth: number, + maxDepth: number + ) { + const curve = (Math.random() - 0.5) * length * (0.18 + depth * 0.04); + const ex = sx + Math.cos(angle) * length; + const ez = sz + Math.sin(angle) * length; + const thickEnd = Math.max(0.018, thickStart * (0.42 + Math.random() * 0.15)); + rootSegments.push({ sx, sz, ex, ez, bend: curve, thickStart, thickEnd, depth }); + + if (depth >= maxDepth) return; + + const childCount = depth === 1 ? 2 + Math.floor(Math.random() * 2) : 1 + Math.floor(Math.random() * 2); + for (let child = 0; child < childCount; child += 1) { + const spawnT = 0.42 + Math.random() * 0.46; + const mx = sx + (ex - sx) * spawnT; + const mz = sz + (ez - sz) * spawnT; + const childAngle = angle + (child - (childCount - 1) / 2) * (0.38 + Math.random() * 0.42); + addRootSegment( + mx, + mz, + childAngle, + length * (0.45 + Math.random() * 0.34), + thickStart * (1 - spawnT) + thickEnd * spawnT, + depth + 1, + maxDepth + ); + } + } + + const primaryRoots = 26; + for (let i = 0; i < primaryRoots; i += 1) { + const angle = (i / primaryRoots) * Math.PI * 2 + (Math.random() - 0.5) * 0.16; + const rootFlare = 0.28 + Math.random() * 0.28; + addRootSegment( + Math.cos(angle) * rootFlare, + Math.sin(angle) * rootFlare, + angle, + 4.8 + Math.random() * 3.8, + 0.42 + Math.random() * 0.24, + 1, + 3 + Math.floor(Math.random() * 2) + ); + } + + for (let i = 0; i < rootCount && pIdx < count; i += 1, pIdx += 1) { + const segment = rootSegments[Math.floor(Math.random() * rootSegments.length)]; + const t = Math.pow(Math.random(), 0.82); + const omt = 1 - t; + const midX = (segment.sx + segment.ex) * 0.5 + Math.cos(Math.atan2(segment.ez - segment.sz, segment.ex - segment.sx) + Math.PI / 2) * segment.bend; + const midZ = (segment.sz + segment.ez) * 0.5 + Math.sin(Math.atan2(segment.ez - segment.sz, segment.ex - segment.sx) + Math.PI / 2) * segment.bend; + const centerX = omt * omt * segment.sx + 2 * omt * t * midX + t * t * segment.ex; + const centerZ = omt * omt * segment.sz + 2 * omt * t * midZ + t * t * segment.ez; + const tangentX = 2 * omt * (midX - segment.sx) + 2 * t * (segment.ex - midX); + const tangentZ = 2 * omt * (midZ - segment.sz) + 2 * t * (segment.ez - midZ); + const tangentLen = Math.sqrt(tangentX * tangentX + tangentZ * tangentZ) || 1; + const normalX = -tangentZ / tangentLen; + const normalZ = tangentX / tangentLen; + const thick = (segment.thickStart * (1 - t) + segment.thickEnd * t) * (1 - Math.random() * 0.35); + const spread = (Math.random() - 0.5) * thick; + + positions[pIdx * 3] = centerX + normalX * spread; + positions[pIdx * 3 + 1] = GROUND_Y + 0.012; + positions[pIdx * 3 + 2] = centerZ + normalZ * spread; + setColor(colors, pIdx, palette.treeTrunk, 0.05); + isFlower[pIdx] = 0; + } + + for (let i = 0; i < flowerPlotCount && pIdx < count - 10; i += 1) { + const r = 2.5 + Math.random() * 7; + const theta = Math.random() * Math.PI * 2; + const fx = r * Math.cos(theta); + const fz = r * Math.sin(theta); + const fh = 0.35 + Math.random() * 0.55; + const magenta = Math.random() > 0.5; + const stemSegments = 8; + + for (let j = 0; j < stemSegments; j += 1) { + positions[pIdx * 3] = fx; + positions[pIdx * 3 + 1] = GROUND_Y + (j / stemSegments) * fh; + positions[pIdx * 3 + 2] = fz; + setColor(colors, pIdx, palette.treeFoliage, 0.03); + isFlower[pIdx] = 0; + pIdx += 1; + } + + positions[pIdx * 3] = fx; + positions[pIdx * 3 + 1] = GROUND_Y + fh; + positions[pIdx * 3 + 2] = fz; + if (magenta) { + setColorExact(colors, pIdx, 1, 0.2, 0.8); + } else { + setColorExact(colors, pIdx, 1, 0.9, 0.2); + } + isFlower[pIdx] = 1; + pIdx += 1; + } + + for (let i = 0; i < trunkCount && pIdx < count; i += 1, pIdx += 1) { + const h = Math.random(); + const theta = Math.random() * Math.PI * 2; + depositTrunkParticle(positions, colors, isFlower, pIdx, h, theta, palette); + } + + const branches: TreeBranch[] = []; + const terminalBranches: TreeBranch[] = []; + const junctions: Array<{ h: number; theta: number; thick: number }> = []; + + function generateBranch( + sx: number, + sy: number, + sz: number, + dirX: number, + dirY: number, + dirZ: number, + length: number, + thickStart: number, + depth: number, + maxDepth: number + ) { + const isTerminal = depth >= maxDepth; + const droop = Math.max(0, depth - 1) * 0.06; + const ex = sx + dirX * length; + const ey = Math.max(sy + (dirY - droop) * length, BRANCH_MIN_Y + depth * 0.08); + const ez = sz + dirZ * length; + const cx = sx + dirX * length * 0.45; + const cy = Math.max(sy + dirY * length * 0.45 + 0.5 / depth, Math.max(sy, ey) + 0.12); + const cz = sz + dirZ * length * 0.45; + const thickEnd = Math.max(0.04, thickStart * 0.42); + const branch: TreeBranch = { + sx, + sy, + sz, + cx, + cy, + cz, + ex, + ey, + ez, + thickStart, + thickEnd, + isTerminal, + }; + branches.push(branch); + if (isTerminal) terminalBranches.push(branch); + + if (!isTerminal) { + const numChildren = depth === 1 ? 2 + Math.floor(Math.random() * 2) : 1 + Math.floor(Math.random() * 3); + for (let c = 0; c < numChildren; c += 1) { + const spawnT = 0.35 + Math.random() * 0.55; + const pos = evalBezier(sx, sy, sz, cx, cy, cz, ex, ey, ez, spawnT); + const omt = 1 - spawnT; + let tanX = 2 * omt * (cx - sx) + 2 * spawnT * (ex - cx); + let tanY = 2 * omt * (cy - sy) + 2 * spawnT * (ey - cy); + let tanZ = 2 * omt * (cz - sz) + 2 * spawnT * (ez - cz); + const tanLen = Math.sqrt(tanX * tanX + tanY * tanY + tanZ * tanZ) || 1; + tanX /= tanLen; + tanY /= tanLen; + tanZ /= tanLen; + const spread = 0.55 + depth * 0.25; + let cDirX = tanX + (Math.random() - 0.5) * spread; + let cDirY = tanY + (Math.random() - 0.5) * spread; + let cDirZ = tanZ + (Math.random() - 0.5) * spread; + cDirY = Math.max(cDirY, depth <= 2 ? 0.18 : -0.05); + const cLen = Math.sqrt(cDirX * cDirX + cDirY * cDirY + cDirZ * cDirZ); + cDirX /= cLen; + cDirY /= cLen; + cDirZ /= cLen; + const cLength = length * (0.62 + Math.random() * 0.28); + const cThick = thickStart * (1 - spawnT) + thickEnd * spawnT; + generateBranch(pos.x, pos.y, pos.z, cDirX, cDirY, cDirZ, cLength, cThick * 0.88, depth + 1, maxDepth); + } + } + } + + const numMainBranches = 12 + Math.floor(Math.random() * 6); + for (let i = 0; i < numMainBranches; i += 1) { + const theta = (i / numMainBranches) * Math.PI * 2 + (Math.random() - 0.5) * 0.35; + const h = 0.62 + Math.random() * 0.34; + const surface = trunkSurface(h, theta); + const normalX = Math.cos(theta); + const normalZ = Math.sin(theta); + const embeddedDepth = Math.min(surface.radius * 0.5, 0.38); + const sx = surface.x - normalX * embeddedDepth; + const sy = surface.y; + const sz = surface.z - normalZ * embeddedDepth; + + let dirX = normalX * 1.15; + let dirY = 0.78 + Math.random() * 0.5; + let dirZ = normalZ * 1.15; + const dirLen = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ) || 1; + dirX /= dirLen; + dirY /= dirLen; + dirZ /= dirLen; + + const length = 2.0 + Math.random() * 2.1; + const thickStart = Math.max(0.2, surface.radius * 0.9); + junctions.push({ h, theta, thick: thickStart }); + const targetMaxDepth = 2 + Math.floor(Math.random() * 3); + generateBranch(sx, sy, sz, dirX, dirY, dirZ, length, thickStart, 1, targetMaxDepth); + } + + const junctionParticles = Math.max(8, Math.floor(junctionBudget / Math.max(junctions.length, 1))); + for (const junction of junctions) { + for (let j = 0; j < junctionParticles && pIdx < count; j += 1, pIdx += 1) { + const h = junction.h + (Math.random() - 0.5) * 0.055; + const theta = junction.theta + (Math.random() - 0.5) * 0.72; + const surface = trunkSurface(h, theta); + const normalX = Math.cos(junction.theta); + const normalZ = Math.sin(junction.theta); + const collar = (Math.random() - 0.5) * junction.thick; + const bridge = Math.random() * Math.min(surface.radius * 0.85, 0.75); + positions[pIdx * 3] = surface.x - normalX * bridge + Math.cos(theta) * collar * 0.45; + positions[pIdx * 3 + 1] = surface.y + (Math.random() - 0.5) * junction.thick * 0.45; + positions[pIdx * 3 + 2] = surface.z - normalZ * bridge + Math.sin(theta) * collar * 0.45; + setColor(colors, pIdx, palette.treeTrunk, 0.06); + isFlower[pIdx] = 0; + } + } + + const branchOrange: ShapeColors = palette.helix; + const branchOrangeTip: ShapeColors = palette.sphere; + + for (let i = 0; i < branchParticleCount && pIdx < count; i += 1, pIdx += 1) { + const b = branches[Math.floor(Math.random() * branches.length)]; + const t = + Math.random() < 0.45 + ? Math.pow(Math.random(), 2.2) * 0.35 + : 0.15 + Math.random() * 0.85; + const pos = evalBezier(b.sx, b.sy, b.sz, b.cx, b.cy, b.cz, b.ex, b.ey, b.ez, t); + const thick = b.thickStart * (1 - t) + b.thickEnd * t; + const ringTheta = Math.random() * Math.PI * 2; + positions[pIdx * 3] = pos.x + thick * Math.cos(ringTheta); + positions[pIdx * 3 + 1] = pos.y + thick * Math.sin(ringTheta) * 0.35; + positions[pIdx * 3 + 2] = pos.z + thick * Math.sin(ringTheta); + setColor(colors, pIdx, b.isTerminal ? branchOrangeTip : branchOrange, 0.08); + isFlower[pIdx] = 0; + } + + while (pIdx < count) { + if (terminalBranches.length === 0) break; + const b = terminalBranches[Math.floor(Math.random() * terminalBranches.length)]; + const t = Math.pow(Math.random(), 0.6); + const pos = evalBezier(b.sx, b.sy, b.sz, b.cx, b.cy, b.cz, b.ex, b.ey, b.ez, t); + const angle = Math.random() * Math.PI * 2; + const spread = Math.random() * 1.8; + const droop = spread * (0.15 + Math.random() * 0.15) + Math.random() * 0.1; + positions[pIdx * 3] = pos.x + Math.cos(angle) * spread; + positions[pIdx * 3 + 1] = pos.y - droop; + positions[pIdx * 3 + 2] = pos.z + Math.sin(angle) * spread; + setColor(colors, pIdx, palette.treeFoliage, 0.1); + isFlower[pIdx] = 0; + pIdx += 1; + } + + scalePositions(positions, SHAPE_SCALE); +} + function fillShapeColors( geometry: BufferGeometry, palette: ThemePalette, @@ -188,6 +576,9 @@ const vertexShader = ` attribute vec3 col2; attribute vec3 pos3; attribute vec3 col3; + attribute vec3 pos4; + attribute vec3 col4; + attribute float aIsFlower; uniform float uMorph; uniform float uTime; @@ -201,7 +592,7 @@ const vertexShader = ` varying float vAlpha; void main() { - float m = mod(uMorph, ${SHAPE_COUNT}.0); + float m = clamp(uMorph, 0.0, ${MORPH_MAX}.0); float t = fract(m); float ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0; @@ -212,14 +603,37 @@ const vertexShader = ` finalPos = mix(pos0, pos1, ease); finalCol = mix(col0, col1, ease); } else if (m < 2.0) { + t = m - 1.0; + ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0; finalPos = mix(pos1, pos2, ease); finalCol = mix(col1, col2, ease); } else if (m < 3.0) { + t = m - 2.0; + ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0; finalPos = mix(pos2, pos3, ease); finalCol = mix(col2, col3, ease); } else { - finalPos = mix(pos3, pos0, ease); - finalCol = mix(col3, col0, ease); + t = clamp(m - 3.0, 0.0, 1.0); + ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0; + finalPos = mix(pos3, pos4, ease); + finalCol = mix(col3, col4, ease); + } + + float treeInfluence = 0.0; + if (m > 3.0) treeInfluence = clamp(m - 3.0, 0.0, 1.0); + + if (treeInfluence > 0.0) { + if (finalPos.y > -2.0) { + float swayInfluence = clamp((finalPos.y + 2.0) / 8.0, 0.0, 1.0) * treeInfluence; + finalPos.x += sin(uTime * 1.2 + finalPos.y) * 0.25 * swayInfluence; + finalPos.z += cos(uTime * 1.5 + finalPos.x) * 0.25 * swayInfluence; + } + + if (aIsFlower > 0.5) { + float blink = 0.5 + 0.5 * sin(uTime * 5.0 + pos4.x * 12.0 + pos4.z * 10.0); + finalPos.y += blink * 0.35 * treeInfluence; + finalCol += finalCol * blink * 2.5 * treeInfluence; + } } float sphereInfluence = max(0.0, 1.0 - abs(m - 1.0)); @@ -244,9 +658,16 @@ const vertexShader = ` } } - finalPos.y += sin(finalPos.x * 2.0 + uTime * 2.0) * 0.1; - finalPos.x += cos(finalPos.y * 2.0 + uTime * 2.0) * 0.1; - finalPos.z += sin(finalPos.x * 1.5 + finalPos.y * 1.5 + uTime * 1.6) * 0.06; + float idleWobble = 1.0; + if (m > 3.0 && finalPos.y < -3.0) { + idleWobble = 0.0; + } else if (m > 3.0) { + idleWobble = 1.0 - treeInfluence; + } + + finalPos.y += sin(finalPos.x * 2.0 + uTime * 2.0) * 0.1 * idleWobble; + finalPos.x += cos(finalPos.y * 2.0 + uTime * 2.0) * 0.1 * idleWobble; + finalPos.z += sin(finalPos.x * 1.5 + finalPos.y * 1.5 + uTime * 1.6) * 0.06 * idleWobble; vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0); gl_PointSize = (13.0 * uPixelRatio * uPointScale) * (1.0 / -mvPosition.z); @@ -275,19 +696,20 @@ const fragmentShader = ` function clampMorph(morph: number) { if (morph <= 0) return 0; - if (morph >= MORPH_MAX - MORPH_LOOP_EPS) return MORPH_MAX - MORPH_LOOP_EPS; + if (morph >= MORPH_MAX) return MORPH_MAX; return morph; } +function getTreeHold(morph: number) { + const m = clampMorph(morph); + if (m < 3.5) return 0; + return Math.min(1, (m - 3.5) / 0.5); +} + function getLayersHold(morph: number) { - const clamped = clampMorph(morph); - if (clamped <= MORPH_LOOP_EPS || clamped >= MORPH_MAX - MORPH_LOOP_EPS) { - return 1; - } - const m = clamped % SHAPE_COUNT; - if (m < 0.5) return 1 - m / 0.5; - if (m > 3.5) return (SHAPE_COUNT - m) / 0.5; - return 0; + const m = clampMorph(morph); + if (m >= 0.5) return 0; + return 1 - m / 0.5; } export function initParticleBackground(container: HTMLElement): () => void { @@ -323,11 +745,15 @@ export function initParticleBackground(container: HTMLElement): () => void { const col2 = new Float32Array(particleCount * 3); const pos3 = new Float32Array(particleCount * 3); const col3 = new Float32Array(particleCount * 3); + const pos4 = new Float32Array(particleCount * 3); + const col4 = new Float32Array(particleCount * 3); + const isFlower = new Float32Array(particleCount); generateLayers(particleCount, pos0, col0, palette.layers); generateSphere(particleCount, pos1, col1, palette.sphere); generateCube(particleCount, pos2, col2, palette.cube); generateHelix(particleCount, pos3, col3, palette.helix); + generateDetailedTree(particleCount, pos4, col4, isFlower, palette); geometry.setAttribute("pos0", new BufferAttribute(pos0, 3)); geometry.setAttribute("col0", new BufferAttribute(col0, 3)); @@ -337,6 +763,9 @@ export function initParticleBackground(container: HTMLElement): () => void { geometry.setAttribute("col2", new BufferAttribute(col2, 3)); geometry.setAttribute("pos3", new BufferAttribute(pos3, 3)); geometry.setAttribute("col3", new BufferAttribute(col3, 3)); + geometry.setAttribute("pos4", new BufferAttribute(pos4, 3)); + geometry.setAttribute("col4", new BufferAttribute(col4, 3)); + geometry.setAttribute("aIsFlower", new BufferAttribute(isFlower, 1)); geometry.setAttribute("position", new BufferAttribute(pos0, 3)); const material = new ShaderMaterial({ @@ -359,6 +788,7 @@ export function initParticleBackground(container: HTMLElement): () => void { }); const points = new Points(geometry, material); + points.position.y = -0.65 * SHAPE_SCALE; scene.add(points); let targetMorph = 0; @@ -377,6 +807,10 @@ export function initParticleBackground(container: HTMLElement): () => void { function applyThemePalette(theme: ThemeChoice) { const nextPalette = getPalette(theme); fillShapeColors(geometry, nextPalette, particleCount); + generateDetailedTree(particleCount, pos4, col4, isFlower, nextPalette); + (geometry.getAttribute("col4") as BufferAttribute).needsUpdate = true; + (geometry.getAttribute("pos4") as BufferAttribute).needsUpdate = true; + (geometry.getAttribute("aIsFlower") as BufferAttribute).needsUpdate = true; material.uniforms.uGlow.value = nextPalette.glow; material.uniforms.uParticleAlpha.value = nextPalette.particleAlpha; material.uniforms.uColorBoost.value = nextPalette.colorBoost; @@ -388,9 +822,21 @@ export function initParticleBackground(container: HTMLElement): () => void { applyThemePalette(resolveTheme()); + function getScrollProgress() { + const scrollHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ); + const maxScroll = Math.max(0, scrollHeight - window.innerHeight); + if (maxScroll <= 0) return 0; + const scrollY = Math.min(Math.max(0, window.scrollY), maxScroll); + const progress = scrollY / maxScroll; + // Lock morph at the end so elastic overscroll / sub-pixel gaps do not flicker. + return progress >= 0.992 ? 1 : progress; + } + function onScroll() { - const maxScroll = document.body.scrollHeight - window.innerHeight; - targetMorph = maxScroll > 0 ? clampMorph((window.scrollY / maxScroll) * MORPH_MAX) : 0; + targetMorph = clampMorph(getScrollProgress() * MORPH_MAX); } function onMouseMove(event: MouseEvent) { @@ -405,6 +851,7 @@ export function initParticleBackground(container: HTMLElement): () => void { renderer.setSize(window.innerWidth, window.innerHeight); material.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2); renderer.setClearColor(getPaperColor(), 1); + onScroll(); } const themeObserver = new MutationObserver(() => { @@ -444,11 +891,13 @@ export function initParticleBackground(container: HTMLElement): () => void { material.uniforms.uMorph.value = currentMorph; const layersHold = getLayersHold(currentMorph); + const treeHold = getTreeHold(currentMorph); const spinWeight = 1 - layersHold; + const treeSettle = smoothstep(treeHold); if (layersHold < 0.99) { - spinY += delta * 0.1; - spinX += delta * 0.05; + spinY += delta * (0.1 + 0.05 * treeSettle); + spinX += delta * 0.05 * (1 - treeSettle); } else { spinY = 0; spinX = 0; @@ -456,7 +905,7 @@ export function initParticleBackground(container: HTMLElement): () => void { const easedSpin = smoothstep(spinWeight); points.rotation.y = spinY * easedSpin; - points.rotation.x = spinX * easedSpin; + points.rotation.x = spinX * easedSpin * (1 - treeSettle); if (mouseEnabled) { raycaster.setFromCamera(mouseNdc, camera); diff --git a/package.json b/package.json index 0b11423..d5814f8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build:js": "vite build", - "build": "bun run build:js && zola build", + "build": "rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build", "dev:js": "vite build --watch", "scan:size": "bun scripts/scan-output-size.ts" }, diff --git a/spec.md b/spec.md index 7ba0039..cea5fb9 100644 --- a/spec.md +++ b/spec.md @@ -10,4 +10,6 @@ not markdown, html based. but in every page, we should have a copy as md button we don't i18n. i18n will be solved in future by big companies. -search is supported by pagefind. \ No newline at end of file +search is supported by pagefind. + +we have a catalog of subjects. Each subject will have chapters. Each chapters can be labelled (multiple) cirriculum. E.g. DSE, IB, A level. so that user knows if a chapter is needed in a cirriculum. Each subject should show all chapters by default (all cirriculum). chapters should be sequential in a tree structure. users can filter by themselves. \ No newline at end of file diff --git a/themes/openfreebooks/sass/_components.scss b/themes/openfreebooks/sass/_components.scss index 49954f9..2152ef9 100644 --- a/themes/openfreebooks/sass/_components.scss +++ b/themes/openfreebooks/sass/_components.scss @@ -345,6 +345,45 @@ h3 { margin-bottom: 12px; } +.tree-cta { + min-height: clamp(680px, 92vh, 980px); + display: flex; + align-items: center; + text-align: center; + padding: clamp(180px, 24vh, 280px) 0 clamp(40px, 12vh, 280px); +} + +.tree-cta__inner { + max-width: 820px; + margin-inline: auto; +} + +.tree-cta__title { + max-width: 11ch; + margin-inline: auto; + margin: 0 0 24px; + font-size: clamp(3rem, 8vw, 7.5rem); + line-height: 0.92; + letter-spacing: -0.04em; +} + +.tree-cta__lead { + max-width: 520px; + margin: 0 auto; + color: var(--soft-ink); + font-size: clamp(1.1rem, 2vw, 1.45rem); + line-height: 1.6; +} + +.tree-cta__actions { + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: center; + align-items: center; + margin-top: 34px; +} + .marquee-shell { margin: 0; padding: 0; diff --git a/themes/openfreebooks/static/js/bundle.js b/themes/openfreebooks/static/js/bundle.js index bb91db4..d4ec4f3 100644 --- a/themes/openfreebooks/static/js/bundle.js +++ b/themes/openfreebooks/static/js/bundle.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/quadratic-explorer-BEDvXl1S.js","js/katex-Dc8nsIP1.js","js/render-math-H0D_R-AC.js","js/katex.css"])))=>i.map(i=>d[i]); -const Ae="modulepreload",ke=function(e){return"/"+e},ee={},Q=function(t,n,s){let r=Promise.resolve();if(n&&n.length>0){let l=function(u){return Promise.all(u.map(f=>Promise.resolve(f).then(a=>({status:"fulfilled",value:a}),a=>({status:"rejected",reason:a}))))};document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),c=(o==null?void 0:o.nonce)||(o==null?void 0:o.getAttribute("nonce"));r=l(n.map(u=>{if(u=ke(u),u in ee)return;ee[u]=!0;const f=u.endsWith(".css"),a=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${a}`))return;const m=document.createElement("link");if(m.rel=f?"stylesheet":Ae,f||(m.as="script"),m.crossOrigin="",m.href=u,c&&m.setAttribute("nonce",c),document.head.appendChild(m),f)return new Promise((b,v)=>{m.addEventListener("load",b),m.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${u}`)))})}))}function i(l){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=l,window.dispatchEvent(o),!o.defaultPrevented)throw l}return r.then(l=>{for(const o of l||[])o.status==="rejected"&&i(o.reason);return t().catch(i)})};const Ce=(e,t)=>e===t,$e=Symbol("solid-track"),j={equals:Ce};let fe=me;const L=1,V=2,de={owned:null,cleanups:null,context:null,owner:null};var g=null;let J=null,_e=null,h=null,w=null,$=null,Y=0;function N(e,t){const n=h,s=g,r=e.length===0,i=t===void 0?s:t,l=r?de:{owned:null,cleanups:null,context:i?i.context:null,owner:i},o=r?e:()=>e(()=>x(()=>P(l)));g=l,h=null;try{return U(o,!0)}finally{h=n,g=s}}function G(e,t){t=t?Object.assign({},j,t):j;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},s=r=>(typeof r=="function"&&(r=r(n.value)),ge(n,r));return[he.bind(n),s]}function B(e,t,n){const s=z(e,t,!1,L);q(s)}function Te(e,t,n){fe=Be;const s=z(e,t,!1,L);s.user=!0,$?$.push(s):q(s)}function R(e,t,n){n=n?Object.assign({},j,n):j;const s=z(e,t,!0,0);return s.observers=null,s.observerSlots=null,s.comparator=n.equals||void 0,q(s),he.bind(s)}function x(e){if(h===null)return e();const t=h;h=null;try{return e()}finally{h=t}}function W(e){Te(()=>x(e))}function Z(e){return g===null||(g.cleanups===null?g.cleanups=[e]:g.cleanups.push(e)),e}function he(){if(this.sources&&this.state)if(this.state===L)q(this);else{const e=w;w=null,U(()=>F(this),!1),w=e}if(h){const e=this.observers;if(!e||e[e.length-1]!==h){const t=e?e.length:0;h.sources?(h.sources.push(this),h.sourceSlots.push(t)):(h.sources=[this],h.sourceSlots=[t]),e?(e.push(h),this.observerSlots.push(h.sources.length-1)):(this.observers=[h],this.observerSlots=[h.sources.length-1])}}return this.value}function ge(e,t,n){let s=e.value;return(!e.comparator||!e.comparator(s,t))&&(e.value=t,e.observers&&e.observers.length&&U(()=>{for(let r=0;r1e6)throw w=[],new Error},!1)),t}function q(e){if(!e.fn)return;P(e);const t=Y;xe(e,e.value,t)}function xe(e,t,n){let s;const r=g,i=h;h=g=e;try{s=e.fn(t)}catch(l){return e.pure&&(e.state=L,e.owned&&e.owned.forEach(P),e.owned=null),e.updatedAt=n+1,pe(l)}finally{h=i,g=r}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?ge(e,s):e.value=s,e.updatedAt=n)}function z(e,t,n,s=L,r){const i={fn:e,state:s,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:g,context:g?g.context:null,pure:n};return g===null||g!==de&&(g.owned?g.owned.push(i):g.owned=[i]),i}function H(e){if(e.state===0)return;if(e.state===V)return F(e);if(e.suspense&&x(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;n--)if(e=t[n],e.state===L)q(e);else if(e.state===V){const s=w;w=null,U(()=>F(e,t[0]),!1),w=s}}function U(e,t){if(w)return e();let n=!1;t||(w=[]),$?n=!0:$=[],Y++;try{const s=e();return Le(n),s}catch(s){n||($=null),w=null,pe(s)}}function Le(e){if(w&&(me(w),w=null),e)return;const t=$;$=null,t.length&&U(()=>fe(t),!1)}function me(e){for(let t=0;t=0;t--)P(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)P(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function Ie(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function pe(e,t=g){throw Ie(e)}const Oe=Symbol("fallback");function te(e){for(let t=0;t1?[]:null;return Z(()=>te(i)),()=>{let c=e()||[],u=c.length,f,a;return c[$e],x(()=>{let b,v,k,d,p,y,E,S,C;if(u===0)l!==0&&(te(i),i=[],s=[],r=[],l=0,o&&(o=[])),n.fallback&&(s=[Oe],r[0]=N(Se=>(i[0]=Se,n.fallback())),l=1);else if(l===0){for(r=new Array(u),a=0;a=y&&S>=y&&s[E]===c[S];E--,S--)k[S]=r[E],d[S]=i[E],o&&(p[S]=o[E]);for(b=new Map,v=new Array(S+1),a=S;a>=y;a--)C=c[a],f=b.get(C),v[a]=f===void 0?-1:f,b.set(C,a);for(f=y;f<=E;f++)C=s[f],a=b.get(C),a!==void 0&&a!==-1?(k[a]=r[f],d[a]=i[f],o&&(p[a]=o[f]),a=v[a],b.set(C,a)):i[f]();for(a=y;ae(t||{}))}const Pe=e=>`Stale read from <${e}>.`;function ne(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(De(()=>e.each,e.children,t||void 0))}function qe(e){const t=e.keyed,n=R(()=>e.when,void 0,void 0),s=t?n:R(n,void 0,{equals:(r,i)=>!r==!i});return R(()=>{const r=s();if(r){const i=e.children;return typeof i=="function"&&i.length>0?x(()=>i(t?r:()=>{if(!x(s))throw Pe("Show");return n()})):i}return e.fallback},void 0,void 0)}function Ue(e,t,n){let s=n.length,r=t.length,i=s,l=0,o=0,c=t[r-1].nextSibling,u=null;for(;lf-o){const v=t[l];for(;o{r=i,t===document?e():A(t,e(),t.firstChild?null:void 0,n)},s.owner),()=>{r(),t.textContent=""}}function I(e,t,n,s){let r;const i=()=>{const o=document.createElement("template");return o.innerHTML=e,o.content.firstChild},l=()=>(r||(r=i())).cloneNode(!0);return l.cloneNode=l,l}function we(e,t=window.document){const n=t[se]||(t[se]=new Set);for(let s=0,r=e.length;se(t,n))}function A(e,t,n,s){if(n!==void 0&&!s&&(s=[]),typeof t!="function")return K(e,t,s,n);B(r=>K(e,t(),r,n),s)}function re(e,t,n){const s=t.trim().split(/\s+/);for(let r=0,i=s.length;rObject.defineProperty(e,"target",{configurable:!0,value:c}),l=()=>{const c=t[n];if(c&&!t.disabled){const u=t[`${n}Data`];if(u!==void 0?c.call(t,u,e):c.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},o=()=>{for(;l()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const c=e.composedPath();i(c[0]);for(let u=0;u{let o=t();for(;typeof o=="function";)o=o();n=K(e,o,n,s)}),()=>n;if(Array.isArray(t)){const o=[],c=n&&Array.isArray(n);if(X(o,t,n,r))return B(()=>n=K(e,o,n,s,!0)),()=>n;if(o.length===0){if(n=O(e,n,s),l)return n}else c?n.length===0?oe(e,o,s):Ue(e,n,o):(n&&O(e),oe(e,o));n=o}else if(t.nodeType){if(Array.isArray(n)){if(l)return n=O(e,n,s,t);O(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function X(e,t,n,s){let r=!1;for(let i=0,l=t.length;i=0;l--){const o=t[l];if(r!==o){const c=o.parentNode===e;!i&&!l?c?e.replaceChild(r,o):e.insertBefore(r,n):c&&o.remove()}else i=!0}}else e.insertBefore(r,n);return[r]}const ve="openfreebooks-theme";function Re(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function je(){const e=localStorage.getItem(ve);return e==="light"||e==="dark"?e:null}function ye(){return je()??Re()}function Ee(e){document.documentElement.setAttribute("data-theme",e)}function Ve(e){localStorage.setItem(ve,e),Ee(e)}function Ge(){Ee(ye())}var He=I("