data endpoint for entity 90008 (aka. a website)

refactor constellation generation completely

ptr.pet 4d461f06 2168e947

verified
Waiting for spindle ...
+994 -324
+2 -1
deno.json
··· 1 1 { 2 2 "workspace": ["./eunomia"], 3 - "nodeModulesDir": "auto" 3 + "nodeModulesDir": "auto", 4 + "allowScripts": ["npm:esbuild@0.27.2", "npm:protobufjs@7.5.4", "npm:sharp@0.34.5", "npm:skia-canvas@3.0.8"] 4 5 }
+18 -14
deno.lock
··· 20 20 "npm:eslint-plugin-svelte@^3.14.0": "3.14.0_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6", 21 21 "npm:eslint@^9.39.2": "9.39.2", 22 22 "npm:globals@^16.5.0": "16.5.0", 23 + "npm:konva@*": "10.0.12", 23 24 "npm:konva@^10.0.12": "10.0.12", 24 25 "npm:mdsvex@~0.12.6": "0.12.6_svelte@5.46.1__acorn@8.15.0", 25 26 "npm:nanoid@^5.1.6": "5.1.6", ··· 28 29 "npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0", 29 30 "npm:prettier@^3.7.4": "3.7.4", 30 31 "npm:prometheus-remote-write@~0.5.1": "0.5.1_node-fetch@3.3.2", 32 + "npm:random@*": "5.4.1", 33 + "npm:random@^5.4.1": "5.4.1", 31 34 "npm:robots-parser@^3.0.1": "3.0.1", 35 + "npm:sharp@*": "0.34.5", 32 36 "npm:sharp@~0.34.5": "0.34.5", 37 + "npm:simplex-noise@^4.0.3": "4.0.3", 33 38 "npm:skia-canvas@^3.0.8": "3.0.8", 34 39 "npm:steamgriddb@^2.2.1": "2.2.1", 35 40 "npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", ··· 916 921 "@types/node-schedule@2.1.8": { 917 922 "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", 918 923 "dependencies": [ 919 - "@types/node@22.15.15" 920 - ] 921 - }, 922 - "@types/node@22.15.15": { 923 - "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 924 - "dependencies": [ 925 - "undici-types@6.21.0" 924 + "@types/node" 926 925 ] 927 926 }, 928 927 "@types/node@25.0.6": { 929 928 "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", 930 929 "dependencies": [ 931 - "undici-types@7.16.0" 930 + "undici-types" 932 931 ] 933 932 }, 934 933 "@types/resolve@1.20.2": { ··· 2074 2073 "@protobufjs/path", 2075 2074 "@protobufjs/pool", 2076 2075 "@protobufjs/utf8", 2077 - "@types/node@25.0.6", 2076 + "@types/node", 2078 2077 "long" 2079 2078 ], 2080 2079 "scripts": true ··· 2090 2089 }, 2091 2090 "quick-lru@7.3.0": { 2092 2091 "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==" 2092 + }, 2093 + "random@5.4.1": { 2094 + "integrity": "sha512-HtccRkYkAXCbj9bqsyGKGlicyeZ5AsQgs49fEuUO/BvrJ7WOQqXPjdg1CZrFjBkoT75ozrWlQXJ7TcXXLv2ISQ==" 2093 2095 }, 2094 2096 "rate-limit-threshold@0.1.5": { 2095 2097 "integrity": "sha512-75vpvXC/ZqQJrFDp0dVtfoXZi8kxQP2eBuxVYFvGDfnHhcgE+ZG870u4ItQhWQh54Y6nNwOaaq5g3AL9n27lTg==" ··· 2224 2226 }, 2225 2227 "shebang-regex@3.0.0": { 2226 2228 "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" 2229 + }, 2230 + "simplex-noise@4.0.3": { 2231 + "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==" 2227 2232 }, 2228 2233 "sirv@3.0.2": { 2229 2234 "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", ··· 2458 2463 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 2459 2464 "bin": true 2460 2465 }, 2461 - "undici-types@6.21.0": { 2462 - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 2463 - }, 2464 2466 "undici-types@7.16.0": { 2465 2467 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 2466 2468 }, ··· 2516 2518 "vite@7.3.1_@types+node@25.0.6_picomatch@4.0.3": { 2517 2519 "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", 2518 2520 "dependencies": [ 2519 - "@types/node@25.0.6", 2521 + "@types/node", 2520 2522 "esbuild", 2521 2523 "fdir", 2522 2524 "picomatch@4.0.3", ··· 2528 2530 "fsevents" 2529 2531 ], 2530 2532 "optionalPeers": [ 2531 - "@types/node@25.0.6" 2533 + "@types/node" 2532 2534 ], 2533 2535 "bin": true 2534 2536 }, ··· 2599 2601 "npm:prettier-plugin-svelte@^3.4.1", 2600 2602 "npm:prettier@^3.7.4", 2601 2603 "npm:prometheus-remote-write@~0.5.1", 2604 + "npm:random@^5.4.1", 2602 2605 "npm:robots-parser@^3.0.1", 2603 2606 "npm:sharp@~0.34.5", 2607 + "npm:simplex-noise@^4.0.3", 2604 2608 "npm:skia-canvas@^3.0.8", 2605 2609 "npm:steamgriddb@^2.2.1", 2606 2610 "npm:svelte-check@^4.3.5",
+2
eunomia/package.json
··· 46 46 "nanoid": "^5.1.6", 47 47 "node-fetch": "^3.3.2", 48 48 "prometheus-remote-write": "^0.5.1", 49 + "random": "^5.4.1", 49 50 "robots-parser": "^3.0.1", 50 51 "sharp": "^0.34.5", 52 + "simplex-noise": "^4.0.3", 51 53 "steamgriddb": "^2.2.1", 52 54 "toad-scheduler": "^3.1.0", 53 55 "konva": "^10.0.12",
+36 -8
eunomia/src/components/constellationOverlay.svelte
··· 16 16 timestamp: string; 17 17 angleY: number; 18 18 angleX: number; 19 + seed?: number; 19 20 }; 20 21 } 21 22 ··· 29 30 let containerWidth = $state(0); 30 31 let containerHeight = $state(0); 31 32 33 + let tauShift = $derived.by(() => { 34 + if (stars?.meta?.seed === undefined) return 'UNKNOWN'; 35 + const main = Math.abs(stars.meta.seed % 360); 36 + const sub = Math.abs((stars.meta.seed % 10000) / 10000); 37 + return (main + sub).toFixed(4); 38 + }); 39 + 40 + let lambdaLock = $derived.by(() => { 41 + if (stars?.meta?.seed === undefined) return 'UNKNOWN'; 42 + return ((stars.meta.seed * 1.618) % 100).toFixed(4); 43 + }); 44 + let manifoldId = $derived.by(() => { 45 + if (stars?.meta?.seed === undefined) return 'UNKNOWN'; 46 + const hex = stars.meta.seed.toString(16).toUpperCase(); 47 + const chunks = hex.match(/.{1,5}/g)?.join('-') ?? hex; 48 + return chunks; 49 + }); 50 + 32 51 let scale = $derived.by(() => { 33 52 if (!stars || containerWidth === 0 || containerHeight === 0) return 0; 34 53 ··· 58 77 {#if stars && scale > 0} 59 78 <div class="fixed inset-0 pointer-events-none {isUIHidden ? 'z-[2000]' : 'z-0'} overflow-hidden"> 60 79 {#if stars.meta} 61 - <div class="absolute top-4 left-4 origin-top-left meta-text"> 62 - <div>DATE: {stars.meta.timestamp}</div> 63 - <div>ASCENSION: {(stars.meta.angleY * (180 / Math.PI)).toFixed(4)}°</div> 64 - <div>DECLINATION: {(stars.meta.angleX * (180 / Math.PI)).toFixed(4)}°</div> 80 + <div class="absolute font-mono top-4 left-4 origin-top-left meta-text"> 81 + <div>//DATE/{stars.meta.timestamp}</div> 82 + <div>&nbsp;/ASCENSION/{(stars.meta.angleY * (180 / Math.PI)).toFixed(4)}°</div> 83 + <div>&nbsp;/DECLINATION/{(stars.meta.angleX * (180 / Math.PI)).toFixed(4)}°/</div> 65 84 </div> 66 - <div class="absolute top-4 right-4 origin-top-right meta-text"> 67 - <!-- encode meta data in dollcode --> 85 + <div 86 + class="absolute font-mono top-4 left-1/2 -translate-x-1/2 origin-top meta-text text-center" 87 + > 68 88 {genDollcode( 69 - new Date(stars.meta.timestamp).getTime() + stars.meta.angleY + stars.meta.angleX 89 + new Date(stars.meta.timestamp).getTime() + 90 + stars.meta.angleY + 91 + stars.meta.angleX + 92 + (stars.meta.seed ?? 0) 70 93 )} 71 94 </div> 95 + <div class="absolute top-4 right-4 origin-top-right meta-text text-right"> 96 + <div>//TAU SHIFT/{tauShift}°</div> 97 + <div>&nbsp;/LAMBDA LOCK/{lambdaLock}Gpc</div> 98 + <div>&nbsp;/MANIFOLD/{manifoldId}/</div> 99 + </div> 72 100 {/if} 73 101 {#each stars.stars as star} 74 102 {@const screenX = star.x * scale + offsetX} 75 103 {@const screenY = star.y * scale + offsetY} 76 104 {@const radius = star.r * scale} 77 105 78 - <!-- Only render if potentially visible --> 106 + <!-- only render if potentially visible --> 79 107 {#if screenX > -50 && screenX < containerWidth + 50 && screenY > -50 && screenY < containerHeight + 50} 80 108 <a 81 109 href="https://{star.domain}"
+904 -274
eunomia/src/lib/constellation.ts
··· 3 3 import { writeFile, readFile, stat, mkdir } from 'node:fs/promises'; 4 4 import { join } from 'node:path'; 5 5 import { env } from '$env/dynamic/private'; 6 + // const env = Deno.env.toObject(); 6 7 import type { Canvas } from 'skia-canvas'; 7 8 import sharp from 'sharp'; 9 + import random from 'random'; 10 + import { createNoise3D } from 'simplex-noise'; 11 + import { dev } from '$app/environment'; 8 12 9 13 const DATA_DIR = join(env.WEBSITE_DATA_DIR ?? '', 'constellation'); 10 14 const GRAPH_FILE = join(DATA_DIR, 'graph_processed.json'); ··· 41 45 stars: Star[]; 42 46 nebulae: Nebula[]; 43 47 dust: Dust[]; 48 + seed: number; 44 49 }; 45 50 46 - // Deterministic implementation with SeededRNG 47 - class SeededRNG { 48 - private seed: number; 49 - constructor(seed: number) { 50 - this.seed = seed; 51 + 52 + 53 + export const generateConstellationData = ( 54 + data: GraphData, 55 + seed: number = 567238047896 56 + ): ConstellationData => { 57 + const rng = random.clone(seed); 58 + 59 + // seeded prng for noise (mulberry32) 60 + const mulberry32 = (a: number) => () => { 61 + let t = (a += 0x6d2b79f5); 62 + t = Math.imul(t ^ (t >>> 15), t | 1); 63 + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 64 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 65 + }; 66 + const noise3D = createNoise3D(mulberry32(seed)); 67 + const NOISE_SCALE = 0.0012; // controls feature size of cosmic structures 68 + 69 + // sample density at a point (returns 0-1, higher = denser cosmic region) 70 + const cosmicDensity = (p: { x: number; y: number; z: number }): number => { 71 + const n1 = noise3D(p.x * NOISE_SCALE, p.y * NOISE_SCALE, p.z * NOISE_SCALE); 72 + const n2 = noise3D(p.x * NOISE_SCALE * 2, p.y * NOISE_SCALE * 2, p.z * NOISE_SCALE * 2) * 0.5; 73 + const combined = (n1 + n2) / 1.5; 74 + return (combined + 1) / 2; // normalize to 0-1 75 + }; 76 + 77 + const SPHERE_RADIUS = 1800; 78 + const MAX_STARS = 750; // Target total stars 79 + 80 + // --- Vector Math Helpers --- 81 + type Vec3 = { x: number; y: number; z: number }; 82 + 83 + const vecAdd = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }); 84 + const vecSub = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }); 85 + const vecScale = (v: Vec3, s: number): Vec3 => ({ x: v.x * s, y: v.y * s, z: v.z * s }); 86 + const vecLen = (v: Vec3): number => Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); 87 + const vecNorm = (v: Vec3): Vec3 => { 88 + const l = vecLen(v); 89 + return l === 0 ? { x: 0, y: 0, z: 0 } : vecScale(v, 1 / l); 90 + }; 91 + const vecDot = (a: Vec3, b: Vec3): number => a.x * b.x + a.y * b.y + a.z * b.z; 92 + const vecCross = (a: Vec3, b: Vec3): Vec3 => ({ 93 + x: a.y * b.z - a.z * b.y, 94 + y: a.z * b.x - a.x * b.z, 95 + z: a.x * b.y - a.y * b.x 96 + }); 97 + const vecDistSq = (a: Vec3, b: Vec3): number => { 98 + const dx = a.x - b.x; 99 + const dy = a.y - b.y; 100 + const dz = a.z - b.z; 101 + return dx * dx + dy * dy + dz * dz; 102 + }; 103 + 104 + // geodesic (great-circle) arc distance on sphere surface 105 + const geodesicDist = (a: Vec3, b: Vec3): number => { 106 + const dotProduct = vecDot(a, b) / (SPHERE_RADIUS * SPHERE_RADIUS); 107 + const clamped = Math.max(-1, Math.min(1, dotProduct)); 108 + return SPHERE_RADIUS * Math.acos(clamped); 109 + }; 110 + 111 + // Rotate vector v around axis k by angle theta 112 + const vecRotate = (v: Vec3, k: Vec3, theta: number): Vec3 => { 113 + const cos = Math.cos(theta); 114 + const sin = Math.sin(theta); 115 + const cross = vecCross(k, v); 116 + const dot = vecDot(k, v); 117 + return vecAdd( 118 + vecAdd(vecScale(v, cos), vecScale(cross, sin)), 119 + vecScale(k, dot * (1 - cos)) 120 + ); 121 + }; 122 + 123 + // --- Geometric Shape Generation --- 124 + 125 + interface ShapeNode { 126 + id: number; 127 + pos: Vec3; 128 + adj: number[]; // Connected node IDs within this shape 51 129 } 52 130 53 - next(): number { 54 - this.seed = (this.seed * 1664525 + 1013904223) % 4294967296; 55 - return this.seed / 4294967296; 131 + interface ConstellationShape { 132 + id: number; 133 + nodes: ShapeNode[]; 134 + aabb2d: { minX: number; minY: number; maxX: number; maxY: number }; 56 135 } 57 136 58 - range(min: number, max: number): number { 59 - return min + this.next() * (max - min); 137 + const STEP_SIZE_MIN = 80; 138 + const STEP_SIZE_MAX = 140; 139 + const MIN_NODE_DIST = STEP_SIZE_MIN * 0.7; // Minimum distance between stars 140 + const CONSTELLATION_PADDING = STEP_SIZE_MAX; // Extra padding for AABB checks 141 + 142 + const shapes: ConstellationShape[] = []; 143 + let shapeIdCounter = 0; 144 + 145 + // Ratios for constellation sizes 146 + const SIZE_RATIOS: Record<number, number> = { 147 + 3: 0.05, 4: 0.10, 5: 0.20, 6: 0.25, 7: 0.20, 8: 0.15, 9: 0.05 148 + }; 149 + 150 + // Shape modes control how constellations grow 151 + type ShapeMode = 'linear' | 'zigzag' | 'looped' | 'branching'; 152 + const SHAPE_MODE_RATIOS: Record<ShapeMode, number> = { 153 + linear: 0.10, // mostly straight lines 154 + zigzag: 0.35, // sharp direction changes 155 + looped: 0.35, // intentionally curls back on itself 156 + branching: 0.20 // has multiple offshoots 157 + }; 158 + 159 + // Create a pool of target sizes and modes to pick from 160 + const pendingShapes: { size: number; mode: ShapeMode }[] = []; 161 + { 162 + let sizeSum = 0; 163 + for (const r of Object.values(SIZE_RATIOS)) sizeSum += r; 164 + const tempTotal = MAX_STARS / 6; 165 + 166 + const modeKeys = Object.keys(SHAPE_MODE_RATIOS) as ShapeMode[]; 167 + let modeSum = 0; 168 + for (const r of Object.values(SHAPE_MODE_RATIOS)) modeSum += r; 169 + 170 + for (const [sizeStr, sizeRatio] of Object.entries(SIZE_RATIOS)) { 171 + const size = parseInt(sizeStr); 172 + const count = Math.round(tempTotal * (sizeRatio / sizeSum)); 173 + for (let k = 0; k < count; k++) { 174 + // pick shape mode based on ratios 175 + const roll = rng.float(0, modeSum); 176 + let cumulative = 0; 177 + let mode: ShapeMode = 'linear'; 178 + for (const m of modeKeys) { 179 + cumulative += SHAPE_MODE_RATIOS[m]; 180 + if (roll < cumulative) { 181 + mode = m; 182 + break; 183 + } 184 + } 185 + pendingShapes.push({ size, mode }); 186 + } 187 + } 188 + // Shuffle 189 + for (let i = pendingShapes.length - 1; i > 0; i--) { 190 + const j = Math.floor(rng.float(0, 1) * (i + 1)); 191 + [pendingShapes[i], pendingShapes[j]] = [pendingShapes[j], pendingShapes[i]]; 192 + } 60 193 } 61 - } 62 194 63 - export const generateConstellationData = ( 64 - data: GraphData, 65 - seed: number = 123456 66 - ): ConstellationData => { 67 - const rng = new SeededRNG(seed); 195 + const updateAABB = (shape: ConstellationShape) => { 196 + if (shape.nodes.length === 0) return; 197 + let minX = Infinity, minY = Infinity; 198 + let maxX = -Infinity, maxY = -Infinity; 199 + for (const n of shape.nodes) { 200 + minX = Math.min(minX, n.pos.x); 201 + minY = Math.min(minY, n.pos.y); 202 + maxX = Math.max(maxX, n.pos.x); 203 + maxY = Math.max(maxY, n.pos.y); 204 + } 205 + shape.aabb2d = { 206 + minX: minX - CONSTELLATION_PADDING, 207 + minY: minY - CONSTELLATION_PADDING, 208 + maxX: maxX + CONSTELLATION_PADDING, 209 + maxY: maxY + CONSTELLATION_PADDING 210 + }; 211 + }; 68 212 69 - // Configuration 70 - const MAX_STARS = 2000; 71 - const CLUSTER_SIZE_MAX = 8; 72 - const MIN_DIST = 140; 73 - const STEP_SIZE = 150; 74 - const ATTEMPTS = 32; 213 + const dist2D = (a: Vec3, b: Vec3): number => { 214 + const dx = a.x - b.x; 215 + const dy = a.y - b.y; 216 + return Math.sqrt(dx * dx + dy * dy); 217 + }; 75 218 76 - // Limits 77 - const ROOT_DOMAIN_LIMIT_RATIO = 0.025; // 2.5% of MAX_STARS 78 - const TOTAL_ROOT_DOMAIN_LIMIT_RATIO = 0.2; // 20% of MAX_STARS 79 - const MAX_PER_ROOT = Math.floor(MAX_STARS * ROOT_DOMAIN_LIMIT_RATIO); 80 - const MAX_TOTAL_ROOT = Math.floor(MAX_STARS * TOTAL_ROOT_DOMAIN_LIMIT_RATIO); 81 - const SPECIAL_ROOTS = new Set([ 82 - 'neocities.org', 83 - 'wordpress.com', 84 - 'blogspot.com', 85 - 'blogfree.net', 86 - 'forumfree.it', 87 - 'forumcommunity.net', 88 - 'proboards.com', 89 - 'boards.net', 90 - 'jcink.net', 91 - 'forumactif.net', 92 - 'forumactif.org', 93 - 'forumactif.com', 94 - 'freeforums.net' 95 - ]); 219 + const checkCollision = (p: Vec3, ignoreShapeId: number = -1): boolean => { 220 + for (const shape of shapes) { 221 + if (shape.id === ignoreShapeId) continue; 222 + if (p.x < shape.aabb2d.minX || p.x > shape.aabb2d.maxX || 223 + p.y < shape.aabb2d.minY || p.y > shape.aabb2d.maxY) { 224 + continue; 225 + } 226 + for (const node of shape.nodes) { 227 + if (dist2D(p, node.pos) < MIN_NODE_DIST) return true; 228 + } 229 + } 230 + return false; 231 + }; 96 232 97 - const getRootDomain = (domain: string): string => { 98 - const parts = domain.split('.'); 99 - if (parts.length > 2) { 100 - const root = parts.slice(-2).join('.'); 101 - if (SPECIAL_ROOTS.has(root)) return root; 233 + const checkSelfCollision = (p: Vec3, shape: ConstellationShape): boolean => { 234 + for (const node of shape.nodes) { 235 + if (dist2D(p, node.pos) < MIN_NODE_DIST) return true; 102 236 } 103 - return domain; // Default to full domain if not special 237 + return false; 104 238 }; 105 239 106 - const rootDomainCounts = new Map<string, number>(); 240 + type ShapeMeta = { mode: ShapeMode; snapped: boolean; curled: boolean }; 241 + const shapeMetas: Map<number, ShapeMeta> = new Map(); 242 + 243 + // --- 1. Generate Shapes --- 244 + while (pendingShapes.length > 0) { 245 + const { size: targetSize, mode: shapeMode } = pendingShapes.pop()!; 246 + const shape: ConstellationShape = { 247 + id: shapeIdCounter++, 248 + nodes: [], 249 + aabb2d: { minX: 0, minY: 0, maxX: 0, maxY: 0 } 250 + }; 107 251 108 - // 1. Filter and Collect Stars 109 - const allDomains = Object.keys(data.linksTo); 110 - const validDomains = new Set<string>(allDomains); 252 + // mode-specific parameters (angles in radians) 253 + const modeConfig = { 254 + linear: { minAngle: 0, angleRange: Math.PI / 3, branchChance: 0.05, curlBackChance: 0.0, snapThreshold: 0.7 }, 255 + zigzag: { minAngle: Math.PI / 2.5, angleRange: (2 * Math.PI) / 3, branchChance: 0.10, curlBackChance: 0.0, snapThreshold: 0.6 }, 256 + looped: { minAngle: 0, angleRange: Math.PI / 2, branchChance: 0.05, curlBackChance: 0.50, snapThreshold: 0.3 }, 257 + branching: { minAngle: 0, angleRange: Math.PI / 2, branchChance: 0.40, curlBackChance: 0.0, snapThreshold: 0.6 } 258 + }[shapeMode]; 259 + 260 + // start point - use noise-weighted candidate selection 261 + let startPos: Vec3 = { x: 0, y: 0, z: 0 }; 262 + let placedStart = false; 263 + 264 + // generate candidate positions and score them by cosmic density 265 + const NUM_CANDIDATES = 15; 266 + const candidates: { pos: Vec3; density: number }[] = []; 111 267 112 - // 2. Greedy Clustering 113 - const stars: Star[] = []; 114 - const visited = new Set<string>(); 115 - const clusters: { center: { x: number; y: number; z: number }; stars: Star[] }[] = []; 268 + for (let c = 0; c < NUM_CANDIDATES; c++) { 269 + const u = rng.float(0, 1); 270 + const v = rng.float(0, 1); 271 + const theta = 2 * Math.PI * u; 272 + const phi = Math.acos(2 * v - 1); 273 + const pos: Vec3 = { 274 + x: SPHERE_RADIUS * Math.sin(phi) * Math.cos(theta), 275 + y: SPHERE_RADIUS * Math.sin(phi) * Math.sin(theta), 276 + z: SPHERE_RADIUS * Math.cos(phi) 277 + }; 116 278 117 - // Shuffle domains deterministically 118 - for (let i = allDomains.length - 1; i > 0; i--) { 119 - const j = Math.floor(rng.next() * (i + 1)); 120 - [allDomains[i], allDomains[j]] = [allDomains[j], allDomains[i]]; 121 - } 279 + if (!checkCollision(pos)) { 280 + let density = cosmicDensity(pos); 122 281 123 - for (const domain of allDomains) { 124 - if (visited.has(domain)) continue; 125 - if (stars.length >= MAX_STARS) break; 282 + // Anti-clustering: Repulsion from same-mode shapes 283 + let repulsion = 0; 284 + const REPULSION_RADIUS = 700; 285 + const REPULSION_WEIGHT = 1.2; 126 286 127 - const root = getRootDomain(domain); 128 - const currentCount = rootDomainCounts.get(root) || 0; 287 + for (const [id, meta] of shapeMetas) { 288 + if (meta.mode === shapeMode) { 289 + const otherShape = shapes.find(s => s.id === id); 290 + if (otherShape && otherShape.nodes.length > 0) { 291 + const distSq = vecDistSq(pos, otherShape.nodes[0].pos); 292 + repulsion += Math.exp(-distSq / (REPULSION_RADIUS * REPULSION_RADIUS)); 293 + } 294 + } 295 + } 129 296 130 - // Only limit if it is one of the special roots 131 - if (SPECIAL_ROOTS.has(root) && currentCount >= MAX_PER_ROOT) continue; 297 + // Apply penalty, keeping a small positive minimum to allow placement if necessary 298 + density = Math.max(0.001, density - repulsion * REPULSION_WEIGHT); 132 299 133 - // or if the total number of special roots exceeds the limit 134 - const totalSpecialCount = rootDomainCounts 135 - .entries() 136 - .filter(([root]) => SPECIAL_ROOTS.has(root)) 137 - .reduce((a, [, b]) => a + b, 0); 138 - if (totalSpecialCount >= MAX_TOTAL_ROOT) continue; 300 + candidates.push({ pos, density }); 301 + } 302 + } 139 303 140 - // Start a new cluster 141 - const clusterStars: Star[] = []; 142 - const stack: string[] = [domain]; 304 + if (candidates.length > 0) { 305 + // pick from top candidates weighted by density 306 + candidates.sort((a, b) => b.density - a.density); 307 + const topN = Math.min(5, candidates.length); 308 + const topCandidates = candidates.slice(0, topN); 143 309 144 - // We do NOT add to visited yet, we do it when we actually push to clusterStars 310 + // weighted random selection among top candidates 311 + const totalDensity = topCandidates.reduce((sum, c) => sum + c.density, 0); 312 + let roll = rng.float(0, totalDensity); 313 + let chosen = topCandidates[0]; 314 + for (const c of topCandidates) { 315 + roll -= c.density; 316 + if (roll <= 0) { 317 + chosen = c; 318 + break; 319 + } 320 + } 321 + startPos = chosen.pos; 322 + placedStart = true; 323 + } 145 324 146 - const skeletonEdges: [string, string][] = []; 147 - const parents = new Map<string, string>(); 325 + if (!placedStart) continue; 148 326 149 - // Fill cluster (greedy DFS) 150 - while (stack.length > 0 && clusterStars.length < CLUSTER_SIZE_MAX) { 151 - const current = stack.pop()!; 327 + shape.nodes.push({ id: 0, pos: startPos, adj: [] }); 328 + updateAABB(shape); 152 329 153 - if (visited.has(current)) continue; 330 + let failedGrowth = false; 331 + let prevDir: Vec3 | null = null; 332 + let activeTipId = 0; 333 + let prevStepDist = 0; 334 + let prevAngleSign = 0; 154 335 155 - const currentRoot = getRootDomain(current); 156 - const count = rootDomainCounts.get(currentRoot) || 0; 157 - if (SPECIAL_ROOTS.has(currentRoot) && count >= MAX_PER_ROOT) continue; 336 + for (let i = 1; i < targetSize; i++) { 337 + let placed = false; 338 + let parentId = activeTipId; 339 + let isBranching = false; 158 340 159 - visited.add(current); 160 - rootDomainCounts.set(currentRoot, count + 1); 341 + const starsLeft = targetSize - i; 342 + const canBranch = starsLeft >= 2; 343 + const progress = i / targetSize; 161 344 162 - const links = data.linksTo[current] || []; 345 + // determine if we should branch based on mode 346 + if (canBranch && rng.float(0, 1) < modeConfig.branchChance) { 347 + isBranching = true; 348 + } 163 349 164 - clusterStars.push({ 165 - domain: current, 166 - x: 0, 167 - y: 0, 168 - z: 0, 169 - connections: links, 170 - visualConnections: [] 171 - }); 350 + const tipNode = shape.nodes.find(n => n.id === activeTipId)!; 351 + if (tipNode.adj.length >= 2 && rng.float(0, 1) < 0.7) { 352 + isBranching = true; 353 + } 172 354 173 - const parent = parents.get(current); 174 - if (parent) skeletonEdges.push([parent, current]); 355 + if (isBranching) { 356 + const candidates = shape.nodes.filter(n => n.adj.length < 3); 357 + if (candidates.length === 0) { 358 + failedGrowth = true; 359 + break; 360 + } 361 + const p = candidates[Math.floor(rng.float(0, candidates.length))]; 362 + parentId = p.id; 363 + } 175 364 176 - const neighbors: string[] = []; 177 - for (const link of links) { 178 - if (!visited.has(link) && validDomains.has(link)) { 179 - neighbors.push(link); 180 - // Do not mark visited here, wait until we pop 181 - parents.set(link, current); 365 + let parent = shape.nodes.find(n => n.id === parentId); 366 + if (!parent || parent.adj.length >= 3) { 367 + const candidates = shape.nodes.filter(n => n.adj.length < 3); 368 + if (candidates.length === 0) { 369 + failedGrowth = true; 370 + break; 182 371 } 372 + parent = candidates[Math.floor(rng.float(0, candidates.length))]; 373 + parentId = parent.id; 183 374 } 184 375 185 - // Randomize neighbors for DFS 186 - for (let i = neighbors.length - 1; i > 0; i--) { 187 - const j = Math.floor(rng.next() * (i + 1)); 188 - [neighbors[i], neighbors[j]] = [neighbors[j], neighbors[i]]; 376 + const normal = vecNorm(parent.pos); 377 + let tangent = vecCross(normal, { x: 0, y: 1, z: 0 }); 378 + if (vecLen(tangent) < 0.01) tangent = vecCross(normal, { x: 1, y: 0, z: 0 }); 379 + tangent = vecNorm(tangent); 380 + 381 + let baseDir = tangent; 382 + if (parentId === activeTipId && i > 1 && prevDir && !isBranching) { 383 + baseDir = vecNorm(prevDir); 189 384 } 190 - stack.push(...neighbors); 191 - } 192 385 193 - if (clusterStars.length > 0) { 194 - clusters.push({ center: { x: 0, y: 0, z: 0 }, stars: clusterStars }); 386 + let snapped = false; 387 + let didCurl = false; 195 388 196 - // Map stars for quick lookup 197 - const clusterMap = new Map<string, Star>(); 198 - clusterStars.forEach((s) => clusterMap.set(s.domain, s)); 389 + // curl-back: try to move toward an existing node (for looped mode) 390 + let curlTarget: Vec3 | null = null; 391 + if (modeConfig.curlBackChance > 0 && rng.float(0, 1) < modeConfig.curlBackChance && shape.nodes.length >= 3) { 392 + // find a node that's not the parent and not adjacent to parent 393 + const curlCandidates = shape.nodes.filter(n => 394 + n.id !== parentId && 395 + !parent!.adj.includes(n.id) && 396 + n.adj.length < 3 397 + ); 398 + if (curlCandidates.length > 0) { 399 + const target = curlCandidates[Math.floor(rng.float(0, curlCandidates.length))]; 400 + curlTarget = target.pos; 401 + } 402 + } 199 403 200 - // Build dual-linked visual connections 201 - skeletonEdges.forEach(([src, dst]) => { 202 - const s1 = clusterMap.get(src); 203 - const s2 = clusterMap.get(dst); 204 - if (s1 && s2) { 205 - s1.visualConnections.push(dst); 206 - s2.visualConnections.push(src); 404 + for (let tryIdx = 0; tryIdx < 25; tryIdx++) { 405 + let newDir: Vec3; 406 + 407 + if (curlTarget && tryIdx < 10) { 408 + // more tries moving toward curl target 409 + const toTarget = vecSub(curlTarget, parent.pos); 410 + const projected = vecSub(toTarget, vecScale(normal, vecDot(toTarget, normal))); 411 + if (vecLen(projected) > 0.01) { 412 + const jitter = (rng.float(0, 1) - 0.5) * (Math.PI / 6); 413 + newDir = vecRotate(vecNorm(projected), normal, jitter); 414 + } else { 415 + newDir = vecRotate(tangent, normal, rng.float(0, 2 * Math.PI)); 416 + } 417 + } else if (!isBranching && parentId === activeTipId && prevDir) { 418 + // mode-specific angle deviation with minimum enforced 419 + // bias toward alternating direction to avoid straight lines 420 + let sign: number; 421 + if (prevAngleSign !== 0 && rng.float(0, 1) < 0.7) { 422 + sign = -prevAngleSign; // 70% chance to flip direction 423 + } else { 424 + sign = rng.float(0, 1) < 0.5 ? -1 : 1; 425 + } 426 + const angleMagnitude = modeConfig.minAngle + rng.float(0, 1) * (modeConfig.angleRange - modeConfig.minAngle); 427 + const angle = sign * angleMagnitude; 428 + newDir = vecRotate(baseDir, normal, angle); 429 + prevAngleSign = sign; 430 + } else { 431 + const angle = rng.float(0, 2 * Math.PI); 432 + newDir = vecRotate(tangent, normal, angle); 433 + } 434 + 435 + // enforce variation in step distance (at least 20% different from previous) 436 + const distRange = STEP_SIZE_MAX - STEP_SIZE_MIN; 437 + const minVariation = distRange * 0.2; 438 + let dist: number; 439 + if (prevStepDist > 0) { 440 + // pick from either low or high side, avoiding previous value 441 + const lowMax = Math.max(STEP_SIZE_MIN, prevStepDist - minVariation); 442 + const highMin = Math.min(STEP_SIZE_MAX, prevStepDist + minVariation); 443 + if (rng.float(0, 1) < 0.5 && lowMax > STEP_SIZE_MIN) { 444 + dist = rng.float(STEP_SIZE_MIN, lowMax); 445 + } else if (highMin < STEP_SIZE_MAX) { 446 + dist = rng.float(highMin, STEP_SIZE_MAX); 447 + } else { 448 + dist = rng.float(STEP_SIZE_MIN, STEP_SIZE_MAX); 449 + } 450 + } else { 451 + dist = rng.float(STEP_SIZE_MIN, STEP_SIZE_MAX); 452 + } 453 + prevStepDist = dist; 454 + 455 + let nextPos = vecAdd(parent.pos, vecScale(newDir, dist)); 456 + nextPos = vecScale(vecNorm(nextPos), SPHERE_RADIUS); 457 + 458 + // expanded snapping: trigger based on mode threshold instead of just last 2 stars 459 + if (progress >= modeConfig.snapThreshold) { 460 + // 1. try snapping to edge midpoints (splits edge) 461 + for (const u of shape.nodes) { 462 + if (snapped) break; 463 + for (const vId of u.adj) { 464 + if (vId < u.id) continue; 465 + const v = shape.nodes.find(n => n.id === vId)!; 466 + if (u.id === parent!.id || v.id === parent!.id) continue; 467 + 468 + const mid = vecScale(vecAdd(u.pos, v.pos), 0.5); 469 + const snapDist = dist * (shapeMode === 'looped' ? 0.6 : 0.4); 470 + if (vecDistSq(nextPos, mid) < snapDist ** 2) { 471 + nextPos = vecScale(vecNorm(mid), SPHERE_RADIUS); 472 + 473 + u.adj = u.adj.filter(x => x !== v.id); 474 + v.adj = v.adj.filter(x => x !== u.id); 475 + 476 + if (checkCollision(nextPos, shape.id) || checkSelfCollision(nextPos, shape)) { 477 + u.adj.push(v.id); 478 + v.adj.push(u.id); 479 + continue; 480 + } 481 + 482 + const newNode: ShapeNode = { id: i, pos: nextPos, adj: [u.id, v.id] }; 483 + shape.nodes.push(newNode); 484 + u.adj.push(i); 485 + v.adj.push(i); 486 + 487 + snapped = true; 488 + placed = true; 489 + activeTipId = i; 490 + break; 491 + } 492 + } 493 + } 494 + 495 + // 2. try snapping directly to existing nodes (creates a loop) 496 + if (!snapped && shapeMode === 'looped') { 497 + const nodeSnapDist = dist * 1.5; 498 + for (const candidate of shape.nodes) { 499 + if (candidate.id === parent!.id) continue; 500 + if (parent!.adj.includes(candidate.id)) continue; 501 + if (candidate.adj.length >= 3) continue; 502 + 503 + if (vecDistSq(nextPos, candidate.pos) < nodeSnapDist ** 2) { 504 + if (checkCollision(nextPos, shape.id) || checkSelfCollision(nextPos, shape)) continue; 505 + 506 + const newNode: ShapeNode = { id: i, pos: nextPos, adj: [parent!.id, candidate.id] }; 507 + shape.nodes.push(newNode); 508 + parent!.adj.push(i); 509 + candidate.adj.push(i); 510 + snapped = true; 511 + placed = true; 512 + activeTipId = i; 513 + break; 514 + } 515 + } 516 + } 207 517 } 208 - }); 209 518 210 - stars.push(...clusterStars); 519 + if (snapped) break; 520 + 521 + if (!checkCollision(nextPos, shape.id) && !checkSelfCollision(nextPos, shape)) { 522 + const newNode: ShapeNode = { id: i, pos: nextPos, adj: [parent.id] }; 523 + shape.nodes.push(newNode); 524 + parent.adj.push(i); 525 + prevDir = vecSub(nextPos, parent.pos); 526 + placed = true; 527 + updateAABB(shape); 528 + activeTipId = i; 529 + break; 530 + } 531 + } 532 + 533 + if (!placed && !snapped) { 534 + failedGrowth = true; 535 + break; 536 + } 211 537 } 212 - } 213 538 214 - // 3. Layout Clusters on Fibonacci Sphere 215 - const phi = Math.PI * (3 - Math.sqrt(5)); 216 - const sphereRadius = 1800; 539 + if (!failedGrowth) { 540 + // post-processing: connect parallel branches for branching mode 541 + if (shapeMode === 'branching' && shape.nodes.length >= 4) { 542 + // find leaf nodes (degree 1) 543 + const leaves = shape.nodes.filter(n => n.adj.length === 1); 544 + const MAX_LADDER_DIST = STEP_SIZE_MAX * 1.8; 217 545 218 - for (let i = 0; i < clusters.length; i++) { 219 - const y = 1 - (i / (clusters.length - 1)) * 2; 220 - const radiusAtY = Math.sqrt(1 - y * y); 221 - const theta = phi * i; 546 + // helper: get ancestors up to N hops 547 + const getAncestors = (nodeId: number, maxHops: number): number[] => { 548 + const ancestors: number[] = []; 549 + let current = nodeId; 550 + for (let h = 0; h < maxHops; h++) { 551 + const node = shape.nodes.find(n => n.id === current); 552 + if (!node || node.adj.length === 0) break; 553 + const parent = node.adj[0]; 554 + ancestors.push(parent); 555 + current = parent; 556 + } 557 + return ancestors; 558 + }; 559 + 560 + // helper: check if any ancestor of A is adjacent to any ancestor of B 561 + const ancestorsConnected = (ancestorsA: number[], ancestorsB: number[]): boolean => { 562 + for (const aId of ancestorsA) { 563 + const aNode = shape.nodes.find(n => n.id === aId); 564 + if (!aNode) continue; 565 + for (const bId of ancestorsB) { 566 + if (aNode.adj.includes(bId)) return true; 567 + } 568 + } 569 + return false; 570 + }; 222 571 223 - clusters[i].center = { 224 - x: Math.cos(theta) * radiusAtY * sphereRadius, 225 - y: y * sphereRadius, 226 - z: Math.sin(theta) * radiusAtY * sphereRadius 227 - }; 572 + for (let li = 0; li < leaves.length; li++) { 573 + const leafA = leaves[li]; 574 + if (leafA.adj.length >= 2) continue; 575 + 576 + const ancestorsA = getAncestors(leafA.id, 2); 577 + 578 + for (let lj = li + 1; lj < leaves.length; lj++) { 579 + const leafB = leaves[lj]; 580 + if (leafB.adj.length >= 2) continue; 581 + 582 + const parentBId = leafB.adj[0]; 583 + if (ancestorsA[0] === parentBId) continue; // same parent, not parallel 584 + 585 + const ancestorsB = getAncestors(leafB.id, 2); 586 + 587 + const distSq = vecDistSq(leafA.pos, leafB.pos); 588 + if (distSq > MAX_LADDER_DIST ** 2) continue; 589 + 590 + if (leafA.adj.length >= 3 || leafB.adj.length >= 3) continue; 591 + 592 + // connect if ancestors (within 2 hops) are adjacent 593 + if (ancestorsConnected(ancestorsA, ancestorsB)) { 594 + leafA.adj.push(leafB.id); 595 + leafB.adj.push(leafA.id); 596 + break; 597 + } 598 + } 599 + } 600 + } 601 + 602 + // --- Post-process: Remove crossing edges --- 603 + // project positions to 2D for intersection tests (using x,y) 604 + const proj2D = (p: Vec3): { x: number; y: number } => ({ x: p.x, y: p.y }); 605 + 606 + const segmentsIntersect = ( 607 + a1: { x: number; y: number }, a2: { x: number; y: number }, 608 + b1: { x: number; y: number }, b2: { x: number; y: number } 609 + ): boolean => { 610 + const ccw = (A: { x: number; y: number }, B: { x: number; y: number }, C: { x: number; y: number }) => 611 + (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); 612 + return ccw(a1, b1, b2) !== ccw(a2, b1, b2) && ccw(a1, a2, b1) !== ccw(a1, a2, b2); 613 + }; 614 + 615 + type Edge = { u: number; v: number; lenSq: number }; 616 + const edges: Edge[] = []; 617 + for (const node of shape.nodes) { 618 + for (const adjId of node.adj) { 619 + if (adjId > node.id) { 620 + const other = shape.nodes.find(n => n.id === adjId)!; 621 + edges.push({ u: node.id, v: adjId, lenSq: vecDistSq(node.pos, other.pos) }); 622 + } 623 + } 624 + } 625 + 626 + const edgesToRemove = new Set<string>(); 627 + for (let i = 0; i < edges.length; i++) { 628 + for (let j = i + 1; j < edges.length; j++) { 629 + const e1 = edges[i]; 630 + const e2 = edges[j]; 631 + 632 + // skip if they share a vertex 633 + if (e1.u === e2.u || e1.u === e2.v || e1.v === e2.u || e1.v === e2.v) continue; 634 + 635 + const n1u = shape.nodes.find(n => n.id === e1.u)!; 636 + const n1v = shape.nodes.find(n => n.id === e1.v)!; 637 + const n2u = shape.nodes.find(n => n.id === e2.u)!; 638 + const n2v = shape.nodes.find(n => n.id === e2.v)!; 639 + 640 + if (segmentsIntersect(proj2D(n1u.pos), proj2D(n1v.pos), proj2D(n2u.pos), proj2D(n2v.pos))) { 641 + // remove the longer edge 642 + const key1 = `${Math.min(e1.u, e1.v)}-${Math.max(e1.u, e1.v)}`; 643 + const key2 = `${Math.min(e2.u, e2.v)}-${Math.max(e2.u, e2.v)}`; 644 + if (e1.lenSq > e2.lenSq) { 645 + edgesToRemove.add(key1); 646 + } else { 647 + edgesToRemove.add(key2); 648 + } 649 + } 650 + } 651 + } 652 + 653 + // apply removals 654 + for (const key of edgesToRemove) { 655 + const [uStr, vStr] = key.split('-'); 656 + const u = parseInt(uStr); 657 + const v = parseInt(vStr); 658 + const nodeU = shape.nodes.find(n => n.id === u); 659 + const nodeV = shape.nodes.find(n => n.id === v); 660 + if (nodeU) nodeU.adj = nodeU.adj.filter(x => x !== v); 661 + if (nodeV) nodeV.adj = nodeV.adj.filter(x => x !== u); 662 + } 663 + 664 + shapes.push(shape); 665 + shapeMetas.set(shape.id, { mode: shapeMode, snapped: false, curled: false }); 666 + } else { 667 + if (rng.float(0, 1) > 0.5) pendingShapes.push({ size: targetSize, mode: shapeMode }); 668 + } 228 669 } 229 670 230 - // 4. Layout Stars (Directional Bias with Global Collision) 231 - const placedStars: Star[] = []; 671 + // --- 2. Map Domains to Shapes (DFS) --- 232 672 233 - // Helper: Rotate vector v around random axis by angle 234 - const rotateVector = (v: { dx: number; dy: number; dz: number }, angle: number) => { 235 - // Random axis perpendicular to v 236 - const rx = rng.next() - 0.5; 237 - const ry = rng.next() - 0.5; 238 - const rz = rng.next() - 0.5; 673 + const allDomains = Object.keys(data.linksTo); 674 + const validDomains = new Set(allDomains); 675 + // Shuffle domains for randomness 676 + for (let i = allDomains.length - 1; i > 0; i--) { 677 + const j = Math.floor(rng.float(0, 1) * (i + 1)); 678 + [allDomains[i], allDomains[j]] = [allDomains[j], allDomains[i]]; 679 + } 239 680 240 - // Cross product v x r 241 - let cpx = v.dy * rz - v.dz * ry; 242 - let cpy = v.dz * rx - v.dx * rz; 243 - let cpz = v.dx * ry - v.dy * rx; 681 + const usedDomains = new Set<string>(); 682 + const finalStars: Star[] = []; 244 683 245 - let cpLen = Math.sqrt(cpx * cpx + cpy * cpy + cpz * cpz); 246 - if (cpLen < 0.001) { 247 - cpx = 1; 248 - cpy = 0; 249 - cpz = 0; 250 - cpLen = 1; 684 + // Prepare limited roots 685 + const getLimitRatio = (domain: string): number | null => { 686 + if (domain.includes('forum')) return 0; 687 + const BANNED = ['proboards.com', 'boards.net', 'jcink.net', 'jcink.com', 'bbactif.com', 'superforo.net']; 688 + if (BANNED.includes(domain)) return 0; 689 + const LIMITED = ['neocities.org', 'wordpress.com', 'blogspot.com', 'blogfree.net']; 690 + if (LIMITED.includes(domain)) return 0.025; // 2.5% 691 + return null; 692 + }; 693 + const getRootDomain = (domain: string): string => { 694 + const parts = domain.split('.'); 695 + if (parts.length > 2) { 696 + const root = parts.slice(-2).join('.'); 697 + if (getLimitRatio(root) !== null) return root; 251 698 } 699 + return domain; 700 + }; 701 + const rootCounts = new Map<string, number>(); 252 702 253 - // Axis k (normalized) 254 - const kx = cpx / cpLen; 255 - const ky = cpy / cpLen; 256 - const kz = cpz / cpLen; 703 + let domainCursor = 0; 257 704 258 - // Rodrigues rotation: v_rot = v * cos(a) + (k x v) * sin(a) 259 - const cos = Math.cos(angle); 260 - const sin = Math.sin(angle); 705 + const stats = { 706 + totalShapes: 0, 707 + truncated: 0, 708 + perfect: 0, 709 + oneStar: 0, 710 + sizeDistribution: {} as Record<number, number>, 711 + modeDistribution: { linear: 0, zigzag: 0, looped: 0, branching: 0 } as Record<ShapeMode, number> 712 + }; 261 713 262 - // k x v 263 - const kxv_x = ky * v.dz - kz * v.dy; 264 - const kxv_y = kz * v.dx - kx * v.dz; 265 - const kxv_z = kx * v.dy - ky * v.dx; 714 + // --- Helper: Shape Analysis --- 715 + const analyzeShapeRequirements = (shape: ConstellationShape, startNodeId: number) => { 716 + // BFS to build layers from startNode 717 + const layers: number[][] = []; 718 + const visited = new Set<number>([startNodeId]); 719 + const parentMap = new Map<number, number>(); 266 720 267 - const newDx = v.dx * cos + kxv_x * sin; 268 - const newDy = v.dy * cos + kxv_y * sin; 269 - const newDz = v.dz * cos + kxv_z * sin; 721 + let currentLayer = [startNodeId]; 722 + while (currentLayer.length > 0) { 723 + layers.push(currentLayer); 724 + const nextLayer: number[] = []; 725 + for (const u of currentLayer) { 726 + const node = shape.nodes.find(n => n.id === u)!; 727 + for (const v of node.adj) { 728 + if (!visited.has(v)) { 729 + visited.add(v); 730 + parentMap.set(v, u); 731 + nextLayer.push(v); 732 + } 733 + } 734 + } 735 + currentLayer = nextLayer; 736 + } 270 737 271 - const len = Math.sqrt(newDx * newDx + newDy * newDy + newDz * newDz); 272 - return { dx: newDx / len, dy: newDy / len, dz: newDz / len }; 738 + // Bottom-up to calculate subtree requirements 739 + const requiredSubtreeSize = new Map<number, number>(); // inclusive of self 740 + for (let i = layers.length - 1; i >= 0; i--) { 741 + for (const u of layers[i]) { 742 + let size = 1; 743 + // Sum children 744 + const node = shape.nodes.find(n => n.id === u)!; 745 + for (const v of node.adj) { 746 + if (parentMap.get(v) === u) { 747 + size += requiredSubtreeSize.get(v) || 0; 748 + } 749 + } 750 + requiredSubtreeSize.set(u, size); 751 + } 752 + } 753 + return { requiredSubtreeSize, parentMap }; 273 754 }; 274 755 275 - // Helper: Check collision 276 - const checkCollision = (x: number, y: number, z: number) => { 277 - const minDistSq = MIN_DIST * MIN_DIST; 278 - for (const s of placedStars) { 279 - const d2 = (s.x - x) ** 2 + (s.y - y) ** 2 + (s.z - z) ** 2; 280 - if (d2 < minDistSq) return true; 756 + // --- Global Optimization Strategy --- 757 + 758 + // 1. Sort shapes by difficulty (biggest first) 759 + shapes.sort((a, b) => b.nodes.length - a.nodes.length); 760 + 761 + // 2. Pre-calculate potentials for ALL domains to create a high-quality pool 762 + console.log('analyzing domain potentials...'); 763 + const domainPool: { domain: string; potential: number }[] = []; 764 + 765 + // We use a simplified potential check here (just degree or shallow BFS) for speed 766 + // Actually, let's just use connection count as a rough heuristic first, 767 + // or perform the actual BFS if it's fast enough. 768 + // For ~1000 domains BFS depth 10 is fast. 769 + 770 + const getDomainPotential = (startDomain: string, limit: number): number => { 771 + let count = 0; 772 + const q = [startDomain]; 773 + const visitedLocal = new Set<string>([startDomain]); 774 + let head = 0; 775 + while (head < q.length && count < limit) { 776 + const u = q[head++]; 777 + count++; 778 + const links = data.linksTo[u] || []; 779 + for (const v of links) { 780 + if (!validDomains.has(v) || visitedLocal.has(v)) continue; 781 + // Check static limits (ban list) 782 + const r = getRootDomain(v); 783 + if (getLimitRatio(r) === 0) continue; 784 + 785 + visitedLocal.add(v); 786 + q.push(v); 787 + } 281 788 } 282 - return false; 789 + return count; 283 790 }; 284 791 285 - for (const cluster of clusters) { 286 - if (cluster.stars.length === 0) continue; 792 + for (const d of allDomains) { 793 + // Filter banned roots immediately 794 + const r = getRootDomain(d); 795 + if (getLimitRatio(r) === 0) continue; 796 + 797 + const p = getDomainPotential(d, 20); // Check up to size 20 798 + domainPool.push({ domain: d, potential: p }); 799 + } 800 + 801 + // Sort pool: highest potential first 802 + domainPool.sort((a, b) => b.potential - a.potential); 803 + console.log(`analyzed ${domainPool.length} domains for potential pool.`); 804 + 805 + // --- 3. Strict Match Loop --- 806 + 807 + for (const shape of shapes) { 808 + stats.totalShapes++; 287 809 288 - // Root star 289 - const first = cluster.stars[0]; 290 - first.x = cluster.center.x; 291 - first.y = cluster.center.y; 292 - first.z = cluster.center.z; 810 + let startNode = shape.nodes.find(n => n.adj.length === 1); 811 + if (!startNode) startNode = shape.nodes[0]; 293 812 294 - placedStars.push(first); 295 - const placedInCluster = new Set<string>([first.domain]); 813 + const neededTotal = shape.nodes.length; 814 + const { requiredSubtreeSize } = analyzeShapeRequirements(shape, startNode.id); 296 815 297 - // Initial random direction 298 - const u = rng.next(); 299 - const v = rng.next(); 300 - const theta = 2 * Math.PI * u; 301 - const phi = Math.acos(2 * v - 1); 816 + // Try to find a PERFECT match in the pool 817 + let bestMapping: Map<number, string> | null = null; 818 + let bestRoot: string | null = null; 302 819 303 - const directions = new Map<string, { dx: number; dy: number; dz: number }>(); 304 - directions.set(first.domain, { 305 - dx: Math.sin(phi) * Math.cos(theta), 306 - dy: Math.sin(phi) * Math.sin(theta), 307 - dz: Math.cos(phi) 308 - }); 820 + // To avoid O(N*M) where N=shapes, M=domains, we iterate the sorted pool. 821 + // Since we want the "best" available, we start from top. 309 822 310 - const layoutQueue = [first]; 823 + for (let i = 0; i < domainPool.length; i++) { 824 + const candidate = domainPool[i]; 825 + if (usedDomains.has(candidate.domain)) continue; 311 826 312 - while (layoutQueue.length > 0) { 313 - const current = layoutQueue.shift()!; 314 - const prevDir = directions.get(current.domain)!; 827 + if (candidate.potential < neededTotal) { 828 + // Since list is sorted, no subsequent domain will have enough potential (roughly) 829 + // We can break early? No, potential is just a heuristic, graph topology differs. 830 + // But generally yes. Let's start with loose check. 831 + if (candidate.potential < neededTotal * 0.8) continue; 832 + } 833 + 834 + // Check Limits Dynamic (root counts) 835 + const r = getRootDomain(candidate.domain); 836 + const ratio = getLimitRatio(r); 837 + if (ratio !== null) { 838 + const c = rootCounts.get(r) || 0; 839 + if (c >= MAX_STARS * ratio) continue; 840 + } 841 + 842 + // Perform Test Mapping 843 + const tempMapping = new Map<number, string>(); 844 + tempMapping.set(startNode.id, candidate.domain); 845 + const tempUsed = new Set<string>(usedDomains); // localized used set 846 + tempUsed.add(candidate.domain); 315 847 316 - const unplacedNeighbors = current.visualConnections 317 - .map((id) => cluster.stars.find((s) => s.domain === id)) 318 - .filter((s) => s && !placedInCluster.has(s.domain)) as Star[]; 848 + const tempRootCounts = new Map(rootCounts); // localization is expensive? 849 + // Actually we only need to track changes if we commit or just check constraints on fly 850 + // We can just check constraints. 319 851 320 - for (const target of unplacedNeighbors) { 321 - let bestPos = { x: 0, y: 0, z: 0 }; 322 - let bestDir = prevDir; 323 - let placed = false; 852 + const stack = [{ shapeNodeId: startNode.id, domain: candidate.domain }]; 853 + const visitedShapeNodes = new Set<number>([startNode.id]); 854 + let success = true; 855 + 856 + while (stack.length > 0) { 857 + const { shapeNodeId, domain } = stack.pop()!; 858 + const shapeNode = shape.nodes.find(n => n.id === shapeNodeId)!; 859 + const shapeNeighbors = shapeNode.adj.filter(nid => !visitedShapeNodes.has(nid)); 860 + 861 + if (shapeNeighbors.length === 0) continue; 862 + 863 + // Connections 864 + let links = (data.linksTo[domain] || []) 865 + .filter(d => validDomains.has(d) && !tempUsed.has(d)); 324 866 325 - // Try multiple directions 326 - for (let i = 0; i < ATTEMPTS; i++) { 327 - const angle = (30 + rng.next() * 60) * (Math.PI / 180); 328 - const newDir = rotateVector(prevDir, angle); 867 + // Heuristic Sort: Match biggest subtree needs to biggest potential neighbors 868 + const neighborsWithNeeds = shapeNeighbors.map(nid => ({ 869 + nid, 870 + needed: requiredSubtreeSize.get(nid) || 1 871 + })).sort((a, b) => b.needed - a.needed); 329 872 330 - const cx = current.x + newDir.dx * STEP_SIZE; 331 - const cy = current.y + newDir.dy * STEP_SIZE; 332 - const cz = current.z + newDir.dz * STEP_SIZE; 873 + // Get potentials of links 874 + // Optimization: We can't re-run huge BFS here. Use neighbor count (degree) as proxy or local cache. 875 + // Or just re-run getDomainPotential with small limit 876 + const linkPotentials = links.map(d => ({ 877 + d, 878 + p: (data.linksTo[d] || []).length // Fast degree check 879 + })).sort((a, b) => b.p - a.p); 333 880 334 - if (!checkCollision(cx, cy, cz)) { 335 - bestPos = { x: cx, y: cy, z: cz }; 336 - bestDir = newDir; 337 - placed = true; 338 - break; 339 - } 881 + if (linkPotentials.length < neighborsWithNeeds.length) { 882 + success = false; 883 + break; // Truncated 340 884 } 341 885 342 - // Fallback: Force straight line 343 - if (!placed) { 344 - bestPos = { 345 - x: current.x + prevDir.dx * STEP_SIZE, 346 - y: current.y + prevDir.dy * STEP_SIZE, 347 - z: current.z + prevDir.dz * STEP_SIZE 348 - }; 349 - bestDir = prevDir; 350 - } 886 + // Assign 887 + for (let j = 0; j < neighborsWithNeeds.length; j++) { 888 + const targetNode = neighborsWithNeeds[j]; 889 + const link = linkPotentials[j]; 351 890 352 - target.x = bestPos.x; 353 - target.y = bestPos.y; 354 - target.z = bestPos.z; 891 + // Limit Check 892 + const lr = getRootDomain(link.d); 893 + const lratio = getLimitRatio(lr); 894 + if (lratio === 0) { success = false; break; } // Should match initial filter 895 + if (lratio !== null) { 896 + // Here we can't easily track temp increments without a map. 897 + // Let's assume for a single shape it won't blow the budget unless budget is tight. 898 + const c = (rootCounts.get(lr) || 0); // + local usage in this shape? 899 + // Let's ignore local usage for limit check for simplicity, it's rare to use same root massive times in one shape 900 + if (c >= MAX_STARS * lratio) { success = false; break; } 901 + } 355 902 356 - placedInCluster.add(target.domain); 357 - placedStars.push(target); 358 - directions.set(target.domain, bestDir); 359 - layoutQueue.push(target); 903 + tempMapping.set(targetNode.nid, link.d); 904 + tempUsed.add(link.d); 905 + visitedShapeNodes.add(targetNode.nid); 906 + stack.push({ shapeNodeId: targetNode.nid, domain: link.d }); 907 + } 908 + if (!success) break; 909 + } 910 + 911 + if (success && tempMapping.size === neededTotal) { 912 + // Perfect match found! 913 + bestMapping = tempMapping; 914 + bestRoot = candidate.domain; 915 + break; // Stop searching pool 360 916 } 361 917 } 362 918 363 - // Handle isolated/leftover stars 364 - for (const star of cluster.stars) { 365 - if (!placedInCluster.has(star.domain)) { 366 - // Try random spots near center 367 - for (let k = 0; k < 10; k++) { 368 - const cx = cluster.center.x + (rng.next() - 0.5) * 200; 369 - const cy = cluster.center.y + (rng.next() - 0.5) * 200; 370 - const cz = cluster.center.z + (rng.next() - 0.5) * 200; 919 + if (bestMapping) { 920 + const meta = shapeMetas.get(shape.id); 921 + if (meta) stats.modeDistribution[meta.mode]++; 922 + stats.perfect++; 923 + stats.sizeDistribution[bestMapping.size] = (stats.sizeDistribution[bestMapping.size] || 0) + 1; 924 + 925 + for (const [nid, dom] of bestMapping) { 926 + usedDomains.add(dom); 927 + const r = getRootDomain(dom); 928 + if (getLimitRatio(r) !== null) rootCounts.set(r, (rootCounts.get(r) || 0) + 1); 929 + 930 + // Add star 931 + const node = shape.nodes.find(n => n.id === nid)!; 932 + const neighbors = node.adj.map(aid => bestMapping!.get(aid)).filter(x => x !== undefined) as string[]; 371 933 372 - if (!checkCollision(cx, cy, cz) || k === 9) { 373 - star.x = cx; 374 - star.y = cy; 375 - star.z = cz; 376 - break; 377 - } 378 - } 379 - placedInCluster.add(star.domain); 380 - placedStars.push(star); 934 + finalStars.push({ 935 + domain: dom, 936 + x: node.pos.x, 937 + y: node.pos.y, 938 + z: node.pos.z, 939 + connections: data.linksTo[dom] || [], 940 + visualConnections: neighbors 941 + }); 381 942 } 943 + } else { 944 + stats.truncated++; // Discarded entire shape 945 + // console.log(`Could not find perfect match for shape size ${neededTotal}`); 382 946 } 383 947 } 384 948 385 - // 5. Generate Nebulae (Density-based) 949 + console.log('--- Constellation Generation Stats ---'); 950 + console.log(`Truncated: ${stats.truncated} (${stats.totalShapes ? ((stats.truncated / stats.totalShapes) * 100).toFixed(1) : 0}%)`); 951 + console.log(`Perfect (Full Shape): ${stats.perfect} (${stats.totalShapes ? ((stats.perfect / stats.totalShapes) * 100).toFixed(1) : 0}%)`); 952 + console.log(`Single Star Constellations: ${stats.oneStar}`); 953 + console.log(`Size Distribution:`, JSON.stringify(stats.sizeDistribution)); 954 + console.log(`Mode Distribution:`, JSON.stringify(stats.modeDistribution)); 955 + console.log('--------------------------------------'); 956 + 957 + const stars = finalStars; 958 + console.log(`Final stars generated: ${stars.length}`); 959 + 960 + // 5. Generate Nebulae (Density-based) - SAME AS BEFORE 386 961 const PROBE_COUNT = 300; 387 962 const SEARCH_RADIUS = 400; 388 963 const DENSITY_THRESHOLD = 4; ··· 391 966 392 967 for (let i = 0; i < PROBE_COUNT; i++) { 393 968 if (stars.length === 0) break; 394 - const p = stars[Math.floor(rng.next() * stars.length)]; 969 + const p = stars[Math.floor(rng.float(0, 1) * stars.length)]; 395 970 396 971 let neighbors = 0; 397 972 let sumX = 0, ··· 426 1001 427 1002 console.log(`found ${candidates.length} density-based nebula candidates.`); 428 1003 429 - const breakDensity = candidates[Math.floor(candidates.length * 0.7)].density; 1004 + const breakDensity = candidates.length > 0 ? candidates[Math.floor(candidates.length * 0.7)]?.density ?? 0 : 0; 430 1005 console.log(`70th percentile density: ${breakDensity}`); 431 1006 432 1007 for (const c of candidates) { ··· 451 1026 console.log('generating void noise...'); 452 1027 453 1028 for (let i = 0; i < DUST_COUNT; i++) { 454 - const u = rng.next(); 455 - const v = rng.next(); 1029 + const u = rng.float(0, 1); 1030 + const v = rng.float(0, 1); 456 1031 const theta = 2 * Math.PI * u; 457 1032 const phi = Math.acos(2 * v - 1); 458 - const r = sphereRadius * Math.cbrt(rng.next()); 1033 + const r = SPHERE_RADIUS * Math.cbrt(rng.float(0, 1)); 459 1034 460 1035 const dx = r * Math.sin(phi) * Math.cos(theta); 461 1036 const dy = r * Math.sin(phi) * Math.sin(theta); 462 1037 const dz = r * Math.cos(phi); 463 1038 464 - const baseAlpha = 0.15 + rng.next() * 0.3; 1039 + const baseAlpha = 0.15 + rng.float(0, 1) * 0.2; 465 1040 466 1041 dust.push({ 467 1042 x: dx, 468 1043 y: dy, 469 1044 z: dz, 470 1045 alpha: baseAlpha, 471 - sizeFactor: 0.5 + rng.next() * 1.5, 472 - color: rng.next() > 0.5 ? '#FFFFFF' : '#AAAAAA' 1046 + sizeFactor: 0.5 + rng.float(0, 1) * 1.5, 1047 + color: rng.float(0, 1) > 0.5 ? '#FFFFFF' : '#AAAAAA' 473 1048 }); 474 1049 } 475 1050 console.log(`generated ${dust.length} dust particles.`); 476 1051 477 - return { stars, nebulae, dust }; 1052 + return { stars, nebulae, dust, seed }; 478 1053 }; 479 1054 480 1055 export const initConstellation = async () => { ··· 495 1070 496 1071 start = Date.now(); 497 1072 console.log('generating constellation data...'); 498 - const { stars, nebulae, dust } = generateConstellationData(data); 1073 + // Use fixed seed in dev, random in prod 1074 + const seed = dev ? 567238047896 : Date.now(); 1075 + const { stars, nebulae, dust } = generateConstellationData(data, seed); 499 1076 500 - await writeFile(GRAPH_FILE, JSON.stringify({ stars, nebulae, dust })); 1077 + await writeFile(GRAPH_FILE, JSON.stringify({ stars, nebulae, dust, seed })); 501 1078 console.log( 502 1079 `${stars.length} stars, ${nebulae.length} nebulae, ${dust.length} dust particles generated in ${Date.now() - start}ms` 503 1080 ); ··· 523 1100 console.log('rendering constellation to SVG...'); 524 1101 525 1102 const constellationData: ConstellationData = JSON.parse(await readFile(GRAPH_FILE, 'utf-8')); 526 - const { stars, nebulae, dust } = constellationData; 1103 + const { stars, nebulae, dust, seed } = constellationData; 1104 + const rng = random.clone(seed); 527 1105 528 1106 const RESOLUTION_SCALE = 1; 529 1107 const width = 1920 * RESOLUTION_SCALE; ··· 562 1140 }; 563 1141 564 1142 let svgBody = ''; 565 - let defsContent = ''; 1143 + let defsContent = ` 1144 + <style> 1145 + /* <![CDATA[ */ 1146 + @keyframes twinkle { 1147 + 0% { opacity: 1; } 1148 + 5% { opacity: 0.3; } 1149 + 10% { opacity: 1; } 1150 + 100% { opacity: 1; } 1151 + } 1152 + @keyframes flicker { 1153 + 0% { opacity: 1; } 1154 + 2% { opacity: 1; } 1155 + 4% { opacity: 0.4; } 1156 + 6% { opacity: 1; } 1157 + 8% { opacity: 0.4; } 1158 + 10% { opacity: 1; } 1159 + 12% { opacity: 0.8; } 1160 + 14% { opacity: 1; } 1161 + 100% { opacity: 1; } 1162 + } 1163 + .anim-twinkle { animation: twinkle linear infinite; transform-box: fill-box; transform-origin: center; } 1164 + .anim-flicker { animation: flicker linear infinite; transform-box: fill-box; transform-origin: center; } 1165 + /* ]]> */ 1166 + </style> 1167 + `; 566 1168 567 1169 const stage = new Konva.Stage({ 568 1170 width, ··· 714 1316 const p = projected[star.domain]; 715 1317 716 1318 const connectionCount = star.connections ? star.connections.length : 0; 717 - const importance = Math.min(1.5, 1 + connectionCount * 0.1); 1319 + // importance = Math.min(1.5, 1 + connectionCount * 0.1); 1320 + // Max 1.8, add randomness so high-link constellations aren't uniformly huge 1321 + let baseImportance = 0.6 + connectionCount * 0.15; 1322 + 1323 + // random variation +/- 20% 1324 + const sizeNoise = rng.float(0.8, 1.2); 1325 + baseImportance *= sizeNoise; 1326 + 1327 + const importance = Math.min(1.8, Math.max(0.6, baseImportance)); 718 1328 719 1329 const radius = Math.max(1 * RESOLUTION_SCALE, 25 * p.scale * importance) * 0.4; 720 1330 const haloRadius = radius * 1.85; ··· 724 1334 const opacity = Math.min(1, Math.max(0.2, 1000 / p.z)); 725 1335 const haloOpacity = opacity * 0.3; 726 1336 727 - svgBody += `<rect x="${fmt(p.x - radius / 2)}" y="${fmt(p.y - radius / 2)}" width="${fmt(radius)}" height="${fmt(radius)}" fill="#EEEEEE" fill-opacity="${fmt(opacity)}" stroke="#FFFFFF" stroke-opacity="${fmt(haloOpacity)}" stroke-width="${fmt(strokeWidth)}" paint-order="stroke fill" />`; 1337 + // Animation logic 1338 + const animType = rng.float(0, 1); 1339 + let animClass = ''; 1340 + let duration = 0; 1341 + 1342 + if (animType > 0.85) { 1343 + animClass = 'anim-flicker'; 1344 + duration = rng.float(3.0, 7.0); 1345 + } else if (animType > 0.4) { 1346 + animClass = 'anim-twinkle'; 1347 + duration = rng.float(3.0, 6.0); 1348 + } 1349 + 1350 + const delay = rng.float(0, 10); 1351 + const style = animClass ? `style="animation-duration: ${duration.toFixed(2)}s; animation-delay: -${delay.toFixed(2)}s"` : ''; 1352 + const cls = animClass ? `class="${animClass}"` : ''; 1353 + 1354 + svgBody += `<rect ${cls} ${style} x="${fmt(p.x - radius / 2)}" y="${fmt(p.y - radius / 2)}" width="${fmt(radius)}" height="${fmt(radius)}" fill="#EEEEEE" fill-opacity="${fmt(opacity)}" stroke="#FFFFFF" stroke-opacity="${fmt(haloOpacity)}" stroke-width="${fmt(strokeWidth)}" paint-order="stroke fill" />`; 728 1355 729 1356 visibleStars.push({ domain: star.domain, x: p.x, y: p.y, r: radius * 1.75 }); 730 1357 } ··· 745 1372 meta: { 746 1373 timestamp: new Date().toISOString(), 747 1374 angleY, 748 - angleX 1375 + angleX, 1376 + seed: constellationData.seed 749 1377 } 750 1378 }) 751 1379 ); 752 1380 753 1381 console.log('generating OG image...'); 754 1382 (async () => { 1383 + const og_start = Date.now(); 755 1384 const h = 630; 756 1385 const s = sharp(OUTPUT_FILE).resize({ height: h }) 757 1386 const resized_svg = await s.toBuffer(); ··· 760 1389 .composite([{ input: resized_svg }]) 761 1390 .png(); 762 1391 await og.toFile(OG_IMAGE_FILE); 1392 + console.log(`generated OG image in ${Date.now() - og_start}ms`); 763 1393 s.destroy(); 764 1394 og.destroy(); 765 1395 })();
+5
eunomia/vite.config.ts
··· 6 6 server: { 7 7 fs: { 8 8 allow: ['../'] 9 + }, 10 + watch: { 11 + usePolling: true, 12 + useFsEvents: false, 13 + interval: 100, 9 14 } 10 15 } 11 16 });
+27 -27
flake.lock
··· 27 27 "pyproject-nix": "pyproject-nix" 28 28 }, 29 29 "locked": { 30 - "lastModified": 1765228272, 31 - "narHash": "sha256-duTz4J4NP1edl/ZBdwZPduPM8j6g0yzjb8YH91T9vU0=", 30 + "lastModified": 1765953015, 31 + "narHash": "sha256-5FBZbbWR1Csp3Y2icfRkxMJw/a/5FGg8hCXej2//bbI=", 32 32 "owner": "nix-community", 33 33 "repo": "dream2nix", 34 - "rev": "83c430ce6b6aedf149c5259f066bfff808722dbd", 34 + "rev": "69eb01fa0995e1e90add49d8ca5bcba213b0416f", 35 35 "type": "github" 36 36 }, 37 37 "original": { ··· 100 100 "treefmt": "treefmt" 101 101 }, 102 102 "locked": { 103 - "lastModified": 1765347705, 104 - "narHash": "sha256-pp5ru9CvZz0n1UBvRzp41othwARTUBmK1Es8iAqgepc=", 103 + "lastModified": 1768371832, 104 + "narHash": "sha256-SXluvUhEehrcbQzwZkEl7MH96sLNLID6QiGDbTl1IYM=", 105 105 "owner": "90-008", 106 106 "repo": "nix-cargo-integration", 107 - "rev": "c573f9ec80416fd09964a3e3892f14d9012a03e2", 107 + "rev": "d7e924aa3279d8faa393c3380b05a232d55ba5e2", 108 108 "type": "github" 109 109 }, 110 110 "original": { ··· 115 115 }, 116 116 "nixpkgs": { 117 117 "locked": { 118 - "lastModified": 1765270179, 119 - "narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=", 118 + "lastModified": 1768302833, 119 + "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", 120 120 "owner": "nixos", 121 121 "repo": "nixpkgs", 122 - "rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9", 122 + "rev": "61db79b0c6b838d9894923920b612048e1201926", 123 123 "type": "github" 124 124 }, 125 125 "original": { ··· 131 131 }, 132 132 "nixpkgs-lib": { 133 133 "locked": { 134 - "lastModified": 1761765539, 135 - "narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=", 134 + "lastModified": 1765674936, 135 + "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", 136 136 "owner": "nix-community", 137 137 "repo": "nixpkgs.lib", 138 - "rev": "719359f4562934ae99f5443f20aa06c2ffff91fc", 138 + "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", 139 139 "type": "github" 140 140 }, 141 141 "original": { ··· 152 152 ] 153 153 }, 154 154 "locked": { 155 - "lastModified": 1763759067, 156 - "narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=", 155 + "lastModified": 1768135262, 156 + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", 157 157 "owner": "hercules-ci", 158 158 "repo": "flake-parts", 159 - "rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0", 159 + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", 160 160 "type": "github" 161 161 }, 162 162 "original": { ··· 170 170 "nixpkgs-lib": "nixpkgs-lib" 171 171 }, 172 172 "locked": { 173 - "lastModified": 1763759067, 174 - "narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=", 173 + "lastModified": 1768135262, 174 + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", 175 175 "owner": "hercules-ci", 176 176 "repo": "flake-parts", 177 - "rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0", 177 + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", 178 178 "type": "github" 179 179 }, 180 180 "original": { ··· 216 216 ] 217 217 }, 218 218 "locked": { 219 - "lastModified": 1752481895, 220 - "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 219 + "lastModified": 1763017646, 220 + "narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=", 221 221 "owner": "pyproject-nix", 222 222 "repo": "pyproject.nix", 223 - "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 223 + "rev": "47bd6f296502842643078d66128f7b5e5370790c", 224 224 "type": "github" 225 225 }, 226 226 "original": { ··· 245 245 ] 246 246 }, 247 247 "locked": { 248 - "lastModified": 1765334520, 249 - "narHash": "sha256-jTof2+ir9UPmv4lWksYO6WbaXCC0nsDExrB9KZj7Dz4=", 248 + "lastModified": 1768359079, 249 + "narHash": "sha256-a016mOfKconYrYo3fZLN6c2cnmqYYd44g2bUrBZAsQc=", 250 250 "owner": "oxalica", 251 251 "repo": "rust-overlay", 252 - "rev": "db61f666aea93b28f644861fbddd37f235cc5983", 252 + "rev": "0357d1826057686637e41147545402cbbda420ce", 253 253 "type": "github" 254 254 }, 255 255 "original": { ··· 289 289 ] 290 290 }, 291 291 "locked": { 292 - "lastModified": 1762938485, 293 - "narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=", 292 + "lastModified": 1768158989, 293 + "narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=", 294 294 "owner": "numtide", 295 295 "repo": "treefmt-nix", 296 - "rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4", 296 + "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", 297 297 "type": "github" 298 298 }, 299 299 "original": {