diff --git a/docs/animation-architecture.md b/docs/animation-architecture.md index df431e5..26549cb 100644 --- a/docs/animation-architecture.md +++ b/docs/animation-architecture.md @@ -1,56 +1,80 @@ -# Calcifer graph hero +# Reference-matched fire demon hero ## Rendering The section is statically rendered by Astro. The heading, description, actions and technology labels are present in the first HTML response. The animated canvas is decorative and does not contain SEO-critical content. -The graph is borderless and covers the full hero section. The initial desktop camera places the demon on the right, while the canvas and pointer coordinate system continue across the whole section so a dragged node can stretch the graph far beyond its resting silhouette. +The graph is borderless and covers the full hero section. The initial desktop camera places the demon on the right, while the canvas and pointer coordinate system continue across the whole section so a dragged node can stretch the graph through the complete hero field. The graph uses two Three.js draw calls: -- `THREE.Points` with a custom shader for every body, face and spark node; -- `THREE.LineSegments` for every connection. +- `THREE.Points` with a custom shader for all body, contour, face and detached fire nodes; +- `THREE.LineSegments` for all graph connections. -No post-processing pass is used. Soft glow is calculated inside the point fragment shader, which avoids an additional framebuffer and reduces GPU cost on laptops and mobile devices. +No post-processing pass is used. The point fragment shader renders a hard luminous core, a softer inner core and a role-controlled halo. This keeps the glow expressive without adding an extra framebuffer or bloom pipeline. -## Shape generation +## Reference-derived silhouette -The silhouette is generated deterministically from a seeded pseudo-random function. The body combines a broad rounded base, shoulders and five tapered flame tongues. The tall central tongue and raised side arms define the reference pose. +The main body no longer depends only on primitive ellipses. A compact 72 × 72 density field was derived from the supplied warm reference and stored as numeric rows in `referenceMask.ts`. The runtime samples that field with bilinear interpolation. -The face is generated as explicit graph structures: +The density field controls: -- two large luminous eyes with outer rings, inner rings and dense fill nodes; -- dark pupil rings that preserve clear black centers; -- a wide open mouth with separate upper and lower contours plus red interior particles. +- the accepted body positions; +- higher concentration around the face and lower core; +- sparse crown tips and side flames; +- the wide rounded lower body; +- the raised side-arm silhouette; +- contour detection through local density gradients. -Body points are excluded from the eye and mouth voids so the expression remains readable at every quality level. Larger hub nodes are distributed through the body to reproduce the visual hierarchy of an Obsidian graph. +The generator adds separate graph layers on top of the mask: -Ambient fire uses two spark systems: +- dense body particles; +- a stronger contour/rim layer; +- explicit raised arm chains with surrounding micro-particles; +- two large eye structures with outer rings, inner rings, bright fill and compact black pupils; +- a wide open mouth with upper and lower contours, a dark red interior and a brighter lower tongue arc; +- large hub nodes distributed among much smaller particles; +- detached starburst clusters and paired drifting embers. -- radial starburst clusters with a larger hub and connected satellites; -- paired embers distributed around the silhouette. +Body points are excluded from eye and mouth voids so the expression remains readable. Every node is connected, but detached spark groups are allowed to remain separate visual components. -Every generated node has at least one connection. The main body uses local nearest-neighbour edges, facial structures use stronger internal springs and selected face nodes attach back to the flame body. +## Fire colour and scale -## Physics +Colour is calculated from height, distance from the hot core and local mask density. The palette moves through: + +1. cream and yellow in the dense lower core; +2. amber and orange; +3. red and crimson; +4. magenta and violet at cooler outer tips; +5. occasional blue on the highest or most distant particles. + +Node diameters vary by role and probability. The graph combines many micro-nodes with sparse large hubs, large black pupil nodes, bright eye particles and medium starburst centres. + +## Physics and idle combustion Every node stores its current position, velocity and immutable target position. Each frame applies: 1. spring forces along graph edges; -2. a target-anchor force that preserves the silhouette; +2. a target-anchor force preserving the reference silhouette; 3. velocity damping; -4. role-aware idle convection; -5. rendering-time flame and spark drift. +4. role-aware lateral turbulence and upward convection; +5. rendering-time thermal waves, flicker and size pulses. + +Flame, rim and arm nodes use layered slow, medium and fast waves. Starburst and ember nodes have wider orbital and upward drift. Eye, pupil and mouth nodes stay comparatively stable so the face does not dissolve. -Flame and arm nodes move with layered slow and fast waves. Spark nodes use a wider, softer movement range. Eyes, pupils and mouth nodes stay comparatively stable so the expression does not dissolve. +While a node is dragged, target anchors are weakened for connected nodes up to seven graph steps away. Influence decays by graph distance. This permits large full-section deformation while still allowing the demon to recover after release. -While a node is dragged, its target anchor is weakened for connected nodes up to five graph steps away. Influence decays by graph distance. The selected node follows the pointer directly, while its neighbours follow through the spring network. After release, target anchors restore the original silhouette. +## Torch code reveal -## Torch reveal +Twelve narrow code columns cover the full hero in two DOM layers. Text is deliberately very small and dense. -Six columns of small code cover the entire hero background in two DOM layers. The ambient layer remains barely visible across the section. The lit layer sits behind the WebGL graph and uses a radial CSS mask controlled by custom properties. +- The ambient layer remains almost invisible across the section. +- The lit layer sits behind the WebGL canvas. +- A radial CSS mask follows the dragged node and pointer. +- Pointer speed increases mask radius, code opacity and the warm background haze. +- Releasing the node fades the code and haze back into darkness. -Dragging a node updates the mask position, radius and opacity. Pointer speed increases both brightness and illuminated area, producing a torch-like reveal without rendering code into WebGL. Releasing the node fades the lit code back into darkness. +The code remains real DOM text rather than being painted into WebGL, preserving rendering simplicity and keeping it visually behind the graph. ## Loading and fallbacks @@ -59,7 +83,7 @@ The first render displays an inline SVG graph generated from the same determinis The implementation also: - caps device pixel ratio; -- reduces the source node count for coarse pointers and small viewports; +- uses adaptive source density for mobile, tablet and desktop; - uses a responsive off-centre camera on desktop and a lower camera composition on narrow screens; - pauses animation when the document is hidden; - disposes GPU resources on disconnect; diff --git a/src/components/CalciferGraph.astro b/src/components/CalciferGraph.astro index 9e4d4ed..7bcfb86 100644 --- a/src/components/CalciferGraph.astro +++ b/src/components/CalciferGraph.astro @@ -5,7 +5,7 @@ const fallbackGraph = createCalciferGraph({ nodeCount: 180, seed: 20260710 }) const toColor = (color: readonly [number, number, number]): string => `rgb(${Math.round(color[0] * 255)} ${Math.round(color[1] * 255)} ${Math.round(color[2] * 255)})` -const codeColumns = [ +const codeSources = [ `type AgentTask = { scope: "frontend" evidence: Verification[] @@ -16,7 +16,6 @@ const workflow = async (task: AgentTask) => { const context = await inspectProject(task.scope) const plan = createSmallestSafePlan(context) const result = await executeApprovedScope(plan) - return verifyWithEvidence(result) } @@ -24,12 +23,7 @@ export const guardrails = { broadRewrites: false, unapprovedPackages: false, fakeVerification: false, -} - -const evidence = await verifyRoute({ - viewport: 375, - command: "npm run check", -})`, +}`, `interface ProjectContext { framework: "astro" | "react" | "next" styling: "css-modules" | "scoped-css" @@ -41,12 +35,7 @@ export const inspectProject = async () => { const files = await readRelevantFiles() const patterns = inferExistingPatterns(files) const risks = identifyChangeRisks(patterns) - - return { - files, - patterns, - risks, - } + return { files, patterns, risks } } const scope = selectMinimumFiles({ @@ -115,7 +104,7 @@ return { verified: review.isSafe, }`, `const release = await prepareRelease({ - branch: "feat/calcifer-firefield-refinement", + branch: "feat/reference-matched-fire-demon", target: "main", checks: [ "types", @@ -133,6 +122,8 @@ const pullRequest = await openPullRequest({ export default pullRequest`, ] + +const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % codeSources.length]) --- @@ -146,13 +137,13 @@ export default pullRequest`, - {fallbackGraph.edges.slice(0, 1400).map((edge) => { + {fallbackGraph.edges.slice(0, 2200).map((edge) => { const source = fallbackGraph.nodes[edge.source] const target = fallbackGraph.nodes[edge.target] @@ -167,8 +158,8 @@ export default pullRequest`, x2={target.x} y2={target.y} stroke={toColor(source.color)} - stroke-opacity={source.role === 'pupil' ? '0.04' : '0.28'} - stroke-width="0.0048" + stroke-opacity={source.role === 'pupil' ? '0.035' : '0.28'} + stroke-width="0.0042" /> ) })} @@ -176,9 +167,9 @@ export default pullRequest`, ))} @@ -186,8 +177,8 @@ export default pullRequest`, - The graph is decorative. Drag a visible node to stretch the connected fire demon and reveal the - code hidden in the darkness behind it. + The graph is decorative. Drag a visible node to stretch the connected fire demon across the section + and reveal the code hidden in the darkness behind it. @@ -216,9 +207,9 @@ export default pullRequest`, } if (typeof idleWindow.requestIdleCallback === 'function') { - this.idleId = idleWindow.requestIdleCallback(() => void this.load(), { timeout: 1400 }) + this.idleId = idleWindow.requestIdleCallback(() => void this.load(), { timeout: 1250 }) } else { - this.timeoutId = globalThis.setTimeout(() => void this.load(), 760) + this.timeoutId = globalThis.setTimeout(() => void this.load(), 680) } } @@ -272,8 +263,9 @@ export default pullRequest`, .graph { --torch-x: 66%; --torch-y: 50%; - --torch-radius: 220px; + --torch-radius: 190px; --torch-intensity: 0; + --torch-warmth: 0; position: relative; display: block; width: 100%; @@ -296,17 +288,25 @@ export default pullRequest`, .graph::before { background: - radial-gradient(circle at 67% 54%, rgba(255, 80, 15, 0.16), transparent 24%), - radial-gradient(circle at 69% 45%, rgba(255, 185, 65, 0.075), transparent 35%), - radial-gradient(circle at 82% 22%, rgba(129, 24, 7, 0.11), transparent 23%); - filter: blur(38px); + radial-gradient(circle at 68% 54%, rgba(255, 78, 12, 0.18), transparent 22%), + radial-gradient(circle at 70% 44%, rgba(255, 188, 60, 0.075), transparent 34%), + radial-gradient(circle at 82% 18%, rgba(114, 16, 80, 0.09), transparent 24%); + filter: blur(42px); opacity: 0.92; } .graph::after { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.42'/%3E%3C/svg%3E"); - mix-blend-mode: soft-light; - opacity: 0.1; + background: + radial-gradient( + circle var(--torch-radius) at var(--torch-x) var(--torch-y), + rgba(255, 190, 85, calc(var(--torch-intensity) * 0.15)), + rgba(255, 72, 15, calc(var(--torch-intensity) * 0.075)) 42%, + transparent 78% + ), + url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.42'/%3E%3C/svg%3E"); + mix-blend-mode: screen, soft-light; + opacity: calc(0.08 + var(--torch-warmth) * 0.28); + transition: opacity 420ms ease; } .canvas, @@ -328,12 +328,12 @@ export default pullRequest`, .fallback { z-index: 3; box-sizing: border-box; - padding: clamp(52px, 6vw, 104px); + padding: clamp(28px, 4.5vw, 78px); filter: - drop-shadow(0 0 12px rgba(255, 70, 10, 0.34)) - drop-shadow(0 0 42px rgba(255, 96, 18, 0.18)); + drop-shadow(0 0 12px rgba(255, 66, 8, 0.38)) + drop-shadow(0 0 48px rgba(255, 88, 16, 0.2)); opacity: 1; - transform: translateX(clamp(110px, 17vw, 310px)) scale(0.92); + transform: translateX(clamp(96px, 17vw, 310px)) scale(0.96); transition: opacity 460ms ease; } @@ -341,14 +341,14 @@ export default pullRequest`, z-index: 1; display: grid; box-sizing: border-box; - grid-template-columns: repeat(6, minmax(150px, 1fr)); - gap: clamp(28px, 4.2vw, 78px); - padding: 3vh 2.5vw; + grid-template-columns: repeat(12, minmax(92px, 1fr)); + gap: clamp(14px, 1.9vw, 34px); + padding: 1.4vh 1.1vw; overflow: hidden; - color: rgba(255, 214, 151, 0.64); + color: rgba(255, 220, 168, 0.72); font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; pointer-events: none; - transform: rotate(-1.35deg) scale(1.07); + transform: rotate(-0.65deg) scale(1.035); transform-origin: center; } @@ -356,32 +356,33 @@ export default pullRequest`, min-width: 0; margin: 0; font: inherit; - font-size: clamp(7px, 0.52vw, 9px); - line-height: 1.82; - letter-spacing: -0.025em; + font-size: clamp(5px, 0.37vw, 7px); + line-height: 1.68; + letter-spacing: -0.035em; white-space: pre-wrap; word-break: break-word; } .code-field--ambient { - opacity: 0.032; - filter: blur(0.25px); + opacity: 0.019; + filter: blur(0.22px); } .code-field--lit { z-index: 2; opacity: var(--torch-intensity); filter: - drop-shadow(0 0 8px rgba(255, 129, 37, 0.28)) - blur(0.08px); + drop-shadow(0 0 7px rgba(255, 142, 48, 0.38)) + drop-shadow(0 0 20px rgba(255, 72, 14, 0.16)); mask-image: radial-gradient( circle var(--torch-radius) at var(--torch-x) var(--torch-y), #000 0, - rgba(0, 0, 0, 0.98) 26%, - rgba(0, 0, 0, 0.58) 61%, + rgba(0, 0, 0, 0.98) 24%, + rgba(0, 0, 0, 0.62) 58%, + rgba(0, 0, 0, 0.18) 78%, transparent 100% ); - transition: opacity 520ms ease; + transition: opacity 460ms ease; } .graph[data-ready='true'] .canvas { @@ -399,42 +400,44 @@ export default pullRequest`, @media (max-width: 1040px) { .fallback { - padding: clamp(42px, 7vw, 82px); - transform: translateY(6%) scale(0.92); + padding: 170px 32px 26px; + transform: translateY(180px) scale(0.92); } .code-field { - grid-template-columns: repeat(4, minmax(150px, 1fr)); - gap: 34px; + grid-template-columns: repeat(8, minmax(90px, 1fr)); + gap: 18px; + padding: 1.5vh 1.4vw; } } @media (max-width: 620px) { .graph { - min-height: 620px; + min-height: 1120px; } .fallback { - padding: 48px 8px 12px; - transform: translateY(14%) scale(0.96); + padding: 570px 12px 32px; + transform: translateY(116px) scale(1.03); } .code-field { - grid-template-columns: repeat(3, minmax(128px, 1fr)); - gap: 22px; - padding: 16px; + grid-template-columns: repeat(6, minmax(74px, 1fr)); + gap: 12px; + padding: 8px; } .code-field pre { - font-size: 6.5px; - line-height: 1.75; + font-size: 5px; + line-height: 1.58; } } @media (prefers-reduced-motion: reduce) { .canvas, .fallback, - .code-field--lit { + .code-field, + .graph::after { transition: none; } } diff --git a/src/scripts/graph/generateGraph.ts b/src/scripts/graph/generateGraph.ts index e36d759..123ba0e 100644 --- a/src/scripts/graph/generateGraph.ts +++ b/src/scripts/graph/generateGraph.ts @@ -1,4 +1,5 @@ import { createRandom } from './random' +import { REFERENCE_MASK_BOUNDS, sampleReferenceDensity } from './referenceMask' import type { CalciferGraphData, GraphEdgeSeed, @@ -39,81 +40,14 @@ const pointInsideEllipse = ( ): boolean => Math.pow((x - centerX) / radiusX, 2) + Math.pow((y - centerY) / radiusY, 2) <= 1 -const pointInsideTongue = ( - x: number, - y: number, - centerX: number, - baseY: number, - height: number, - width: number, - lean: number, -): boolean => { - const progress = (y - baseY) / height - - if (progress < 0 || progress > 1) { - return false - } - - const center = centerX + lean * progress - const taper = Math.pow(1 - progress, 0.68) - const ripple = 0.86 + Math.sin(progress * Math.PI * 2.4 + centerX * 9) * 0.14 - - return Math.abs(x - center) <= width * taper * ripple -} - -const pointInsideSilhouette = (x: number, y: number): boolean => { - const body = pointInsideEllipse(x, y, 0, -0.16, 0.88, 0.72) - const base = pointInsideEllipse(x, y, 0, -0.62, 0.72, 0.32) - const leftShoulder = pointInsideEllipse(x, y, -0.68, -0.16, 0.29, 0.38) - const rightShoulder = pointInsideEllipse(x, y, 0.68, -0.16, 0.29, 0.38) - const center = pointInsideTongue(x, y, 0.02, 0.18, 1.12, 0.3, 0.08) - const leftInner = pointInsideTongue(x, y, -0.31, 0.18, 0.78, 0.24, -0.09) - const rightInner = pointInsideTongue(x, y, 0.34, 0.16, 0.76, 0.23, 0.12) - const leftOuter = pointInsideTongue(x, y, -0.58, 0.02, 0.58, 0.19, -0.13) - const rightOuter = pointInsideTongue(x, y, 0.61, 0.02, 0.6, 0.19, 0.12) - - return ( - body || - base || - leftShoulder || - rightShoulder || - center || - leftInner || - rightInner || - leftOuter || - rightOuter - ) -} - const pointInsideFaceVoid = (x: number, y: number): boolean => { - const leftEye = pointInsideEllipse(x, y, -0.28, -0.17, 0.22, 0.2) - const rightEye = pointInsideEllipse(x, y, 0.28, -0.17, 0.22, 0.2) - const mouth = pointInsideEllipse(x, y, 0, -0.49, 0.39, 0.24) + const leftEye = pointInsideEllipse(x, y, -0.29, -0.25, 0.235, 0.215) + const rightEye = pointInsideEllipse(x, y, 0.29, -0.25, 0.235, 0.215) + const mouth = pointInsideEllipse(x, y, 0, -0.61, 0.43, 0.255) return leftEye || rightEye || mouth } -const getFlameColor = (x: number, y: number): Rgb => { - const radial = clamp(Math.hypot(x / 0.98, (y + 0.25) / 1.32), 0, 1) - const height = clamp((y + 0.05) / 1.25, 0, 1) - const edge = clamp(Math.abs(x) / 0.94, 0, 1) - const heat = clamp(radial * 0.38 + height * 0.36 + edge * 0.28, 0, 1) - const cream: Rgb = [1, 0.86, 0.56] - const amber: Rgb = [1, 0.49, 0.08] - const orange: Rgb = [1, 0.19, 0.025] - const red: Rgb = [0.68, 0.025, 0.012] - - if (heat < 0.33) { - return mixColor(cream, amber, heat / 0.33) - } - - if (heat < 0.7) { - return mixColor(amber, orange, (heat - 0.33) / 0.37) - } - - return mixColor(orange, red, (heat - 0.7) / 0.3) -} - const quadraticPoint = (start: Point, control: Point, end: Point, progress: number): Point => { const inverse = 1 - progress @@ -129,6 +63,74 @@ const quadraticPoint = (start: Point, control: Point, end: Point, progress: numb } } +const cubicPoint = ( + start: Point, + controlA: Point, + controlB: Point, + end: Point, + progress: number, +): Point => { + const inverse = 1 - progress + + return { + x: + inverse * inverse * inverse * start.x + + 3 * inverse * inverse * progress * controlA.x + + 3 * inverse * progress * progress * controlB.x + + progress * progress * progress * end.x, + y: + inverse * inverse * inverse * start.y + + 3 * inverse * inverse * progress * controlA.y + + 3 * inverse * progress * progress * controlB.y + + progress * progress * progress * end.y, + } +} + +const getFireColor = (x: number, y: number, density: number, variation: number): Rgb => { + const vertical = clamp((y - REFERENCE_MASK_BOUNDS.minY) / 2.56, 0, 1) + const radial = clamp(Math.hypot(x / 1.24, (y + 0.28) / 1.52), 0, 1) + const coolness = clamp(vertical * 0.62 + radial * 0.3 + (1 - density) * 0.16, 0, 1) + const cream: Rgb = [1, 0.9, 0.48] + const amber: Rgb = [1, 0.53, 0.045] + const orange: Rgb = [1, 0.19, 0.018] + const crimson: Rgb = [0.93, 0.015, 0.08] + const magenta: Rgb = [0.82, 0.025, 0.48] + const violet: Rgb = [0.43, 0.08, 0.82] + const blue: Rgb = [0.05, 0.34, 0.95] + let color: Rgb + + if (coolness < 0.2) { + color = mixColor(cream, amber, coolness / 0.2) + } else if (coolness < 0.43) { + color = mixColor(amber, orange, (coolness - 0.2) / 0.23) + } else if (coolness < 0.65) { + color = mixColor(orange, crimson, (coolness - 0.43) / 0.22) + } else if (coolness < 0.82) { + color = mixColor(crimson, magenta, (coolness - 0.65) / 0.17) + } else if (coolness < 0.94) { + color = mixColor(magenta, violet, (coolness - 0.82) / 0.12) + } else { + color = mixColor(violet, blue, (coolness - 0.94) / 0.06) + } + + return mixColor(color, cream, variation * 0.08 * density) +} + +const getMaskEdge = (x: number, y: number): number => { + const center = sampleReferenceDensity(x, y) + const offset = 0.042 + const neighbours = [ + sampleReferenceDensity(x - offset, y), + sampleReferenceDensity(x + offset, y), + sampleReferenceDensity(x, y - offset), + sampleReferenceDensity(x, y + offset), + ] + const minimum = Math.min(...neighbours) + const maximum = Math.max(...neighbours) + + return clamp(Math.max(center - minimum, maximum - minimum), 0, 1) +} + const addNode = ( nodes: GraphNodeSeed[], random: () => number, @@ -241,6 +243,7 @@ const connectNearestNeighbours = ( .filter((target) => target !== source) .map((target) => { const targetNode = nodes[target] + return { target, distance: targetNode @@ -258,265 +261,207 @@ const connectNearestNeighbours = ( } } -export const createCalciferGraph = ({ - nodeCount, - seed = 20260710, -}: CreateGraphOptions): CalciferGraphData => { - const random = createRandom(seed) - const nodes: GraphNodeSeed[] = [] - const edges: GraphEdgeSeed[] = [] - const edgeKeys = new Set() - const flameNodeIds: number[] = [] - const desiredFlameCount = Math.max(120, Math.floor(nodeCount * 0.78)) - let attempts = 0 +const connectLoop = ( + nodes: GraphNodeSeed[], + edges: GraphEdgeSeed[], + keys: Set, + nodeIds: number[], + stiffness: number, +): void => { + for (let index = 0; index < nodeIds.length; index += 1) { + const current = nodeIds[index] + const next = nodeIds[(index + 1) % nodeIds.length] - while (flameNodeIds.length < desiredFlameCount && attempts < desiredFlameCount * 120) { - attempts += 1 - const x = mix(-0.99, 0.99, random()) - const y = mix(-0.92, 1.31, random()) + if (current !== undefined && next !== undefined) { + addEdge(edges, keys, nodes, current, next, stiffness) + } + } +} - if (!pointInsideSilhouette(x, y) || pointInsideFaceVoid(x, y)) { - continue +const connectChain = ( + nodes: GraphNodeSeed[], + edges: GraphEdgeSeed[], + keys: Set, + nodeIds: number[], + stiffness: number, +): void => { + for (let index = 1; index < nodeIds.length; index += 1) { + const previous = nodeIds[index - 1] + const current = nodeIds[index] + + if (previous !== undefined && current !== undefined) { + addEdge(edges, keys, nodes, previous, current, stiffness) } + } +} - const centerBias = 1 - clamp(Math.hypot(x / 0.93, (y + 0.18) / 1.28), 0, 1) - const hub = random() < 0.105 - const size = hub - ? mix(7.2, 11.2, random()) - : mix(2.25, 5.15, random() * 0.72 + centerBias * 0.28) +const addEye = ( + nodes: GraphNodeSeed[], + edges: GraphEdgeSeed[], + edgeKeys: Set, + random: () => number, + centerX: number, +): { eyeIds: number[]; pupilIds: number[] } => { + const centerY = -0.25 + const eyeIds: number[] = [] + const pupilIds: number[] = [] + const outerRing: number[] = [] + const innerRing: number[] = [] + + for (let index = 0; index < 42; index += 1) { + const angle = (index / 42) * Math.PI * 2 + const wobble = 1 + Math.sin(angle * 5 + centerX * 8) * 0.025 + const point = { + x: centerX + Math.cos(angle) * 0.205 * wobble, + y: centerY + Math.sin(angle) * 0.184 * wobble, + } const id = addNode( nodes, random, - 'flame', - { x, y }, - size, - getFlameColor(x, y), - mix(5.2, 9.4, 1 - centerBias), - mix(-0.04, 0.05, random()), + 'eye', + point, + mix(3.2, 7.6, random()), + mixColor([1, 0.72, 0.18], [1, 0.99, 0.9], 0.62 + random() * 0.38), + 17, + 0.16, ) - flameNodeIds.push(id) + outerRing.push(id) + eyeIds.push(id) } - const armNodeIds: number[] = [] - - for (const side of [-1, 1]) { - const mainArm: number[] = [] - const start = { x: side * 0.68, y: -0.08 } - const control = { x: side * 1.02, y: 0.04 } - const end = { x: side * 1.18, y: 0.55 } - - for (let index = 0; index < 16; index += 1) { - const progress = index / 15 - const point = quadraticPoint(start, control, end, progress) - point.x += Math.sin(progress * Math.PI * 2.2) * 0.025 * side - point.y += Math.sin(progress * Math.PI) * 0.035 - const id = addNode( - nodes, - random, - 'arm', - point, - mix(4.2, 8.2, 0.35 + random() * 0.65), - mixColor([1, 0.42, 0.055], [0.78, 0.035, 0.014], progress), - 7.8, - 0.02, - ) - mainArm.push(id) - armNodeIds.push(id) - - if (index > 0) { - addEdge(edges, edgeKeys, nodes, mainArm[index - 1] ?? id, id, 17) - } - } - - for (let index = 0; index < 24; index += 1) { - const progress = random() - const center = quadraticPoint(start, control, end, progress) - const spread = mix(0.035, 0.12, Math.sin(progress * Math.PI)) - const point = { - x: center.x + mix(-spread, spread, random()), - y: center.y + mix(-spread, spread, random()), - } - const id = addNode( - nodes, - random, - 'arm', - point, - mix(2.4, 5.1, random()), - mixColor([1, 0.34, 0.035], [0.76, 0.025, 0.012], progress), - 7.2, - mix(-0.015, 0.04, random()), - ) - armNodeIds.push(id) - const mainTarget = mainArm[Math.min(mainArm.length - 1, Math.round(progress * 15))] - - if (mainTarget !== undefined) { - addEdge(edges, edgeKeys, nodes, id, mainTarget, 14) - } - } - - const root = mainArm[0] - const rootNode = root === undefined ? undefined : nodes[root] - - if (root !== undefined && rootNode) { - const target = nearestNode(nodes, rootNode, new Set(['flame']), root) - addEdge(edges, edgeKeys, nodes, root, target, 18) + for (let index = 0; index < 32; index += 1) { + const angle = (index / 32) * Math.PI * 2 + const point = { + x: centerX + Math.cos(angle) * 0.132, + y: centerY + Math.sin(angle) * 0.119, } + const id = addNode( + nodes, + random, + 'eye', + point, + mix(2.8, 5.8, random()), + [1, 0.96, 0.78], + 17.5, + 0.17, + ) + innerRing.push(id) + eyeIds.push(id) } - const eyeNodeIds: number[] = [] - const pupilNodeIds: number[] = [] - - for (const centerX of [-0.28, 0.28]) { - const centerY = -0.17 - const outerRing: number[] = [] - const innerRing: number[] = [] + connectLoop(nodes, edges, edgeKeys, outerRing, 27) + connectLoop(nodes, edges, edgeKeys, innerRing, 27) - for (let index = 0; index < 30; index += 1) { - const angle = (index / 30) * Math.PI * 2 - const point = { - x: centerX + Math.cos(angle) * 0.19, - y: centerY + Math.sin(angle) * 0.17, - } - const id = addNode( - nodes, - random, - 'eye', - point, - mix(4.1, 6.6, random()), - mixColor([1, 0.78, 0.36], [1, 0.98, 0.86], random() * 0.76), - 15, - 0.11, - ) - outerRing.push(id) - eyeNodeIds.push(id) - } + for (let index = 0; index < outerRing.length; index += 2) { + const outer = outerRing[index] + const inner = innerRing[Math.floor((index / outerRing.length) * innerRing.length)] - for (let index = 0; index < 22; index += 1) { - const angle = (index / 22) * Math.PI * 2 - const point = { - x: centerX + Math.cos(angle) * 0.125, - y: centerY + Math.sin(angle) * 0.112, - } - const id = addNode( - nodes, - random, - 'eye', - point, - mix(3.4, 5.4, random()), - [1, 0.95, 0.79], - 15.5, - 0.12, - ) - innerRing.push(id) - eyeNodeIds.push(id) + if (outer !== undefined && inner !== undefined) { + addEdge(edges, edgeKeys, nodes, outer, inner, 21) } + } - for (let index = 0; index < outerRing.length; index += 1) { - const current = outerRing[index] - const next = outerRing[(index + 1) % outerRing.length] - const inner = innerRing[Math.round((index / outerRing.length) * innerRing.length) % innerRing.length] - - if (current !== undefined && next !== undefined) { - addEdge(edges, edgeKeys, nodes, current, next, 24) - } - - if (current !== undefined && inner !== undefined && index % 2 === 0) { - addEdge(edges, edgeKeys, nodes, current, inner, 19) - } + for (let index = 0; index < 68; index += 1) { + const angle = random() * Math.PI * 2 + const radius = Math.sqrt(random()) + const point = { + x: centerX + Math.cos(angle) * 0.176 * radius, + y: centerY + Math.sin(angle) * 0.158 * radius, } + const normalized = Math.hypot((point.x - centerX) / 0.176, (point.y - centerY) / 0.158) - for (let index = 0; index < innerRing.length; index += 1) { - const current = innerRing[index] - const next = innerRing[(index + 1) % innerRing.length] - - if (current !== undefined && next !== undefined) { - addEdge(edges, edgeKeys, nodes, current, next, 23) - } + if (normalized < 0.31) { + continue } - for (let index = 0; index < 28; index += 1) { - const angle = random() * Math.PI * 2 - const radius = Math.sqrt(random()) - const point = { - x: centerX + Math.cos(angle) * 0.16 * radius, - y: centerY + Math.sin(angle) * 0.142 * radius, - } + const id = addNode( + nodes, + random, + 'eye', + point, + mix(1.8, 4.4, random()), + mixColor([1, 0.67, 0.13], [1, 1, 0.92], random()), + 17, + 0.17, + ) + eyeIds.push(id) + const target = nearestNode(nodes, point, new Set(['eye']), id) + addEdge(edges, edgeKeys, nodes, id, target, 18) + } - if (Math.hypot((point.x - centerX) / 0.16, (point.y - centerY) / 0.142) < 0.34) { - continue - } + const pupilCenterX = centerX + (centerX < 0 ? 0.012 : -0.012) + const pupilCenterY = centerY - 0.004 + const pupilRing: number[] = [] - const id = addNode( - nodes, - random, - 'eye', - point, - mix(2.4, 4.5, random()), - mixColor([1, 0.76, 0.28], [1, 0.99, 0.9], random()), - 15.5, - 0.12, - ) - eyeNodeIds.push(id) - const target = nearestNode(nodes, point, new Set(['eye']), id) - addEdge(edges, edgeKeys, nodes, id, target, 17) + for (let index = 0; index < 18; index += 1) { + const angle = (index / 18) * Math.PI * 2 + const point = { + x: pupilCenterX + Math.cos(angle) * 0.058, + y: pupilCenterY + Math.sin(angle) * 0.055, } + const id = addNode(nodes, random, 'pupil', point, mix(5.8, 8.6, random()), [0.006, 0.005, 0.006], 22, 0.2) + pupilRing.push(id) + pupilIds.push(id) + } - const pupilCenterX = centerX + (centerX < 0 ? 0.015 : -0.015) - const pupilCenterY = centerY - 0.006 - const pupilRing: number[] = [] + connectLoop(nodes, edges, edgeKeys, pupilRing, 31) - for (let index = 0; index < 12; index += 1) { - const angle = (index / 12) * Math.PI * 2 - const point = { - x: pupilCenterX + Math.cos(angle) * 0.055, - y: pupilCenterY + Math.sin(angle) * 0.052, - } - const id = addNode(nodes, random, 'pupil', point, 6.8, [0.012, 0.01, 0.009], 18, 0.16) - pupilRing.push(id) - pupilNodeIds.push(id) - } + const pupilHub = addNode( + nodes, + random, + 'pupil', + { x: pupilCenterX, y: pupilCenterY }, + 12.5, + [0.003, 0.003, 0.004], + 24, + 0.205, + ) + pupilIds.push(pupilHub) - for (let index = 0; index < pupilRing.length; index += 1) { - const current = pupilRing[index] - const next = pupilRing[(index + 1) % pupilRing.length] + for (let index = 0; index < pupilRing.length; index += 3) { + const pupil = pupilRing[index] + const eye = innerRing[Math.floor((index / pupilRing.length) * innerRing.length)] - if (current !== undefined && next !== undefined) { - addEdge(edges, edgeKeys, nodes, current, next, 26) - } + if (pupil !== undefined) { + addEdge(edges, edgeKeys, nodes, pupilHub, pupil, 31) } - for (let index = 0; index < 4; index += 1) { - const pupil = pupilRing[index * 3] - const eye = innerRing[index * 5] - - if (pupil !== undefined && eye !== undefined) { - addEdge(edges, edgeKeys, nodes, pupil, eye, 16) - } + if (pupil !== undefined && eye !== undefined) { + addEdge(edges, edgeKeys, nodes, pupil, eye, 18) } + } - for (const index of [1, 8, 16, 23]) { - const eye = outerRing[index] + for (const index of [2, 11, 21, 31]) { + const eye = outerRing[index] - if (eye !== undefined) { - const eyeNode = nodes[eye] - const target = eyeNode - ? nearestNode(nodes, eyeNode, new Set(['flame']), eye) - : -1 - addEdge(edges, edgeKeys, nodes, eye, target, 10) - } + if (eye !== undefined) { + const eyeNode = nodes[eye] + const target = eyeNode + ? nearestNode(nodes, eyeNode, new Set(['flame', 'rim']), eye) + : -1 + addEdge(edges, edgeKeys, nodes, eye, target, 12) } } - const mouthNodeIds: number[] = [] + return { eyeIds, pupilIds } +} + +const addMouth = ( + nodes: GraphNodeSeed[], + edges: GraphEdgeSeed[], + edgeKeys: Set, + random: () => number, +): number[] => { + const mouthIds: number[] = [] const upperLip: number[] = [] const lowerLip: number[] = [] - for (let index = 0; index < 24; index += 1) { - const progress = index / 23 + for (let index = 0; index < 34; index += 1) { + const progress = index / 33 const point = quadraticPoint( - { x: -0.34, y: -0.42 }, - { x: 0, y: -0.54 }, - { x: 0.34, y: -0.42 }, + { x: -0.39, y: -0.48 }, + { x: 0, y: -0.59 }, + { x: 0.39, y: -0.48 }, progress, ) const id = addNode( @@ -524,21 +469,21 @@ export const createCalciferGraph = ({ random, 'mouth', point, - mix(3.5, 5.8, random()), - [0.92, 0.09, 0.022], - 16, - 0.14, + mix(3.2, 6.6, random()), + mixColor([1, 0.22, 0.025], [0.68, 0.012, 0.02], Math.sin(progress * Math.PI) * 0.5), + 18, + 0.18, ) upperLip.push(id) - mouthNodeIds.push(id) + mouthIds.push(id) } - for (let index = 0; index < 28; index += 1) { - const progress = index / 27 + for (let index = 0; index < 38; index += 1) { + const progress = index / 37 const point = quadraticPoint( - { x: -0.34, y: -0.43 }, - { x: 0, y: -0.75 }, - { x: 0.34, y: -0.43 }, + { x: -0.39, y: -0.49 }, + { x: 0, y: -0.86 }, + { x: 0.39, y: -0.49 }, progress, ) const id = addNode( @@ -546,162 +491,362 @@ export const createCalciferGraph = ({ random, 'mouth', point, - mix(3.2, 5.4, random()), - mixColor([0.96, 0.12, 0.026], [0.58, 0.018, 0.012], Math.sin(progress * Math.PI)), - 16, - 0.14, + mix(3, 6.2, random()), + mixColor([0.94, 0.045, 0.02], [0.48, 0.006, 0.018], Math.sin(progress * Math.PI)), + 18, + 0.18, ) lowerLip.push(id) - mouthNodeIds.push(id) - } - - for (const contour of [upperLip, lowerLip]) { - for (let index = 1; index < contour.length; index += 1) { - addEdge(edges, edgeKeys, nodes, contour[index - 1] ?? -1, contour[index] ?? -1, 24) - } + mouthIds.push(id) } - addEdge(edges, edgeKeys, nodes, upperLip[0] ?? -1, lowerLip[0] ?? -1, 25) + connectChain(nodes, edges, edgeKeys, upperLip, 29) + connectChain(nodes, edges, edgeKeys, lowerLip, 29) + addEdge(edges, edgeKeys, nodes, upperLip[0] ?? -1, lowerLip[0] ?? -1, 30) addEdge( edges, edgeKeys, nodes, upperLip[upperLip.length - 1] ?? -1, lowerLip[lowerLip.length - 1] ?? -1, - 25, + 30, ) - for (let index = 0; index < 32; index += 1) { + for (let index = 0; index < 86; index += 1) { const angle = random() * Math.PI * 2 const radius = Math.sqrt(random()) const point = { - x: Math.cos(angle) * 0.29 * radius, - y: -0.53 + Math.sin(angle) * 0.13 * radius, + x: Math.cos(angle) * 0.34 * radius, + y: -0.64 + Math.sin(angle) * 0.17 * radius, } const id = addNode( nodes, random, 'mouth', point, - mix(2.2, 4.3, random()), - mixColor([0.56, 0.012, 0.008], [1, 0.2, 0.035], random() * 0.72), - 16.5, - 0.145, + mix(1.8, 4.8, random()), + mixColor([0.16, 0.002, 0.008], [1, 0.12, 0.025], Math.pow(random(), 1.6)), + 18.5, + 0.19, ) - mouthNodeIds.push(id) + mouthIds.push(id) const target = nearestNode(nodes, point, new Set(['mouth']), id) - addEdge(edges, edgeKeys, nodes, id, target, 18) + addEdge(edges, edgeKeys, nodes, id, target, 20) + } + + for (let index = 0; index < 24; index += 1) { + const progress = index / 23 + const point = quadraticPoint( + { x: -0.25, y: -0.7 }, + { x: 0, y: -0.79 }, + { x: 0.25, y: -0.7 }, + progress, + ) + const id = addNode( + nodes, + random, + 'mouth', + point, + mix(2.2, 4.5, random()), + mixColor([0.72, 0.015, 0.025], [1, 0.23, 0.035], 0.45 + random() * 0.55), + 19, + 0.2, + ) + mouthIds.push(id) } - for (const index of [0, 7, 15, 23]) { + for (const index of [0, 9, 18, 27]) { const mouth = upperLip[index] if (mouth !== undefined) { const mouthNode = nodes[mouth] const target = mouthNode - ? nearestNode(nodes, mouthNode, new Set(['flame']), mouth) + ? nearestNode(nodes, mouthNode, new Set(['flame', 'rim']), mouth) : -1 - addEdge(edges, edgeKeys, nodes, mouth, target, 10) + addEdge(edges, edgeKeys, nodes, mouth, target, 12) } } - const sparkNodeIds: number[] = [] - const clusterCount = Math.max(12, Math.floor(nodeCount / 30)) + return mouthIds +} - for (let clusterIndex = 0; clusterIndex < clusterCount; clusterIndex += 1) { - const angle = mix(0.04 * Math.PI, 0.96 * Math.PI, random()) - const radiusX = mix(1.04, 1.46, random()) - const radiusY = mix(0.9, 1.32, random()) - const center = { - x: Math.cos(angle) * radiusX, - y: Math.sin(angle) * radiusY + mix(-0.08, 0.16, random()), +export const createCalciferGraph = ({ + nodeCount, + seed = 20260710, +}: CreateGraphOptions): CalciferGraphData => { + const random = createRandom(seed) + const nodes: GraphNodeSeed[] = [] + const edges: GraphEdgeSeed[] = [] + const edgeKeys = new Set() + const flameNodeIds: number[] = [] + const rimNodeIds: number[] = [] + const desiredFlameCount = Math.max(180, Math.floor(nodeCount * 0.84)) + let attempts = 0 + + while (flameNodeIds.length < desiredFlameCount && attempts < desiredFlameCount * 180) { + attempts += 1 + const x = mix(REFERENCE_MASK_BOUNDS.minX, REFERENCE_MASK_BOUNDS.maxX, random()) + const y = mix(REFERENCE_MASK_BOUNDS.minY, REFERENCE_MASK_BOUNDS.maxY, random()) + const density = sampleReferenceDensity(x, y) + + if (density < 0.12 || pointInsideFaceVoid(x, y)) { + continue } - const hub = addNode( + + const acceptance = clamp(0.15 + density * 0.94, 0, 1) + + if (random() > acceptance) { + continue + } + + const edge = getMaskEdge(x, y) + const hub = random() < 0.028 + density * 0.052 + const size = hub + ? mix(7.2, 13.8, random()) + : mix(1.7, 5.8, Math.pow(random(), 1.35) * 0.76 + density * 0.24) + const id = addNode( nodes, random, - 'spark', - center, - mix(5.4, 9.6, random()), - mixColor([1, 0.27, 0.025], [1, 0.82, 0.36], random()), - mix(4.4, 7.2, random()), - mix(-0.03, 0.08, random()), + 'flame', + { x, y }, + size, + getFireColor(x, y, density, random()), + mix(5.4, 10.8, edge * 0.58 + (1 - density) * 0.42), + mix(-0.055, 0.07, random()), ) - sparkNodeIds.push(hub) - const satelliteCount = 5 + Math.floor(random() * 7) - let previous = hub + flameNodeIds.push(id) + } + + const desiredRimCount = Math.max(82, Math.floor(nodeCount * 0.17)) + attempts = 0 + + while (rimNodeIds.length < desiredRimCount && attempts < desiredRimCount * 260) { + attempts += 1 + const x = mix(REFERENCE_MASK_BOUNDS.minX, REFERENCE_MASK_BOUNDS.maxX, random()) + const y = mix(REFERENCE_MASK_BOUNDS.minY, REFERENCE_MASK_BOUNDS.maxY, random()) + const density = sampleReferenceDensity(x, y) + const edge = getMaskEdge(x, y) + + if (density < 0.13 || edge < 0.15 || pointInsideFaceVoid(x, y)) { + continue + } + + const id = addNode( + nodes, + random, + 'rim', + { x, y }, + random() < 0.12 ? mix(6.5, 10.8, random()) : mix(2.2, 6.2, random()), + getFireColor(x, y, Math.max(0.2, density * 0.62), random()), + 11.2, + mix(0.035, 0.1, random()), + ) + rimNodeIds.push(id) + } + + const armNodeIds: number[] = [] - for (let satelliteIndex = 0; satelliteIndex < satelliteCount; satelliteIndex += 1) { - const satelliteAngle = random() * Math.PI * 2 - const distance = mix(0.035, 0.13, random()) + for (const side of [-1, 1]) { + const mainArm: number[] = [] + const start = { x: side * 0.66, y: -0.12 } + const controlA = { x: side * 0.94, y: -0.02 } + const controlB = { x: side * 1.12, y: 0.35 } + const end = { x: side * 1.27, y: 0.68 } + + for (let index = 0; index < 24; index += 1) { + const progress = index / 23 + const point = cubicPoint(start, controlA, controlB, end, progress) + point.x += Math.sin(progress * Math.PI * 3.1) * 0.022 * side + point.y += Math.sin(progress * Math.PI) * 0.045 + const id = addNode( + nodes, + random, + 'arm', + point, + random() < 0.16 ? mix(6.8, 11.4, random()) : mix(2.8, 7.2, random()), + getFireColor(point.x, point.y, 0.36, random()), + 10.2, + 0.07, + ) + mainArm.push(id) + armNodeIds.push(id) + } + + connectChain(nodes, edges, edgeKeys, mainArm, 22) + + for (let index = 0; index < 56; index += 1) { + const progress = random() + const center = cubicPoint(start, controlA, controlB, end, progress) + const spread = mix(0.025, 0.14, Math.sin(progress * Math.PI)) const point = { - x: center.x + Math.cos(satelliteAngle) * distance, - y: center.y + Math.sin(satelliteAngle) * distance, + x: center.x + mix(-spread, spread, random()), + y: center.y + mix(-spread, spread, random()), } - const satellite = addNode( + const id = addNode( nodes, random, - 'spark', + 'arm', point, - mix(1.8, 4.1, random()), - mixColor([1, 0.18, 0.018], [1, 0.9, 0.55], random()), - mix(3.8, 6.2, random()), - mix(-0.04, 0.1, random()), + mix(1.9, 5.6, random()), + getFireColor(point.x, point.y, 0.24, random()), + 9.5, + mix(0.02, 0.09, random()), ) - sparkNodeIds.push(satellite) - addEdge(edges, edgeKeys, nodes, hub, satellite, 8.5) + armNodeIds.push(id) + const mainTarget = mainArm[Math.min(mainArm.length - 1, Math.round(progress * 23))] - if (satelliteIndex > 0 && random() > 0.42) { - addEdge(edges, edgeKeys, nodes, previous, satellite, 6.5) + if (mainTarget !== undefined) { + addEdge(edges, edgeKeys, nodes, id, mainTarget, 17) } + } + + const root = mainArm[0] + const rootNode = root === undefined ? undefined : nodes[root] + + if (root !== undefined && rootNode) { + const target = nearestNode(nodes, rootNode, new Set(['flame', 'rim']), root) + addEdge(edges, edgeKeys, nodes, root, target, 23) + } + } + + connectNearestNeighbours(nodes, edges, edgeKeys, flameNodeIds, 4, 0.205, 12.2) + connectNearestNeighbours(nodes, edges, edgeKeys, rimNodeIds, 3, 0.18, 16) + connectNearestNeighbours(nodes, edges, edgeKeys, armNodeIds, 3, 0.215, 17) + + for (const rimId of rimNodeIds) { + const rim = nodes[rimId] - previous = satellite + if (!rim) { + continue } + + const target = nearestNode(nodes, rim, new Set(['flame']), rimId) + addEdge(edges, edgeKeys, nodes, rimId, target, 13) } - const emberPairCount = Math.max(18, Math.floor(nodeCount / 18)) + const hubIds = flameNodeIds.filter((id) => (nodes[id]?.size ?? 0) >= 8) - for (let pairIndex = 0; pairIndex < emberPairCount; pairIndex += 1) { - let x = mix(-1.46, 1.46, random()) - let y = mix(-0.28, 1.4, random()) - let safety = 0 + for (const hubId of hubIds) { + const hub = nodes[hubId] - while (pointInsideSilhouette(x, y) && safety < 30) { - x = mix(-1.46, 1.46, random()) - y = mix(-0.28, 1.4, random()) - safety += 1 + if (!hub) { + continue } - const first = addNode( + const candidates = flameNodeIds + .filter((id) => id !== hubId) + .map((id) => { + const node = nodes[id] + + return { + id, + distance: node ? Math.hypot(node.x - hub.x, node.y - hub.y) : Number.POSITIVE_INFINITY, + } + }) + .filter(({ distance }) => distance > 0.2 && distance < 0.52) + .sort((left, right) => left.distance - right.distance) + .slice(0, 3) + + for (const candidate of candidates) { + addEdge(edges, edgeKeys, nodes, hubId, candidate.id, 8.8) + } + } + + const leftEye = addEye(nodes, edges, edgeKeys, random, -0.29) + const rightEye = addEye(nodes, edges, edgeKeys, random, 0.29) + const mouthNodeIds = addMouth(nodes, edges, edgeKeys, random) + const sparkNodeIds: number[] = [] + const emberNodeIds: number[] = [] + const clusterCount = Math.max(20, Math.floor(nodeCount / 24)) + + for (let clusterIndex = 0; clusterIndex < clusterCount; clusterIndex += 1) { + const angle = mix(-0.06 * Math.PI, 1.06 * Math.PI, random()) + const radiusX = mix(1.1, 1.58, random()) + const radiusY = mix(0.96, 1.5, random()) + const center = { + x: Math.cos(angle) * radiusX, + y: Math.sin(angle) * radiusY + mix(-0.08, 0.2, random()), + } + const hub = addNode( nodes, random, 'spark', - { x, y }, - mix(1.7, 3.6, random()), - mixColor([1, 0.16, 0.015], [1, 0.78, 0.32], random()), - mix(3.6, 5.5, random()), - mix(-0.04, 0.08, random()), + center, + mix(5.6, 12.4, random()), + getFireColor(center.x, center.y, 0.16, random()), + 6.2, + 0.11, + ) + sparkNodeIds.push(hub) + const rays = Math.floor(mix(7, 15, random())) + + for (let rayIndex = 0; rayIndex < rays; rayIndex += 1) { + const rayAngle = random() * Math.PI * 2 + const segments = random() < 0.42 ? 2 : 1 + let previous = hub + + for (let segmentIndex = 0; segmentIndex < segments; segmentIndex += 1) { + const distance = mix(0.045, 0.15, random()) * (segmentIndex + 1) + const point = { + x: center.x + Math.cos(rayAngle) * distance, + y: center.y + Math.sin(rayAngle) * distance, + } + const id = addNode( + nodes, + random, + 'spark', + point, + mix(1.7, 4.3, random()), + getFireColor(point.x, point.y, 0.08, random()), + 5.4, + mix(0.07, 0.15, random()), + ) + sparkNodeIds.push(id) + addEdge(edges, edgeKeys, nodes, previous, id, 11.5) + previous = id + } + } + } + + const emberPairCount = Math.max(34, Math.floor(nodeCount / 8)) + + for (let pairIndex = 0; pairIndex < emberPairCount; pairIndex += 1) { + const angle = mix(-0.12 * Math.PI, 1.12 * Math.PI, random()) + const radius = mix(1.06, 1.72, random()) + const start = { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * mix(0.9, 1.46, random()) + mix(-0.14, 0.22, random()), + } + const direction = mix(-0.4, 0.4, random()) + const end = { + x: start.x + direction, + y: start.y + mix(0.04, 0.16, random()), + } + const first = addNode( + nodes, + random, + 'ember', + start, + mix(1.5, 4.4, random()), + getFireColor(start.x, start.y, 0.05, random()), + 3.8, + 0.12, ) - const direction = random() * Math.PI * 2 - const distance = mix(0.025, 0.075, random()) const second = addNode( nodes, random, - 'spark', - { - x: x + Math.cos(direction) * distance, - y: y + Math.sin(direction) * distance, - }, - mix(1.4, 2.8, random()), - [1, 0.52, 0.08], - mix(3.6, 5.5, random()), - mix(-0.04, 0.08, random()), + 'ember', + end, + mix(1.4, 3.7, random()), + getFireColor(end.x, end.y, 0.04, random()), + 3.4, + 0.13, ) - sparkNodeIds.push(first, second) - addEdge(edges, edgeKeys, nodes, first, second, 5.5) + emberNodeIds.push(first, second) + addEdge(edges, edgeKeys, nodes, first, second, 8.5) } - connectNearestNeighbours(nodes, edges, edgeKeys, flameNodeIds, 4, 0.245, 10.5) - connectNearestNeighbours(nodes, edges, edgeKeys, armNodeIds, 3, 0.18, 13) + connectNearestNeighbours(nodes, edges, edgeKeys, sparkNodeIds, 2, 0.26, 9.5) const connectedNodeIds = new Set() @@ -710,35 +855,43 @@ export const createCalciferGraph = ({ connectedNodeIds.add(edge.target) } - for (const nodeId of flameNodeIds) { - if (connectedNodeIds.has(nodeId)) { - continue - } - - const node = nodes[nodeId] + const allRoles = new Set([ + 'flame', + 'rim', + 'arm', + 'eye', + 'pupil', + 'mouth', + 'spark', + 'ember', + ]) - if (!node) { + for (const node of nodes) { + if (connectedNodeIds.has(node.id)) { continue } - const target = nearestNode(nodes, node, new Set(['flame']), nodeId) - addEdge(edges, edgeKeys, nodes, nodeId, target, 10.5) + const target = nearestNode(nodes, node, allRoles, node.id) + addEdge(edges, edgeKeys, nodes, node.id, target, 8) } - for (const nodeId of [...eyeNodeIds, ...pupilNodeIds, ...mouthNodeIds, ...sparkNodeIds]) { - if (connectedNodeIds.has(nodeId)) { - continue - } + const faceIds = [ + ...leftEye.eyeIds, + ...leftEye.pupilIds, + ...rightEye.eyeIds, + ...rightEye.pupilIds, + ...mouthNodeIds, + ] - const node = nodes[nodeId] + for (const faceId of faceIds) { + const node = nodes[faceId] if (!node) { continue } - const roles = new Set([node.role]) - const target = nearestNode(nodes, node, roles, nodeId) - addEdge(edges, edgeKeys, nodes, nodeId, target, node.role === 'spark' ? 5.5 : 12) + const target = nearestNode(nodes, node, new Set(['flame', 'rim']), faceId) + addEdge(edges, edgeKeys, nodes, faceId, target, 11) } return { nodes, edges } diff --git a/src/scripts/graph/mountCalciferGraph.ts b/src/scripts/graph/mountCalciferGraph.ts index d98ff21..078a6c2 100644 --- a/src/scripts/graph/mountCalciferGraph.ts +++ b/src/scripts/graph/mountCalciferGraph.ts @@ -1,7 +1,7 @@ import * as THREE from 'three' import { createCalciferGraph } from './generateGraph' import { createDragInfluence, createPhysicsState, stepGraphPhysics } from './physics' -import type { CalciferGraphData, Rgb } from './types' +import type { CalciferGraphData, GraphNodeRole, Rgb } from './types' interface PointerSample { x: number @@ -38,12 +38,13 @@ const fragmentShader = ` float distanceToCenter = distance(gl_PointCoord, vec2(0.5)); if (distanceToCenter > 0.5) discard; - float core = 1.0 - smoothstep(0.05, 0.28, distanceToCenter); - float halo = 1.0 - smoothstep(0.16, 0.5, distanceToCenter); + float hardCore = 1.0 - smoothstep(0.045, 0.24, distanceToCenter); + float softCore = 1.0 - smoothstep(0.12, 0.37, distanceToCenter); + float halo = 1.0 - smoothstep(0.22, 0.5, distanceToCenter); float haloStrength = halo * vGlow; - float alpha = clamp(core + haloStrength * (0.42 + vHeat * 0.26), 0.0, 1.0); - vec3 fireCore = vec3(1.0, 0.9, 0.62); - vec3 glow = mix(vColor, fireCore, (haloStrength * 0.22 + vHeat * 0.16) * vGlow); + float alpha = clamp(hardCore + softCore * 0.52 + haloStrength * (0.3 + vHeat * 0.36), 0.0, 1.0); + vec3 fireCore = vec3(1.0, 0.93, 0.68); + vec3 glow = mix(vColor, fireCore, (softCore * 0.12 + haloStrength * 0.24 + vHeat * 0.16) * vGlow); gl_FragColor = vec4(glow, alpha); } ` @@ -56,14 +57,14 @@ const getQualityNodeCount = (): number => { const width = window.innerWidth if (coarsePointer || width < 640) { - return 230 + return 340 } if (width < 1100) { - return 330 + return 520 } - return 470 + return 760 } const writeColor = (target: Float32Array, offset: number, color: Rgb, multiplier = 1): void => { @@ -72,6 +73,26 @@ const writeColor = (target: Float32Array, offset: number, color: Rgb, multiplier target[offset + 2] = color[2] * multiplier } +const getRoleGlow = (role: GraphNodeRole): number => { + if (role === 'pupil') { + return 0 + } + + if (role === 'eye') { + return 1.18 + } + + if (role === 'spark' || role === 'ember') { + return 1.12 + } + + if (role === 'mouth') { + return 0.76 + } + + return 1 +} + const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { const positions = new Float32Array(graph.nodes.length * 3) const colors = new Float32Array(graph.nodes.length * 3) @@ -87,7 +108,7 @@ const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => writeColor(colors, offset, node.color) sizes[node.id] = node.size heat[node.id] = 0 - glow[node.id] = node.role === 'pupil' ? 0 : 1 + glow[node.id] = getRoleGlow(node.role) } const geometry = new THREE.BufferGeometry() @@ -125,8 +146,8 @@ const createLineGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { positions[offset + 3] = target.x positions[offset + 4] = target.y positions[offset + 5] = target.z - writeColor(colors, offset, source.color, 0.56) - writeColor(colors, offset + 3, target.color, 0.56) + writeColor(colors, offset, source.color, source.role === 'pupil' ? 0.08 : 0.48) + writeColor(colors, offset + 3, target.color, target.role === 'pupil' ? 0.08 : 0.48) } const geometry = new THREE.BufferGeometry() @@ -181,6 +202,9 @@ const findNearestNode = ( return nearest } +const isCombustionRole = (role: GraphNodeRole): boolean => + role === 'flame' || role === 'rim' || role === 'arm' || role === 'spark' || role === 'ember' + export const mountCalciferGraph = ( host: HTMLElement, canvas: HTMLCanvasElement, @@ -198,7 +222,7 @@ export const mountCalciferGraph = ( renderer.setClearColor(0x000000, 0) const scene = new THREE.Scene() - const camera = new THREE.OrthographicCamera(-1.8, 1.8, 1.45, -1.45, 0.1, 20) + const camera = new THREE.OrthographicCamera(-1.8, 1.8, 1.55, -1.55, 0.1, 20) camera.position.z = 5 const pointGeometry = createPointGeometry(graph) @@ -223,7 +247,7 @@ export const mountCalciferGraph = ( const lineMaterial = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, - opacity: 0.42, + opacity: 0.36, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, @@ -255,12 +279,12 @@ export const mountCalciferGraph = ( const resize = (): void => { const width = Math.max(1, host.clientWidth) const height = Math.max(1, host.clientHeight) - const pixelRatio = Math.min(window.devicePixelRatio, width < 700 ? 1.2 : 1.55) + const pixelRatio = Math.min(window.devicePixelRatio, width < 700 ? 1.15 : 1.5) const aspect = width / height - const vertical = aspect < 0.8 ? 1.72 : aspect < 1.15 ? 1.52 : 1.42 + const vertical = aspect < 0.78 ? 1.82 : aspect < 1.08 ? 1.63 : 1.52 const horizontal = vertical * aspect - const centerX = aspect > 1.35 ? -horizontal * 0.31 : aspect > 1.05 ? -horizontal * 0.12 : 0 - const centerY = aspect < 0.8 ? 0.58 : aspect < 1.05 ? 0.18 : 0 + const centerX = aspect > 1.45 ? -horizontal * 0.335 : aspect > 1.08 ? -horizontal * 0.14 : 0 + const centerY = aspect < 0.78 ? 0.55 : aspect < 1.08 ? 0.18 : 0.05 camera.top = centerY + vertical camera.bottom = centerY - vertical @@ -279,7 +303,7 @@ export const mountCalciferGraph = ( } if (viewportScaleUniform) { - viewportScaleUniform.value = clamp(Math.min(width, height) / 720, 0.72, 1.18) + viewportScaleUniform.value = clamp(Math.min(width, height) / 760, 0.66, 1.14) } } @@ -287,12 +311,14 @@ export const mountCalciferGraph = ( const rect = torchSurface.getBoundingClientRect() const x = clamp(event.clientX - rect.left, 0, rect.width) const y = clamp(event.clientY - rect.top, 0, rect.height) - const radius = clamp(190 + speed * 240, 190, 350) + const radius = clamp(155 + speed * 285, 155, 390) + const warmth = clamp(0.48 + speed * 0.62, 0.48, 1) torchSurface.style.setProperty('--torch-x', `${x}px`) torchSurface.style.setProperty('--torch-y', `${y}px`) torchSurface.style.setProperty('--torch-radius', `${radius}px`) torchSurface.style.setProperty('--torch-intensity', intensity.toFixed(3)) + torchSurface.style.setProperty('--torch-warmth', warmth.toFixed(3)) } const updateGeometry = (elapsed: number): void => { @@ -310,39 +336,44 @@ export const mountCalciferGraph = ( const offset = index * 3 const influence = dragInfluence?.[index] ?? 0 - const slowFlicker = Math.sin(elapsed * 2.35 + seed.phase) - const quickFlicker = Math.sin(elapsed * 6.8 + seed.phase * 1.83) - const upwardBias = clamp((seed.y + 0.85) / 2.15, 0, 1) - const isFire = seed.role === 'flame' || seed.role === 'arm' - const isSpark = seed.role === 'spark' - const renderDriftX = isSpark - ? Math.sin(elapsed * 1.5 + seed.phase) * 0.025 - : isFire - ? quickFlicker * 0.0055 * (0.5 + upwardBias) + const slowFlicker = Math.sin(elapsed * 1.75 + seed.phase) + const mediumFlicker = Math.sin(elapsed * 4.25 + seed.phase * 1.61) + const quickFlicker = Math.sin(elapsed * 8.4 + seed.phase * 2.17) + const thermalWave = Math.sin(elapsed * 2.05 - seed.y * 5.8 + seed.phase * 0.44) + const upwardBias = clamp((seed.y + 1.08) / 2.58, 0, 1) + const combustion = isCombustionRole(seed.role) + const detached = seed.role === 'spark' || seed.role === 'ember' + const renderDriftX = detached + ? (slowFlicker * 0.025 + mediumFlicker * 0.009) * (seed.role === 'ember' ? 1.35 : 1) + : combustion + ? (mediumFlicker * 0.006 + quickFlicker * 0.0028) * (0.55 + upwardBias) : 0 - const renderDriftY = isSpark - ? Math.sin(elapsed * 1.08 + seed.phase * 1.27) * 0.035 - : isFire - ? Math.max(0, slowFlicker) * 0.009 * (0.45 + upwardBias) + const renderDriftY = detached + ? (Math.max(0, thermalWave) * 0.034 + slowFlicker * 0.014) * + (seed.role === 'ember' ? 1.4 : 1) + : combustion + ? (Math.max(0, thermalWave) * 0.012 + mediumFlicker * 0.0038) * + (0.5 + upwardBias) : 0 const renderX = node.x + renderDriftX const renderY = node.y + renderDriftY const renderZ = node.z - const idleHeat = isSpark - ? 0.34 + (slowFlicker + 1) * 0.19 - : isFire - ? 0.12 + Math.max(0, slowFlicker * 0.24 + quickFlicker * 0.12) + const idleHeat = detached + ? 0.28 + (slowFlicker + 1) * 0.16 + Math.max(0, quickFlicker) * 0.18 + : combustion + ? 0.12 + Math.max(0, thermalWave) * 0.24 + Math.max(0, quickFlicker) * 0.12 : seed.role === 'eye' - ? 0.22 + Math.max(0, slowFlicker) * 0.08 + ? 0.3 + Math.max(0, slowFlicker) * 0.1 : seed.role === 'mouth' - ? 0.14 + Math.max(0, quickFlicker) * 0.12 + ? 0.16 + Math.max(0, mediumFlicker) * 0.12 : 0 - const activeHeat = seed.role === 'pupil' ? 0 : clamp(idleHeat + influence * 0.92, 0, 1) - const brightness = seed.role === 'pupil' ? 1 : 1 + activeHeat * 0.24 + const activeHeat = seed.role === 'pupil' ? 0 : clamp(idleHeat + influence * 0.96, 0, 1) + const brightness = seed.role === 'pupil' ? 1 : 1 + activeHeat * 0.28 + const pulseStrength = detached ? 0.17 : seed.role === 'rim' ? 0.085 : combustion ? 0.06 : 0.018 const scalePulse = seed.role === 'pupil' ? 1 - : 1 + slowFlicker * (isSpark ? 0.12 : isFire ? 0.055 : 0.018) + influence * 0.2 + : 1 + slowFlicker * pulseStrength + quickFlicker * pulseStrength * 0.35 + influence * 0.22 renderPositions[offset] = renderX renderPositions[offset + 1] = renderY @@ -350,7 +381,7 @@ export const mountCalciferGraph = ( positionAttribute.setXYZ(index, renderX, renderY, renderZ) writeColor(colorArray, offset, seed.color, brightness) heatArray[index] = activeHeat - sizeArray[index] = seed.size * scalePulse + sizeArray[index] = seed.size * Math.max(0.7, scalePulse) } for (let index = 0; index < graph.edges.length; index += 1) { @@ -371,9 +402,11 @@ export const mountCalciferGraph = ( const targetOffset = edge.target * 3 const lineOffset = index * 6 const edgeHeat = Math.max(dragInfluence?.[edge.source] ?? 0, dragInfluence?.[edge.target] ?? 0) - const idlePulse = - Math.max(0, Math.sin(elapsed * 2.1 + sourceSeed.phase + targetSeed.phase)) * 0.16 - const multiplier = 0.5 + idlePulse + edgeHeat * 0.72 + const wave = Math.sin(elapsed * 2.45 - (sourceSeed.y + targetSeed.y) * 3.2 + sourceSeed.phase) + const quick = Math.sin(elapsed * 6.2 + sourceSeed.phase + targetSeed.phase) + const idlePulse = Math.max(0, wave) * 0.17 + Math.max(0, quick) * 0.08 + const pupilEdge = sourceSeed.role === 'pupil' || targetSeed.role === 'pupil' + const multiplier = pupilEdge ? 0.07 : 0.42 + idlePulse + edgeHeat * 0.78 linePositionAttribute.setXYZ( index * 2, @@ -430,7 +463,7 @@ export const mountCalciferGraph = ( const onPointerDown = (event: PointerEvent): void => { const point = getPointerWorld(event, canvas, camera) - const threshold = event.pointerType === 'touch' ? 0.25 : 0.17 + const threshold = event.pointerType === 'touch' ? 0.27 : 0.18 const nearest = findNearestNode(graph, state, point, threshold) if (nearest < 0) { @@ -445,13 +478,13 @@ export const mountCalciferGraph = ( canvas.setPointerCapture(event.pointerId) canvas.style.cursor = 'grabbing' host.dataset.dragging = 'true' - setTorch(event, 0.74) + setTorch(event, 0.82) } const onPointerMove = (event: PointerEvent): void => { if (draggedIndex < 0) { const point = getPointerWorld(event, canvas, camera) - canvas.style.cursor = findNearestNode(graph, state, point, 0.17) >= 0 ? 'grab' : 'default' + canvas.style.cursor = findNearestNode(graph, state, point, 0.18) >= 0 ? 'grab' : 'default' return } @@ -465,7 +498,7 @@ export const mountCalciferGraph = ( event.clientY - (previous?.y ?? event.clientY), ) const speed = distance / elapsed - const intensity = clamp(0.62 + speed * 0.72, 0.62, 1) + const intensity = clamp(0.68 + speed * 0.78, 0.68, 1) pointerSample = { x: event.clientX, y: event.clientY, time: now } setTorch(event, intensity, speed) @@ -486,6 +519,7 @@ export const mountCalciferGraph = ( canvas.style.cursor = 'default' host.dataset.dragging = 'false' torchSurface.style.setProperty('--torch-intensity', '0') + torchSurface.style.setProperty('--torch-warmth', '0') } const onVisibilityChange = (): void => { diff --git a/src/scripts/graph/physics.ts b/src/scripts/graph/physics.ts index 85de55d..f3cbda6 100644 --- a/src/scripts/graph/physics.ts +++ b/src/scripts/graph/physics.ts @@ -59,19 +59,43 @@ const applyEdgeSpring = ( } const getRoleMotion = (role: PhysicsState['nodes'][number]['role']): number => { + if (role === 'ember') { + return 3.1 + } + if (role === 'spark') { - return 2.15 + return 2.45 } if (role === 'arm') { - return 1.35 + return 1.48 + } + + if (role === 'rim') { + return 1.18 } if (role === 'flame') { return 1 } - return 0.12 + return 0.075 +} + +const getDamping = (role: PhysicsState['nodes'][number]['role']): number => { + if (role === 'ember') { + return 3.8 + } + + if (role === 'spark') { + return 4.2 + } + + if (role === 'arm' || role === 'rim') { + return 5 + } + + return 5.65 } export const stepGraphPhysics = ( @@ -109,22 +133,37 @@ export const stepGraphPhysics = ( } const roleMotion = getRoleMotion(node.role) - const slowWave = Math.sin(elapsedSeconds * 1.05 + node.phase) - const fastWave = Math.sin(elapsedSeconds * 3.6 + node.phase * 1.73) - const verticalWave = Math.cos(elapsedSeconds * 1.34 + node.phase * 1.21) - const driftX = - (slowWave * 0.014 + fastWave * 0.0055) * roleMotion * motionScale - const driftY = - (verticalWave * 0.017 + fastWave * 0.0045) * roleMotion * motionScale + - (node.role === 'spark' ? Math.sin(elapsedSeconds * 0.72 + node.phase) * 0.024 : 0) + const height = clamp((node.targetY + 1.08) / 2.58, 0, 1) + const slowWave = Math.sin(elapsedSeconds * 0.92 + node.phase) + const mediumWave = Math.sin(elapsedSeconds * 2.35 + node.phase * 1.47) + const fastWave = Math.sin(elapsedSeconds * 5.6 + node.phase * 2.11) + const thermalWave = Math.sin(elapsedSeconds * 1.28 - node.targetY * 4.6 + node.phase * 0.52) + const lateral = + (slowWave * 0.013 + mediumWave * 0.007 + fastWave * 0.0035) * + roleMotion * + (0.56 + height * 0.72) * + motionScale + const convection = + (Math.max(0, thermalWave) * 0.018 + mediumWave * 0.005) * + roleMotion * + (0.48 + height * 0.84) * + motionScale + const orbitalX = + node.role === 'spark' || node.role === 'ember' + ? Math.cos(elapsedSeconds * 0.68 + node.phase) * 0.023 * roleMotion + : 0 + const orbitalY = + node.role === 'spark' || node.role === 'ember' + ? Math.sin(elapsedSeconds * 0.56 + node.phase * 1.2) * 0.029 * roleMotion + : 0 const influence = options.dragInfluence?.[index] ?? 0 - const anchorScale = 1 - influence * 0.78 + const anchorScale = 1 - influence * 0.9 const anchor = node.anchorStrength * anchorScale - const targetX = node.targetX + driftX - const targetY = node.targetY + driftY + const targetX = node.targetX + lateral + orbitalX + const targetY = node.targetY + convection + orbitalY const targetZ = node.targetZ + - Math.sin(elapsedSeconds * 0.92 + node.phase) * 0.009 * roleMotion * motionScale + Math.sin(elapsedSeconds * 0.86 + node.phase) * 0.012 * roleMotion * motionScale accelerationX[index] = (accelerationX[index] ?? 0) + (targetX - node.x) * anchor accelerationY[index] = (accelerationY[index] ?? 0) + (targetY - node.y) * anchor @@ -134,7 +173,7 @@ export const stepGraphPhysics = ( node.vy += (accelerationY[index] ?? 0) * delta node.vz += (accelerationZ[index] ?? 0) * delta - const damping = Math.exp(-(node.role === 'spark' ? 4.65 : 5.55) * delta) + const damping = Math.exp(-getDamping(node.role) * delta) node.vx *= damping node.vy *= damping node.vz *= damping @@ -164,11 +203,11 @@ export const createDragInfluence = ( while (queue.length > 0) { const current = queue.shift() - if (!current || current.depth > 5) { + if (!current || current.depth > 7) { continue } - influence[current.index] = Math.max(influence[current.index] ?? 0, Math.pow(0.62, current.depth)) + influence[current.index] = Math.max(influence[current.index] ?? 0, Math.pow(0.67, current.depth)) for (const neighbour of adjacency[current.index] ?? []) { if (visited.has(neighbour)) { diff --git a/src/scripts/graph/referenceMask.ts b/src/scripts/graph/referenceMask.ts new file mode 100644 index 0000000..83f67ff --- /dev/null +++ b/src/scripts/graph/referenceMask.ts @@ -0,0 +1,120 @@ +const rows = [ + '000000000000000000000000000000000000003500000000000000000000000000000000', + '000000000000000000000000000000000000003500000000000000000000000000000000', + '000000000000000000000000000000000000034400000000000000000000000000000000', + '000000000000000000000000000000000000455400000000000000000000000000000000', + '000000000000000000000000000000000004555400000000000000000000000000000000', + '000000000000000000000000000000000003764500000000000000000000000000000000', + '000000000000000000000000000000000003765500000000000000000000000000000000', + '000000000000000000000000000000000045765400000000000000000000000000000000', + '000000000000000000000000000000000456666300000000000000000000000000000000', + '000000000000000000000000000000004466656400000000000000000000000000000000', + '000000000000000000000000000000344665566400000000000000000000000000000000', + '000000000000000000000000000005557754565400000000000000000000000000000000', + '000000000000000000000000000005668755765400000000000000000000000000000000', + '000000000000000000000000000005777656766544000000000000000000000000000000', + '000000000000000000000000000036867556555354000004400000000000000000000000', + '000000000000000000000000000047766556654000000004400000000000000000000000', + '000000000000000000000000045037677556654500000004400000000000000000000000', + '000000000000000000003000036457677656645500000004500000000000000000000000', + '000000000000000000005500045457676666664440000005530000000000000000000000', + '000000000000000000004400047657676677775550000046500000000000000000000000', + '000000000000000000045450358656665677676540000556500000000000000000000000', + '000000000000000000036540467666666666676554540567400000000000000000000000', + '000000000000000000056644677657676665676655640367400000000000000000000000', + '000000000000000000458754675657766656656655640467540000000000000000000000', + '000000000000000004468754775677666656766567655467650000000000000000000000', + '000000000000000005677765775676667655566666655577650000000000000000000000', + '000000000000003404677776776766876655566776655677763000000000000000000000', + '000000000000006635776677876667865665556666768866750000000000000000000000', + '000000000000004546776777877697665555566666668756650000000000000000000000', + '004400000000000455766688776777665565566677777655764000550000000000000000', + '004540000000000467777677887767676566566677776766663005650000000000000000', + '005640000004400458877877877776676666666797876666654046863000000000000000', + '004740000004504557887777777666666668766787776767855057850000000000000000', + '004763000036604567876667876666666566777677776878740057840000000000000000', + '004775404006744568777766777765666666777787877777540567863000000000000000', + '005676555305755677777777777866666667666677877786554578763000000000000000', + '004676766406776687777777776667666666678687887877655677763000000000000000', + '004587676305778889777777677666668767788667778986667876663000000000000000', + '004556676505877877777788777666668778777767787897679867764000000000000000', + '000556677405687777778889887777666777777777787898788767750000000000000000', + '000455566540478777788999998776777777788887778788887876760000000000000000', + '000045555544368787789998998767867778889998877777877988750454000000000000', + '000005566554467787789995699777777878899999887777778787540665000000000000', + '000000045765568877799996899877777878999999987777788776004674000000000000', + '000000005776668877799999998877777788994899998787777876036763000000000000', + '000000005777778777789999998777777888997999998798778766447864000000000000', + '000000004778888777778999987777887888999999998777778866568864000000000000', + '000000003776897777677788888877888888999999987777778777787754000000000000', + '000000004677787777667789988888888888899999987777788878887640000000000000', + '000000004687888977766788789988888899989988877777798888778640000000000000', + '000000000578878977767787557899999999898787776777777777778500000000000000', + '000000554058877777767788666667888776679877777776777787777300000000000000', + '000000565047877777776788677877777766679877688667677777786400000000000000', + '000000576546877776777778978998888876698777677698677677775400000000000000', + '000000457646876786767777999999999888987777777677676887864000000000000000', + '000000046656887677676777789999999999877677876776677887650000000000000000', + '000000045555787777688676778999999988777677766678778886504555500000000000', + '000000004567678767766676677778888877766787778777788886456666400000000000', + '000000000057888876766766676777776776666666779777887765567875000000000000', + '000000000034788887777667666777776666677667667778876555766554000000000000', + '000000000000456887777679766666766666666666777878876655643400000000000000', + '000000000000455698777767666766666667667667677778766676000000000000000000', + '000000000004665677877776666766667766777677777788777764000000000000000000', + '000000000004455666787777676666666666777777778887777640000000000000000000', + '000000000000446766688776666776766667777777888766665400000000000000000000', + '000000000000004456667877777777677776897778865665544000000000000000000000', + '000000000000000004556878777888888877787778653446500000000000000000000000', + '000000000000000000444567777776667887777765555004000000000000000000000000', + '000000000000000000000044555555445677778764445400000000000000000000000000', + '000000000000000000000000056445504446556540000000000000000000000000000000', + '000000000000000000000000004005500000000000000000000000000000000000000000', + '000000000000000000000000000000000000000000000000000000000000000000000000', +] as const + +export const REFERENCE_MASK_BOUNDS = { + minX: -1.25, + maxX: 1.25, + minY: -1.08, + maxY: 1.48, +} as const + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +const sampleCell = (column: number, row: number): number => { + const safeRow = rows[clamp(row, 0, rows.length - 1)] + const value = safeRow?.[clamp(column, 0, (safeRow?.length ?? 1) - 1)] + + return value === undefined ? 0 : Number(value) / 9 +} + +export const sampleReferenceDensity = (x: number, y: number): number => { + const u = clamp( + (x - REFERENCE_MASK_BOUNDS.minX) / + (REFERENCE_MASK_BOUNDS.maxX - REFERENCE_MASK_BOUNDS.minX), + 0, + 1, + ) + const v = clamp( + (REFERENCE_MASK_BOUNDS.maxY - y) / + (REFERENCE_MASK_BOUNDS.maxY - REFERENCE_MASK_BOUNDS.minY), + 0, + 1, + ) + const width = rows[0]?.length ?? 1 + const height = rows.length + const px = u * (width - 1) + const py = v * (height - 1) + const left = Math.floor(px) + const top = Math.floor(py) + const right = Math.min(width - 1, left + 1) + const bottom = Math.min(height - 1, top + 1) + const tx = px - left + const ty = py - top + const topValue = sampleCell(left, top) * (1 - tx) + sampleCell(right, top) * tx + const bottomValue = sampleCell(left, bottom) * (1 - tx) + sampleCell(right, bottom) * tx + + return topValue * (1 - ty) + bottomValue * ty +} diff --git a/src/scripts/graph/types.ts b/src/scripts/graph/types.ts index b007399..78c2fb3 100644 --- a/src/scripts/graph/types.ts +++ b/src/scripts/graph/types.ts @@ -1,4 +1,12 @@ -export type GraphNodeRole = 'flame' | 'arm' | 'eye' | 'pupil' | 'mouth' | 'spark' +export type GraphNodeRole = + | 'flame' + | 'rim' + | 'arm' + | 'eye' + | 'pupil' + | 'mouth' + | 'spark' + | 'ember' export type Rgb = readonly [number, number, number] diff --git a/tests/generateGraph.test.ts b/tests/generateGraph.test.ts index a95c546..99e4be1 100644 --- a/tests/generateGraph.test.ts +++ b/tests/generateGraph.test.ts @@ -2,6 +2,17 @@ import assert from 'node:assert/strict' import test from 'node:test' import { createCalciferGraph } from '../src/scripts/graph/generateGraph' +const expectedRoles = new Set([ + 'flame', + 'rim', + 'arm', + 'eye', + 'pupil', + 'mouth', + 'spark', + 'ember', +]) + test('graph generation is deterministic for a fixed seed', () => { const first = createCalciferGraph({ nodeCount: 180, seed: 42 }) const second = createCalciferGraph({ nodeCount: 180, seed: 42 }) @@ -10,7 +21,7 @@ test('graph generation is deterministic for a fixed seed', () => { assert.deepEqual(first.edges, second.edges) }) -test('graph contains a dense fire demon face, raised arms and ambient sparks', () => { +test('graph contains the reference-matched face, contour, arms and detached fire', () => { const graph = createCalciferGraph({ nodeCount: 180, seed: 7 }) const roles = new Set(graph.nodes.map((node) => node.role)) const degree = new Uint16Array(graph.nodes.length) @@ -25,15 +36,36 @@ test('graph contains a dense fire demon face, raised arms and ambient sparks', ( degree[edge.target] = (degree[edge.target] ?? 0) + 1 } - assert.ok(graph.nodes.length >= 430) - assert.deepEqual(roles, new Set(['flame', 'arm', 'eye', 'pupil', 'mouth', 'spark'])) - assert.ok((roleCounts.get('eye') ?? 0) >= 130) - assert.ok((roleCounts.get('pupil') ?? 0) >= 24) - assert.ok((roleCounts.get('mouth') ?? 0) >= 80) - assert.ok((roleCounts.get('spark') ?? 0) >= 90) + assert.ok(graph.nodes.length >= 850) + assert.deepEqual(roles, expectedRoles) + assert.ok((roleCounts.get('flame') ?? 0) >= 180) + assert.ok((roleCounts.get('rim') ?? 0) >= 80) + assert.ok((roleCounts.get('arm') ?? 0) >= 140) + assert.ok((roleCounts.get('eye') ?? 0) >= 220) + assert.ok((roleCounts.get('pupil') ?? 0) >= 36) + assert.ok((roleCounts.get('mouth') ?? 0) >= 170) + assert.ok((roleCounts.get('spark') ?? 0) >= 150) + assert.ok((roleCounts.get('ember') ?? 0) >= 68) assert.ok(Array.from(degree).every((value) => value > 0)) - assert.ok(graph.nodes.every((node) => node.x >= -1.7 && node.x <= 1.7)) - assert.ok(graph.nodes.every((node) => node.y >= -1 && node.y <= 1.65)) + assert.ok(graph.nodes.some((node) => node.size >= 12)) + assert.ok(graph.nodes.some((node) => node.size <= 2)) + assert.ok(graph.nodes.every((node) => node.x >= -2.2 && node.x <= 2.2)) + assert.ok(graph.nodes.every((node) => node.y >= -1.25 && node.y <= 1.9)) +}) + +test('facial landmarks remain large, bright and spatially separated', () => { + const graph = createCalciferGraph({ nodeCount: 220, seed: 2026 }) + const leftEye = graph.nodes.filter((node) => node.role === 'eye' && node.x < 0) + const rightEye = graph.nodes.filter((node) => node.role === 'eye' && node.x > 0) + const pupils = graph.nodes.filter((node) => node.role === 'pupil') + const mouth = graph.nodes.filter((node) => node.role === 'mouth') + + assert.ok(leftEye.length > 100) + assert.ok(rightEye.length > 100) + assert.ok(leftEye.every((node) => node.y > -0.48 && node.y < -0.02)) + assert.ok(rightEye.every((node) => node.y > -0.48 && node.y < -0.02)) + assert.ok(pupils.every((node) => node.color[0] < 0.02 && node.color[1] < 0.02)) + assert.ok(mouth.some((node) => node.y < -0.78)) }) test('all edges reference valid distinct nodes', () => {