data endpoint for entity 90008 (aka. a website)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at svelte 1386 lines 44 kB view raw
1import Konva from 'konva'; 2import 'konva/skia-backend'; 3import { writeFile, readFile, stat, mkdir } from 'node:fs/promises'; 4import { join } from 'node:path'; 5import { env } from '$env/dynamic/private'; 6import type { Canvas } from 'skia-canvas'; 7import sharp from 'sharp'; 8import random from 'random'; 9import { createNoise3D } from 'simplex-noise'; 10import { dev } from '$app/environment'; 11 12const DATA_DIR = join(env.WEBSITE_DATA_DIR ?? '', 'constellation'); 13const GRAPH_FILE = join(DATA_DIR, 'graph_processed.json'); 14const OUTPUT_FILE = join(DATA_DIR, 'background.svg'); 15const DUST_FILE = join(DATA_DIR, 'background_dust.webp'); 16const OG_IMAGE_FILE = join(DATA_DIR, 'og_image.png'); 17const STARS_FILE = join(DATA_DIR, 'stars.json'); 18const GRAPH_URL = 'https://eightyeightthirty.one/graph.json'; 19 20type GraphData = { 21 linksTo: Record<string, string[]>; 22}; 23 24type Star = { 25 domain: string; 26 x: number; 27 y: number; 28 z: number; 29 connections: string[]; 30 visualConnections: string[]; 31}; 32 33export type Nebula = { x: number; y: number; z: number; density: number }; 34export type Dust = { 35 x: number; 36 y: number; 37 z: number; 38 alpha: number; 39 sizeFactor: number; 40 color: string; 41}; 42 43export type ConstellationData = { 44 stars: Star[]; 45 nebulae: Nebula[]; 46 dust: Dust[]; 47 seed: number; 48}; 49 50 51 52export const generateConstellationData = ( 53 data: GraphData, 54 seed: number = 567238047896 55): ConstellationData => { 56 const rng = random.clone(seed); 57 58 // seeded prng for noise (mulberry32) 59 const mulberry32 = (a: number) => () => { 60 let t = (a += 0x6d2b79f5); 61 t = Math.imul(t ^ (t >>> 15), t | 1); 62 t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 63 return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 64 }; 65 const noise3D = createNoise3D(mulberry32(seed)); 66 const NOISE_SCALE = 0.0012; // controls feature size of cosmic structures 67 68 // sample density at a point (returns 0-1, higher = denser cosmic region) 69 const cosmicDensity = (p: { x: number; y: number; z: number }): number => { 70 const n1 = noise3D(p.x * NOISE_SCALE, p.y * NOISE_SCALE, p.z * NOISE_SCALE); 71 const n2 = noise3D(p.x * NOISE_SCALE * 2, p.y * NOISE_SCALE * 2, p.z * NOISE_SCALE * 2) * 0.5; 72 const combined = (n1 + n2) / 1.5; 73 return (combined + 1) / 2; // normalize to 0-1 74 }; 75 76 const SPHERE_RADIUS = 1800; 77 const MAX_STARS = 750; // Target total stars 78 79 // --- Vector Math Helpers --- 80 type Vec3 = { x: number; y: number; z: number }; 81 82 const vecAdd = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }); 83 const vecSub = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }); 84 const vecScale = (v: Vec3, s: number): Vec3 => ({ x: v.x * s, y: v.y * s, z: v.z * s }); 85 const vecLen = (v: Vec3): number => Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); 86 const vecNorm = (v: Vec3): Vec3 => { 87 const l = vecLen(v); 88 return l === 0 ? { x: 0, y: 0, z: 0 } : vecScale(v, 1 / l); 89 }; 90 const vecDot = (a: Vec3, b: Vec3): number => a.x * b.x + a.y * b.y + a.z * b.z; 91 const vecCross = (a: Vec3, b: Vec3): Vec3 => ({ 92 x: a.y * b.z - a.z * b.y, 93 y: a.z * b.x - a.x * b.z, 94 z: a.x * b.y - a.y * b.x 95 }); 96 const vecDistSq = (a: Vec3, b: Vec3): number => { 97 const dx = a.x - b.x; 98 const dy = a.y - b.y; 99 const dz = a.z - b.z; 100 return dx * dx + dy * dy + dz * dz; 101 }; 102 103 // geodesic (great-circle) arc distance on sphere surface 104 const geodesicDist = (a: Vec3, b: Vec3): number => { 105 const dotProduct = vecDot(a, b) / (SPHERE_RADIUS * SPHERE_RADIUS); 106 const clamped = Math.max(-1, Math.min(1, dotProduct)); 107 return SPHERE_RADIUS * Math.acos(clamped); 108 }; 109 110 // Rotate vector v around axis k by angle theta 111 const vecRotate = (v: Vec3, k: Vec3, theta: number): Vec3 => { 112 const cos = Math.cos(theta); 113 const sin = Math.sin(theta); 114 const cross = vecCross(k, v); 115 const dot = vecDot(k, v); 116 return vecAdd( 117 vecAdd(vecScale(v, cos), vecScale(cross, sin)), 118 vecScale(k, dot * (1 - cos)) 119 ); 120 }; 121 122 // --- Geometric Shape Generation --- 123 124 interface ShapeNode { 125 id: number; 126 pos: Vec3; 127 adj: number[]; // Connected node IDs within this shape 128 } 129 130 interface ConstellationShape { 131 id: number; 132 nodes: ShapeNode[]; 133 aabb2d: { minX: number; minY: number; maxX: number; maxY: number }; 134 } 135 136 const STEP_SIZE_MIN = 80; 137 const STEP_SIZE_MAX = 140; 138 const MIN_NODE_DIST = STEP_SIZE_MIN * 0.7; // Minimum distance between stars 139 const CONSTELLATION_PADDING = STEP_SIZE_MAX; // Extra padding for AABB checks 140 141 const shapes: ConstellationShape[] = []; 142 let shapeIdCounter = 0; 143 144 // Ratios for constellation sizes 145 const SIZE_RATIOS: Record<number, number> = { 146 3: 0.05, 4: 0.10, 5: 0.20, 6: 0.25, 7: 0.20, 8: 0.15, 9: 0.05 147 }; 148 149 // Shape modes control how constellations grow 150 type ShapeMode = 'linear' | 'zigzag' | 'looped' | 'branching'; 151 const SHAPE_MODE_RATIOS: Record<ShapeMode, number> = { 152 linear: 0.10, // mostly straight lines 153 zigzag: 0.35, // sharp direction changes 154 looped: 0.35, // intentionally curls back on itself 155 branching: 0.20 // has multiple offshoots 156 }; 157 158 // Create a pool of target sizes and modes to pick from 159 const pendingShapes: { size: number; mode: ShapeMode }[] = []; 160 { 161 let sizeSum = 0; 162 for (const r of Object.values(SIZE_RATIOS)) sizeSum += r; 163 const tempTotal = MAX_STARS / 6; 164 165 const modeKeys = Object.keys(SHAPE_MODE_RATIOS) as ShapeMode[]; 166 let modeSum = 0; 167 for (const r of Object.values(SHAPE_MODE_RATIOS)) modeSum += r; 168 169 for (const [sizeStr, sizeRatio] of Object.entries(SIZE_RATIOS)) { 170 const size = parseInt(sizeStr); 171 const count = Math.round(tempTotal * (sizeRatio / sizeSum)); 172 for (let k = 0; k < count; k++) { 173 // pick shape mode based on ratios 174 const roll = rng.float(0, modeSum); 175 let cumulative = 0; 176 let mode: ShapeMode = 'linear'; 177 for (const m of modeKeys) { 178 cumulative += SHAPE_MODE_RATIOS[m]; 179 if (roll < cumulative) { 180 mode = m; 181 break; 182 } 183 } 184 pendingShapes.push({ size, mode }); 185 } 186 } 187 // Shuffle 188 for (let i = pendingShapes.length - 1; i > 0; i--) { 189 const j = Math.floor(rng.float(0, 1) * (i + 1)); 190 [pendingShapes[i], pendingShapes[j]] = [pendingShapes[j], pendingShapes[i]]; 191 } 192 } 193 194 const updateAABB = (shape: ConstellationShape) => { 195 if (shape.nodes.length === 0) return; 196 let minX = Infinity, minY = Infinity; 197 let maxX = -Infinity, maxY = -Infinity; 198 for (const n of shape.nodes) { 199 minX = Math.min(minX, n.pos.x); 200 minY = Math.min(minY, n.pos.y); 201 maxX = Math.max(maxX, n.pos.x); 202 maxY = Math.max(maxY, n.pos.y); 203 } 204 shape.aabb2d = { 205 minX: minX - CONSTELLATION_PADDING, 206 minY: minY - CONSTELLATION_PADDING, 207 maxX: maxX + CONSTELLATION_PADDING, 208 maxY: maxY + CONSTELLATION_PADDING 209 }; 210 }; 211 212 const dist2D = (a: Vec3, b: Vec3): number => { 213 const dx = a.x - b.x; 214 const dy = a.y - b.y; 215 return Math.sqrt(dx * dx + dy * dy); 216 }; 217 218 const checkCollision = (p: Vec3, ignoreShapeId: number = -1): boolean => { 219 for (const shape of shapes) { 220 if (shape.id === ignoreShapeId) continue; 221 if (p.x < shape.aabb2d.minX || p.x > shape.aabb2d.maxX || 222 p.y < shape.aabb2d.minY || p.y > shape.aabb2d.maxY) { 223 continue; 224 } 225 for (const node of shape.nodes) { 226 if (dist2D(p, node.pos) < MIN_NODE_DIST) return true; 227 } 228 } 229 return false; 230 }; 231 232 const checkSelfCollision = (p: Vec3, shape: ConstellationShape): boolean => { 233 for (const node of shape.nodes) { 234 if (dist2D(p, node.pos) < MIN_NODE_DIST) return true; 235 } 236 return false; 237 }; 238 239 type ShapeMeta = { mode: ShapeMode; snapped: boolean; curled: boolean }; 240 const shapeMetas: Map<number, ShapeMeta> = new Map(); 241 242 // --- 1. Generate Shapes --- 243 while (pendingShapes.length > 0) { 244 const { size: targetSize, mode: shapeMode } = pendingShapes.pop()!; 245 const shape: ConstellationShape = { 246 id: shapeIdCounter++, 247 nodes: [], 248 aabb2d: { minX: 0, minY: 0, maxX: 0, maxY: 0 } 249 }; 250 251 // mode-specific parameters (angles in radians) 252 const modeConfig = { 253 linear: { minAngle: 0, angleRange: Math.PI / 3, branchChance: 0.05, curlBackChance: 0.0, snapThreshold: 0.7 }, 254 zigzag: { minAngle: Math.PI / 4, angleRange: (2 * Math.PI) / 3, branchChance: 0.10, curlBackChance: 0.0, snapThreshold: 0.6 }, 255 looped: { minAngle: 0, angleRange: Math.PI / 2, branchChance: 0.05, curlBackChance: 0.50, snapThreshold: 0.3 }, 256 branching: { minAngle: 0, angleRange: Math.PI / 2, branchChance: 0.40, curlBackChance: 0.0, snapThreshold: 0.6 } 257 }[shapeMode]; 258 259 // start point - use noise-weighted candidate selection 260 let startPos: Vec3 = { x: 0, y: 0, z: 0 }; 261 let placedStart = false; 262 263 // generate candidate positions and score them by cosmic density 264 const NUM_CANDIDATES = 15; 265 const candidates: { pos: Vec3; density: number }[] = []; 266 267 for (let c = 0; c < NUM_CANDIDATES; c++) { 268 const u = rng.float(0, 1); 269 const v = rng.float(0, 1); 270 const theta = 2 * Math.PI * u; 271 const phi = Math.acos(2 * v - 1); 272 const pos: Vec3 = { 273 x: SPHERE_RADIUS * Math.sin(phi) * Math.cos(theta), 274 y: SPHERE_RADIUS * Math.sin(phi) * Math.sin(theta), 275 z: SPHERE_RADIUS * Math.cos(phi) 276 }; 277 278 if (!checkCollision(pos)) { 279 let density = cosmicDensity(pos); 280 281 // Anti-clustering: Repulsion from same-mode shapes 282 let repulsion = 0; 283 const REPULSION_RADIUS = 700; 284 const REPULSION_WEIGHT = 1.2; 285 286 for (const [id, meta] of shapeMetas) { 287 if (meta.mode === shapeMode) { 288 const otherShape = shapes.find(s => s.id === id); 289 if (otherShape && otherShape.nodes.length > 0) { 290 const distSq = vecDistSq(pos, otherShape.nodes[0].pos); 291 repulsion += Math.exp(-distSq / (REPULSION_RADIUS * REPULSION_RADIUS)); 292 } 293 } 294 } 295 296 // Apply penalty, keeping a small positive minimum to allow placement if necessary 297 density = Math.max(0.001, density - repulsion * REPULSION_WEIGHT); 298 299 candidates.push({ pos, density }); 300 } 301 } 302 303 if (candidates.length > 0) { 304 // pick from top candidates weighted by density 305 candidates.sort((a, b) => b.density - a.density); 306 const topN = Math.min(5, candidates.length); 307 const topCandidates = candidates.slice(0, topN); 308 309 // weighted random selection among top candidates 310 const totalDensity = topCandidates.reduce((sum, c) => sum + c.density, 0); 311 let roll = rng.float(0, totalDensity); 312 let chosen = topCandidates[0]; 313 for (const c of topCandidates) { 314 roll -= c.density; 315 if (roll <= 0) { 316 chosen = c; 317 break; 318 } 319 } 320 startPos = chosen.pos; 321 placedStart = true; 322 } 323 324 if (!placedStart) continue; 325 326 shape.nodes.push({ id: 0, pos: startPos, adj: [] }); 327 updateAABB(shape); 328 329 let failedGrowth = false; 330 let prevDir: Vec3 | null = null; 331 let activeTipId = 0; 332 let prevStepDist = 0; 333 let prevAngleSign = 0; 334 335 for (let i = 1; i < targetSize; i++) { 336 let placed = false; 337 let parentId = activeTipId; 338 let isBranching = false; 339 340 const starsLeft = targetSize - i; 341 const canBranch = starsLeft >= 2; 342 const progress = i / targetSize; 343 344 // determine if we should branch based on mode 345 if (canBranch && rng.float(0, 1) < modeConfig.branchChance) { 346 isBranching = true; 347 } 348 349 const tipNode = shape.nodes.find(n => n.id === activeTipId)!; 350 if (tipNode.adj.length >= 2 && rng.float(0, 1) < 0.7) { 351 isBranching = true; 352 } 353 354 if (isBranching) { 355 const candidates = shape.nodes.filter(n => n.adj.length < 3); 356 if (candidates.length === 0) { 357 failedGrowth = true; 358 break; 359 } 360 const p = candidates[Math.floor(rng.float(0, candidates.length))]; 361 parentId = p.id; 362 } 363 364 let parent = shape.nodes.find(n => n.id === parentId); 365 if (!parent || parent.adj.length >= 3) { 366 const candidates = shape.nodes.filter(n => n.adj.length < 3); 367 if (candidates.length === 0) { 368 failedGrowth = true; 369 break; 370 } 371 parent = candidates[Math.floor(rng.float(0, candidates.length))]; 372 parentId = parent.id; 373 } 374 375 const normal = vecNorm(parent.pos); 376 let tangent = vecCross(normal, { x: 0, y: 1, z: 0 }); 377 if (vecLen(tangent) < 0.01) tangent = vecCross(normal, { x: 1, y: 0, z: 0 }); 378 tangent = vecNorm(tangent); 379 380 let baseDir = tangent; 381 if (parentId === activeTipId && i > 1 && prevDir && !isBranching) { 382 baseDir = vecNorm(prevDir); 383 } 384 385 let snapped = false; 386 let didCurl = false; 387 388 // curl-back: try to move toward an existing node (for looped mode) 389 let curlTarget: Vec3 | null = null; 390 if (modeConfig.curlBackChance > 0 && rng.float(0, 1) < modeConfig.curlBackChance && shape.nodes.length >= 3) { 391 // find a node that's not the parent and not adjacent to parent 392 const curlCandidates = shape.nodes.filter(n => 393 n.id !== parentId && 394 !parent!.adj.includes(n.id) && 395 n.adj.length < 3 396 ); 397 if (curlCandidates.length > 0) { 398 const target = curlCandidates[Math.floor(rng.float(0, curlCandidates.length))]; 399 curlTarget = target.pos; 400 } 401 } 402 403 for (let tryIdx = 0; tryIdx < 25; tryIdx++) { 404 let newDir: Vec3; 405 406 if (curlTarget && tryIdx < 10) { 407 // more tries moving toward curl target 408 const toTarget = vecSub(curlTarget, parent.pos); 409 const projected = vecSub(toTarget, vecScale(normal, vecDot(toTarget, normal))); 410 if (vecLen(projected) > 0.01) { 411 const jitter = (rng.float(0, 1) - 0.5) * (Math.PI / 6); 412 newDir = vecRotate(vecNorm(projected), normal, jitter); 413 } else { 414 newDir = vecRotate(tangent, normal, rng.float(0, 2 * Math.PI)); 415 } 416 } else if (!isBranching && parentId === activeTipId && prevDir) { 417 // mode-specific angle deviation with minimum enforced 418 // bias toward alternating direction to avoid straight lines 419 let sign: number; 420 if (prevAngleSign !== 0 && rng.float(0, 1) < 0.7) { 421 sign = -prevAngleSign; // 70% chance to flip direction 422 } else { 423 sign = rng.float(0, 1) < 0.5 ? -1 : 1; 424 } 425 const angleMagnitude = modeConfig.minAngle + rng.float(0, 1) * (modeConfig.angleRange - modeConfig.minAngle); 426 const angle = sign * angleMagnitude; 427 newDir = vecRotate(baseDir, normal, angle); 428 prevAngleSign = sign; 429 } else { 430 const angle = rng.float(0, 2 * Math.PI); 431 newDir = vecRotate(tangent, normal, angle); 432 } 433 434 // enforce variation in step distance (at least 20% different from previous) 435 const distRange = STEP_SIZE_MAX - STEP_SIZE_MIN; 436 const minVariation = distRange * 0.2; 437 let dist: number; 438 if (prevStepDist > 0) { 439 // pick from either low or high side, avoiding previous value 440 const lowMax = Math.max(STEP_SIZE_MIN, prevStepDist - minVariation); 441 const highMin = Math.min(STEP_SIZE_MAX, prevStepDist + minVariation); 442 if (rng.float(0, 1) < 0.5 && lowMax > STEP_SIZE_MIN) { 443 dist = rng.float(STEP_SIZE_MIN, lowMax); 444 } else if (highMin < STEP_SIZE_MAX) { 445 dist = rng.float(highMin, STEP_SIZE_MAX); 446 } else { 447 dist = rng.float(STEP_SIZE_MIN, STEP_SIZE_MAX); 448 } 449 } else { 450 dist = rng.float(STEP_SIZE_MIN, STEP_SIZE_MAX); 451 } 452 prevStepDist = dist; 453 454 let nextPos = vecAdd(parent.pos, vecScale(newDir, dist)); 455 nextPos = vecScale(vecNorm(nextPos), SPHERE_RADIUS); 456 457 // expanded snapping: trigger based on mode threshold instead of just last 2 stars 458 if (progress >= modeConfig.snapThreshold) { 459 // 1. try snapping to edge midpoints (splits edge) 460 for (const u of shape.nodes) { 461 if (snapped) break; 462 for (const vId of u.adj) { 463 if (vId < u.id) continue; 464 const v = shape.nodes.find(n => n.id === vId)!; 465 if (u.id === parent!.id || v.id === parent!.id) continue; 466 467 const mid = vecScale(vecAdd(u.pos, v.pos), 0.5); 468 const snapDist = dist * (shapeMode === 'looped' ? 0.6 : 0.4); 469 if (vecDistSq(nextPos, mid) < snapDist ** 2) { 470 nextPos = vecScale(vecNorm(mid), SPHERE_RADIUS); 471 472 u.adj = u.adj.filter(x => x !== v.id); 473 v.adj = v.adj.filter(x => x !== u.id); 474 475 if (checkCollision(nextPos, shape.id) || checkSelfCollision(nextPos, shape)) { 476 u.adj.push(v.id); 477 v.adj.push(u.id); 478 continue; 479 } 480 481 const newNode: ShapeNode = { id: i, pos: nextPos, adj: [u.id, v.id] }; 482 shape.nodes.push(newNode); 483 u.adj.push(i); 484 v.adj.push(i); 485 486 snapped = true; 487 placed = true; 488 activeTipId = i; 489 break; 490 } 491 } 492 } 493 494 // 2. try snapping directly to existing nodes (creates a loop) 495 if (!snapped && shapeMode === 'looped') { 496 const nodeSnapDist = dist * 1.5; 497 for (const candidate of shape.nodes) { 498 if (candidate.id === parent!.id) continue; 499 if (parent!.adj.includes(candidate.id)) continue; 500 if (candidate.adj.length >= 3) continue; 501 502 if (vecDistSq(nextPos, candidate.pos) < nodeSnapDist ** 2) { 503 if (checkCollision(nextPos, shape.id) || checkSelfCollision(nextPos, shape)) continue; 504 505 const newNode: ShapeNode = { id: i, pos: nextPos, adj: [parent!.id, candidate.id] }; 506 shape.nodes.push(newNode); 507 parent!.adj.push(i); 508 candidate.adj.push(i); 509 snapped = true; 510 placed = true; 511 activeTipId = i; 512 break; 513 } 514 } 515 } 516 } 517 518 if (snapped) break; 519 520 if (!checkCollision(nextPos, shape.id) && !checkSelfCollision(nextPos, shape)) { 521 const newNode: ShapeNode = { id: i, pos: nextPos, adj: [parent.id] }; 522 shape.nodes.push(newNode); 523 parent.adj.push(i); 524 prevDir = vecSub(nextPos, parent.pos); 525 placed = true; 526 updateAABB(shape); 527 activeTipId = i; 528 break; 529 } 530 } 531 532 if (!placed && !snapped) { 533 failedGrowth = true; 534 break; 535 } 536 } 537 538 if (!failedGrowth) { 539 // post-processing: connect parallel branches for branching mode 540 if (shapeMode === 'branching' && shape.nodes.length >= 4) { 541 // find leaf nodes (degree 1) 542 const leaves = shape.nodes.filter(n => n.adj.length === 1); 543 const MAX_LADDER_DIST = STEP_SIZE_MAX * 1.8; 544 545 // helper: get ancestors up to N hops 546 const getAncestors = (nodeId: number, maxHops: number): number[] => { 547 const ancestors: number[] = []; 548 let current = nodeId; 549 for (let h = 0; h < maxHops; h++) { 550 const node = shape.nodes.find(n => n.id === current); 551 if (!node || node.adj.length === 0) break; 552 const parent = node.adj[0]; 553 ancestors.push(parent); 554 current = parent; 555 } 556 return ancestors; 557 }; 558 559 // helper: check if any ancestor of A is adjacent to any ancestor of B 560 const ancestorsConnected = (ancestorsA: number[], ancestorsB: number[]): boolean => { 561 for (const aId of ancestorsA) { 562 const aNode = shape.nodes.find(n => n.id === aId); 563 if (!aNode) continue; 564 for (const bId of ancestorsB) { 565 if (aNode.adj.includes(bId)) return true; 566 } 567 } 568 return false; 569 }; 570 571 for (let li = 0; li < leaves.length; li++) { 572 const leafA = leaves[li]; 573 if (leafA.adj.length >= 2) continue; 574 575 const ancestorsA = getAncestors(leafA.id, 2); 576 577 for (let lj = li + 1; lj < leaves.length; lj++) { 578 const leafB = leaves[lj]; 579 if (leafB.adj.length >= 2) continue; 580 581 const parentBId = leafB.adj[0]; 582 if (ancestorsA[0] === parentBId) continue; // same parent, not parallel 583 584 const ancestorsB = getAncestors(leafB.id, 2); 585 586 const distSq = vecDistSq(leafA.pos, leafB.pos); 587 if (distSq > MAX_LADDER_DIST ** 2) continue; 588 589 if (leafA.adj.length >= 3 || leafB.adj.length >= 3) continue; 590 591 // connect if ancestors (within 2 hops) are adjacent 592 if (ancestorsConnected(ancestorsA, ancestorsB)) { 593 leafA.adj.push(leafB.id); 594 leafB.adj.push(leafA.id); 595 break; 596 } 597 } 598 } 599 } 600 601 // --- Post-process: Remove crossing edges --- 602 // project positions to 2D for intersection tests (using x,y) 603 const proj2D = (p: Vec3): { x: number; y: number } => ({ x: p.x, y: p.y }); 604 605 const segmentsIntersect = ( 606 a1: { x: number; y: number }, a2: { x: number; y: number }, 607 b1: { x: number; y: number }, b2: { x: number; y: number } 608 ): boolean => { 609 const ccw = (A: { x: number; y: number }, B: { x: number; y: number }, C: { x: number; y: number }) => 610 (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); 611 return ccw(a1, b1, b2) !== ccw(a2, b1, b2) && ccw(a1, a2, b1) !== ccw(a1, a2, b2); 612 }; 613 614 type Edge = { u: number; v: number; lenSq: number }; 615 const edges: Edge[] = []; 616 for (const node of shape.nodes) { 617 for (const adjId of node.adj) { 618 if (adjId > node.id) { 619 const other = shape.nodes.find(n => n.id === adjId)!; 620 edges.push({ u: node.id, v: adjId, lenSq: vecDistSq(node.pos, other.pos) }); 621 } 622 } 623 } 624 625 const edgesToRemove = new Set<string>(); 626 for (let i = 0; i < edges.length; i++) { 627 for (let j = i + 1; j < edges.length; j++) { 628 const e1 = edges[i]; 629 const e2 = edges[j]; 630 631 // skip if they share a vertex 632 if (e1.u === e2.u || e1.u === e2.v || e1.v === e2.u || e1.v === e2.v) continue; 633 634 const n1u = shape.nodes.find(n => n.id === e1.u)!; 635 const n1v = shape.nodes.find(n => n.id === e1.v)!; 636 const n2u = shape.nodes.find(n => n.id === e2.u)!; 637 const n2v = shape.nodes.find(n => n.id === e2.v)!; 638 639 if (segmentsIntersect(proj2D(n1u.pos), proj2D(n1v.pos), proj2D(n2u.pos), proj2D(n2v.pos))) { 640 // remove the longer edge 641 const key1 = `${Math.min(e1.u, e1.v)}-${Math.max(e1.u, e1.v)}`; 642 const key2 = `${Math.min(e2.u, e2.v)}-${Math.max(e2.u, e2.v)}`; 643 if (e1.lenSq > e2.lenSq) { 644 edgesToRemove.add(key1); 645 } else { 646 edgesToRemove.add(key2); 647 } 648 } 649 } 650 } 651 652 // apply removals 653 for (const key of edgesToRemove) { 654 const [uStr, vStr] = key.split('-'); 655 const u = parseInt(uStr); 656 const v = parseInt(vStr); 657 const nodeU = shape.nodes.find(n => n.id === u); 658 const nodeV = shape.nodes.find(n => n.id === v); 659 if (nodeU) nodeU.adj = nodeU.adj.filter(x => x !== v); 660 if (nodeV) nodeV.adj = nodeV.adj.filter(x => x !== u); 661 } 662 663 shapes.push(shape); 664 shapeMetas.set(shape.id, { mode: shapeMode, snapped: false, curled: false }); 665 } else { 666 if (rng.float(0, 1) > 0.5) pendingShapes.push({ size: targetSize, mode: shapeMode }); 667 } 668 } 669 670 // --- 2. Map Domains to Shapes (DFS) --- 671 672 const allDomains = Object.keys(data.linksTo); 673 const validDomains = new Set(allDomains); 674 // Shuffle domains for randomness 675 for (let i = allDomains.length - 1; i > 0; i--) { 676 const j = Math.floor(rng.float(0, 1) * (i + 1)); 677 [allDomains[i], allDomains[j]] = [allDomains[j], allDomains[i]]; 678 } 679 680 const usedDomains = new Set<string>(); 681 const finalStars: Star[] = []; 682 683 // Prepare limited roots 684 const getLimitRatio = (domain: string): number | null => { 685 if (domain.includes('forum')) return 0; 686 const BANNED = ['proboards.com', 'boards.net', 'jcink.net', 'jcink.com', 'bbactif.com', 'superforo.net']; 687 if (BANNED.includes(domain)) return 0; 688 const LIMITED = ['neocities.org', 'wordpress.com', 'blogspot.com', 'blogfree.net']; 689 if (LIMITED.includes(domain)) return 0.025; // 2.5% 690 return null; 691 }; 692 const getRootDomain = (domain: string): string => { 693 const parts = domain.split('.'); 694 if (parts.length > 2) { 695 const root = parts.slice(-2).join('.'); 696 if (getLimitRatio(root) !== null) return root; 697 } 698 return domain; 699 }; 700 const rootCounts = new Map<string, number>(); 701 702 let domainCursor = 0; 703 704 const stats = { 705 totalShapes: 0, 706 truncated: 0, 707 perfect: 0, 708 oneStar: 0, 709 sizeDistribution: {} as Record<number, number>, 710 modeDistribution: { linear: 0, zigzag: 0, looped: 0, branching: 0 } as Record<ShapeMode, number> 711 }; 712 713 // --- Helper: Shape Analysis --- 714 const analyzeShapeRequirements = (shape: ConstellationShape, startNodeId: number) => { 715 // BFS to build layers from startNode 716 const layers: number[][] = []; 717 const visited = new Set<number>([startNodeId]); 718 const parentMap = new Map<number, number>(); 719 720 let currentLayer = [startNodeId]; 721 while (currentLayer.length > 0) { 722 layers.push(currentLayer); 723 const nextLayer: number[] = []; 724 for (const u of currentLayer) { 725 const node = shape.nodes.find(n => n.id === u)!; 726 for (const v of node.adj) { 727 if (!visited.has(v)) { 728 visited.add(v); 729 parentMap.set(v, u); 730 nextLayer.push(v); 731 } 732 } 733 } 734 currentLayer = nextLayer; 735 } 736 737 // Bottom-up to calculate subtree requirements 738 const requiredSubtreeSize = new Map<number, number>(); // inclusive of self 739 for (let i = layers.length - 1; i >= 0; i--) { 740 for (const u of layers[i]) { 741 let size = 1; 742 // Sum children 743 const node = shape.nodes.find(n => n.id === u)!; 744 for (const v of node.adj) { 745 if (parentMap.get(v) === u) { 746 size += requiredSubtreeSize.get(v) || 0; 747 } 748 } 749 requiredSubtreeSize.set(u, size); 750 } 751 } 752 return { requiredSubtreeSize, parentMap }; 753 }; 754 755 // --- Global Optimization Strategy --- 756 757 // 1. Sort shapes by difficulty (biggest first) 758 shapes.sort((a, b) => b.nodes.length - a.nodes.length); 759 760 // 2. Pre-calculate potentials for ALL domains to create a high-quality pool 761 console.log('analyzing domain potentials...'); 762 const domainPool: { domain: string; potential: number }[] = []; 763 764 // We use a simplified potential check here (just degree or shallow BFS) for speed 765 // Actually, let's just use connection count as a rough heuristic first, 766 // or perform the actual BFS if it's fast enough. 767 // For ~1000 domains BFS depth 10 is fast. 768 769 const getDomainPotential = (startDomain: string, limit: number): number => { 770 let count = 0; 771 const q = [startDomain]; 772 const visitedLocal = new Set<string>([startDomain]); 773 let head = 0; 774 while (head < q.length && count < limit) { 775 const u = q[head++]; 776 count++; 777 const links = data.linksTo[u] || []; 778 for (const v of links) { 779 if (!validDomains.has(v) || visitedLocal.has(v)) continue; 780 // Check static limits (ban list) 781 const r = getRootDomain(v); 782 if (getLimitRatio(r) === 0) continue; 783 784 visitedLocal.add(v); 785 q.push(v); 786 } 787 } 788 return count; 789 }; 790 791 for (const d of allDomains) { 792 // Filter banned roots immediately 793 const r = getRootDomain(d); 794 if (getLimitRatio(r) === 0) continue; 795 796 const p = getDomainPotential(d, 20); // Check up to size 20 797 domainPool.push({ domain: d, potential: p }); 798 } 799 800 // Sort pool: highest potential first 801 domainPool.sort((a, b) => b.potential - a.potential); 802 console.log(`analyzed ${domainPool.length} domains for potential pool.`); 803 804 // --- 3. Strict Match Loop --- 805 806 for (const shape of shapes) { 807 stats.totalShapes++; 808 809 let startNode = shape.nodes.find(n => n.adj.length === 1); 810 if (!startNode) startNode = shape.nodes[0]; 811 812 const neededTotal = shape.nodes.length; 813 const { requiredSubtreeSize } = analyzeShapeRequirements(shape, startNode.id); 814 815 // Try to find a PERFECT match in the pool 816 let bestMapping: Map<number, string> | null = null; 817 let bestRoot: string | null = null; 818 819 // To avoid O(N*M) where N=shapes, M=domains, we iterate the sorted pool. 820 // Since we want the "best" available, we start from top. 821 822 for (let i = 0; i < domainPool.length; i++) { 823 const candidate = domainPool[i]; 824 if (usedDomains.has(candidate.domain)) continue; 825 if (candidate.potential < neededTotal * 0.8) continue; 826 827 const r = getRootDomain(candidate.domain); 828 const ratio = getLimitRatio(r); 829 if (ratio !== null) { 830 const c = rootCounts.get(r) || 0; 831 if (c >= MAX_STARS * ratio) continue; 832 } 833 834 const tempMapping = new Map<number, string>(); 835 tempMapping.set(startNode.id, candidate.domain); 836 const tempUsed = new Set<string>(usedDomains); // localized used set 837 tempUsed.add(candidate.domain); 838 839 const stack = [{ shapeNodeId: startNode.id, domain: candidate.domain }]; 840 const visitedShapeNodes = new Set<number>([startNode.id]); 841 let success = true; 842 843 while (stack.length > 0) { 844 const { shapeNodeId, domain } = stack.pop()!; 845 const shapeNode = shape.nodes.find(n => n.id === shapeNodeId)!; 846 const shapeNeighbors = shapeNode.adj.filter(nid => !visitedShapeNodes.has(nid)); 847 848 if (shapeNeighbors.length === 0) continue; 849 850 // Connections 851 let links = (data.linksTo[domain] || []) 852 .filter(d => validDomains.has(d) && !tempUsed.has(d)); 853 854 // Heuristic Sort: Match biggest subtree needs to biggest potential neighbors 855 const neighborsWithNeeds = shapeNeighbors.map(nid => ({ 856 nid, 857 needed: requiredSubtreeSize.get(nid) || 1 858 })).sort((a, b) => b.needed - a.needed); 859 860 // Get potentials of links 861 const linkPotentials = links.map(d => ({ 862 d, 863 p: (data.linksTo[d] || []).length // Fast degree check 864 })).sort((a, b) => b.p - a.p); 865 866 if (linkPotentials.length < neighborsWithNeeds.length) { 867 success = false; 868 break; // Truncated 869 } 870 871 // Assign 872 for (let j = 0; j < neighborsWithNeeds.length; j++) { 873 const targetNode = neighborsWithNeeds[j]; 874 const link = linkPotentials[j]; 875 876 // Limit Check 877 const lr = getRootDomain(link.d); 878 const lratio = getLimitRatio(lr); 879 if (lratio === 0) { success = false; break; } // Should match initial filter 880 if (lratio !== null) { 881 // Here we can't easily track temp increments without a map. 882 // Let's assume for a single shape it won't blow the budget unless budget is tight. 883 const c = (rootCounts.get(lr) || 0); // + local usage in this shape? 884 // Let's ignore local usage for limit check for simplicity, it's rare to use same root massive times in one shape 885 if (c >= MAX_STARS * lratio) { success = false; break; } 886 } 887 888 tempMapping.set(targetNode.nid, link.d); 889 tempUsed.add(link.d); 890 visitedShapeNodes.add(targetNode.nid); 891 stack.push({ shapeNodeId: targetNode.nid, domain: link.d }); 892 } 893 if (!success) break; 894 } 895 896 if (success && tempMapping.size === neededTotal) { 897 // Perfect match found! 898 bestMapping = tempMapping; 899 bestRoot = candidate.domain; 900 break; // Stop searching pool 901 } 902 } 903 904 if (bestMapping) { 905 const meta = shapeMetas.get(shape.id); 906 if (meta) stats.modeDistribution[meta.mode]++; 907 stats.perfect++; 908 stats.sizeDistribution[bestMapping.size] = (stats.sizeDistribution[bestMapping.size] || 0) + 1; 909 910 for (const [nid, dom] of bestMapping) { 911 usedDomains.add(dom); 912 const r = getRootDomain(dom); 913 if (getLimitRatio(r) !== null) rootCounts.set(r, (rootCounts.get(r) || 0) + 1); 914 915 // Add star 916 const node = shape.nodes.find(n => n.id === nid)!; 917 const neighbors = node.adj.map(aid => bestMapping!.get(aid)).filter(x => x !== undefined) as string[]; 918 919 finalStars.push({ 920 domain: dom, 921 x: node.pos.x, 922 y: node.pos.y, 923 z: node.pos.z, 924 connections: data.linksTo[dom] || [], 925 visualConnections: neighbors 926 }); 927 } 928 } else { 929 stats.truncated++; // Discarded entire shape 930 // console.log(`Could not find perfect match for shape size ${neededTotal}`); 931 } 932 } 933 934 console.log('--- Constellation Generation Stats ---'); 935 console.log(`Truncated: ${stats.truncated} (${stats.totalShapes ? ((stats.truncated / stats.totalShapes) * 100).toFixed(1) : 0}%)`); 936 console.log(`Perfect (Full Shape): ${stats.perfect} (${stats.totalShapes ? ((stats.perfect / stats.totalShapes) * 100).toFixed(1) : 0}%)`); 937 console.log(`Single Star Constellations: ${stats.oneStar}`); 938 console.log(`Size Distribution:`, JSON.stringify(stats.sizeDistribution)); 939 console.log(`Mode Distribution:`, JSON.stringify(stats.modeDistribution)); 940 console.log('--------------------------------------'); 941 942 const stars = finalStars; 943 console.log(`Final stars generated: ${stars.length}`); 944 945 // 5. Generate ebulae (Density-based) 946 const PROBE_COUNT = 300; 947 const SEARCH_RADIUS = 400; 948 const DENSITY_THRESHOLD = 4; 949 type NebulaDef = { x: number; y: number; z: number; density: number }; 950 let candidates: NebulaDef[] = []; 951 952 for (let i = 0; i < PROBE_COUNT; i++) { 953 if (stars.length === 0) break; 954 const p = stars[Math.floor(rng.float(0, 1) * stars.length)]; 955 956 let neighbors = 0; 957 let sumX = 0, 958 sumY = 0, 959 sumZ = 0; 960 961 for (const s of stars) { 962 const dx = s.x - p.x; 963 const dy = s.y - p.y; 964 const dz = s.z - p.z; 965 const d2 = dx * dx + dy * dy + dz * dz; 966 if (d2 < SEARCH_RADIUS * SEARCH_RADIUS) { 967 neighbors++; 968 sumX += s.x; 969 sumY += s.y; 970 sumZ += s.z; 971 } 972 } 973 974 if (neighbors >= DENSITY_THRESHOLD) 975 candidates.push({ 976 x: sumX / neighbors, 977 y: sumY / neighbors, 978 z: sumZ / neighbors, 979 density: neighbors 980 }); 981 } 982 983 const nebulae: Nebula[] = []; 984 const MERGE_DIST = 400; 985 candidates.sort((a, b) => b.density - a.density); 986 987 console.log(`found ${candidates.length} density-based nebula candidates.`); 988 989 const breakDensity = candidates.length > 0 ? candidates[Math.floor(candidates.length * 0.7)]?.density ?? 0 : 0; 990 console.log(`70th percentile density: ${breakDensity}`); 991 992 for (const c of candidates) { 993 let merged = false; 994 for (const n of nebulae) { 995 const dist = Math.sqrt((c.x - n.x) ** 2 + (c.y - n.y) ** 2 + (c.z - n.z) ** 2); 996 if (dist < MERGE_DIST) { 997 n.density = Math.max(n.density, c.density); 998 merged = true; 999 break; 1000 } 1001 } 1002 if (!merged) nebulae.push(c); 1003 1004 if (c.density < breakDensity) break; 1005 } 1006 console.log(`generated ${nebulae.length} density-based nebulae.`); 1007 1008 // 6. Generate Dust (Void Noise) 1009 const dust: Dust[] = []; 1010 const DUST_COUNT = 50000; 1011 console.log('generating void noise...'); 1012 1013 for (let i = 0; i < DUST_COUNT; i++) { 1014 const u = rng.float(0, 1); 1015 const v = rng.float(0, 1); 1016 const theta = 2 * Math.PI * u; 1017 const phi = Math.acos(2 * v - 1); 1018 const r = SPHERE_RADIUS * Math.cbrt(rng.float(0, 1)); 1019 1020 const dx = r * Math.sin(phi) * Math.cos(theta); 1021 const dy = r * Math.sin(phi) * Math.sin(theta); 1022 const dz = r * Math.cos(phi); 1023 1024 const baseAlpha = 0.15 + rng.float(0, 1) * 0.2; 1025 1026 dust.push({ 1027 x: dx, 1028 y: dy, 1029 z: dz, 1030 alpha: baseAlpha, 1031 sizeFactor: 0.5 + rng.float(0, 1) * 1.5, 1032 color: rng.float(0, 1) > 0.5 ? '#FFFFFF' : '#AAAAAA' 1033 }); 1034 } 1035 console.log(`generated ${dust.length} dust particles.`); 1036 1037 return { stars, nebulae, dust, seed }; 1038}; 1039 1040export const initConstellation = async () => { 1041 try { 1042 try { 1043 await stat(DATA_DIR); 1044 } catch { 1045 await mkdir(DATA_DIR, { recursive: true }); 1046 } 1047 1048 sharp.concurrency(1); 1049 1050 let start = Date.now(); 1051 console.log('fetching 88x31s graph data...'); 1052 const response = await fetch(GRAPH_URL); 1053 const data: GraphData = await response.json(); 1054 console.log(`fetched 88x31s graph data in ${Date.now() - start}ms`); 1055 1056 start = Date.now(); 1057 console.log('generating constellation data...'); 1058 // Use fixed seed in dev, random in prod 1059 const seed = dev ? 567238047896 : Date.now(); 1060 const { stars, nebulae, dust } = generateConstellationData(data, seed); 1061 1062 await writeFile(GRAPH_FILE, JSON.stringify({ stars, nebulae, dust, seed })); 1063 console.log( 1064 `${stars.length} stars, ${nebulae.length} nebulae, ${dust.length} dust particles generated in ${Date.now() - start}ms` 1065 ); 1066 1067 await renderConstellation(); 1068 } catch (error) { 1069 console.error('error initializing constellation:', error); 1070 } 1071}; 1072 1073type ProjectedTrans = { x: number; y: number; scale: number; z: number }; 1074 1075export const renderConstellation = async () => { 1076 try { 1077 try { 1078 await stat(GRAPH_FILE); 1079 } catch { 1080 await initConstellation(); 1081 return; 1082 } 1083 1084 const start = Date.now(); 1085 console.log('rendering constellation to SVG...'); 1086 1087 const constellationData: ConstellationData = JSON.parse(await readFile(GRAPH_FILE, 'utf-8')); 1088 const { stars, nebulae, dust, seed } = constellationData; 1089 const rng = random.clone(seed); 1090 1091 const RESOLUTION_SCALE = 1; 1092 const width = 1920 * RESOLUTION_SCALE; 1093 const height = 1080 * RESOLUTION_SCALE; 1094 1095 const fov = 400 * RESOLUTION_SCALE; // Field of view equivalent 1096 const cx = width / 2; 1097 const cy = height / 2; 1098 1099 // Calculate angle based on time: one full rotation per 3 hours (Y-axis) 1100 const periodY = 3 * 60 * 60 * 1000; 1101 // Secondary rotation on X-axis to see the poles (different period to avoid repeating patterns) 1102 const periodX = 5.14 * 60 * 60 * 1000; 1103 1104 const date = Date.now(); 1105 const angleY = ((date % periodY) / periodY) * Math.PI * 2; 1106 const angleX = ((date % periodX) / periodX) * Math.PI * 2; 1107 1108 const cosY = Math.cos(angleY); 1109 const sinY = Math.sin(angleY); 1110 const cosX = Math.cos(angleX); 1111 const sinX = Math.sin(angleX); 1112 1113 const rotatePoint = (x: number, y: number, z: number) => { 1114 // Yaw (Y-axis) 1115 const x1 = x * cosY - z * sinY; 1116 const z1 = z * cosY + x * sinY; 1117 const y1 = y; 1118 1119 // Pitch (X-axis) 1120 const y2 = y1 * cosX - z1 * sinX; 1121 const z2 = z1 * cosX + y1 * sinX; 1122 const x2 = x1; 1123 1124 return { x: x2, y: y2, z: z2 }; 1125 }; 1126 1127 let svgBody = ''; 1128 let defsContent = ` 1129 <style> 1130 /* <![CDATA[ */ 1131 @keyframes twinkle { 1132 0% { opacity: 1; } 1133 5% { opacity: 0.3; } 1134 10% { opacity: 1; } 1135 100% { opacity: 1; } 1136 } 1137 @keyframes flicker { 1138 0% { opacity: 1; } 1139 2% { opacity: 1; } 1140 4% { opacity: 0.4; } 1141 6% { opacity: 1; } 1142 8% { opacity: 0.4; } 1143 10% { opacity: 1; } 1144 12% { opacity: 0.8; } 1145 14% { opacity: 1; } 1146 100% { opacity: 1; } 1147 } 1148 .anim-twinkle { animation: twinkle linear infinite; transform-box: fill-box; transform-origin: center; } 1149 .anim-flicker { animation: flicker linear infinite; transform-box: fill-box; transform-origin: center; } 1150 /* ]]> */ 1151 </style> 1152 `; 1153 1154 const stage = new Konva.Stage({ 1155 width, 1156 height 1157 }); 1158 const layer = new Konva.Layer(); 1159 stage.add(layer); 1160 1161 const rect = new Konva.Rect({ 1162 width, 1163 height, 1164 fill: '#000000' 1165 }); 1166 layer.add(rect); 1167 1168 // Draw dust particles using Konva 1169 for (const d of dust) { 1170 const { x: rotX, y: rotY, z: rotZ } = rotatePoint(d.x, d.y, d.z); 1171 1172 if (rotZ > 100) { 1173 const scale = fov / rotZ; 1174 const screenX = cx + rotX * scale; 1175 const screenY = cy + rotY * scale * -1; 1176 1177 const size = d.sizeFactor * scale; 1178 1179 const rect = new Konva.Rect({ 1180 x: screenX, 1181 y: screenY, 1182 width: size, 1183 height: size, 1184 fill: d.color, 1185 opacity: d.alpha 1186 }); 1187 layer.add(rect); 1188 } 1189 } 1190 1191 layer.draw(); 1192 1193 const sharpImg = await (stage.toCanvas() as unknown as Canvas).toSharp(); 1194 const buffer = await sharpImg.webp({ effort: 6, quality: 30, smartDeblock: true }).toBuffer(); 1195 await writeFile(DUST_FILE, buffer); 1196 sharpImg.destroy(); 1197 1198 stage.destroyChildren(); 1199 stage.destroy(); 1200 1201 const projected: Record<string, ProjectedTrans> = {}; 1202 1203 const fmt = (n: number) => n.toFixed(1); // round to 1 decimal place, so we save more space 1204 1205 // 0. Universe Noise / Heatmap (Background Nebulae) 1206 let nebulaIndex = 0; 1207 for (const n of nebulae) { 1208 // Rotate matches star rotation 1209 const { x: rotX, y: rotY, z: rotZ } = rotatePoint(n.x, n.y, n.z); 1210 1211 // Render if in front of camera 1212 if (rotZ > 100) { 1213 const scale = fov / rotZ; 1214 const screenX = cx + rotX * scale; 1215 const screenY = cy + rotY * scale * -1; 1216 1217 // Density -> Size & Opacity 1218 const intensity = Math.min(1, n.density / 25); 1219 1220 const hueSeed = Math.abs(Math.sin(n.x * n.y * n.z)); 1221 const hue = 200 + hueSeed * 80; 1222 1223 const radius = (600 + intensity * 800) * scale; 1224 const alpha = 0.2 + intensity * 0.5; 1225 1226 const gradId = `nebula-${nebulaIndex++}`; 1227 defsContent += ` 1228 <radialGradient id="${gradId}" cx="0.5" cy="0.5" r="0.5" fx="0.5" fy="0.5"> 1229 <stop offset="0%" stop-color="hsla(${fmt(hue)}, 90%, 60%, ${fmt(alpha)})" /> 1230 <stop offset="100%" stop-color="hsla(0, 0%, 0%, 0)" /> 1231 </radialGradient>`; 1232 1233 svgBody += `<circle cx="${fmt(screenX)}" cy="${fmt(screenY)}" r="${fmt(radius)}" fill="url(#${gradId})" opacity="1" />`; 1234 } 1235 } 1236 1237 // 1. Projection pass 1238 for (const star of stars) { 1239 const { x: rotX, y: rotY, z: rotZ } = rotatePoint(star.x, star.y, star.z); 1240 1241 if (rotZ > 10) { 1242 const scale = fov / rotZ; 1243 const screenX = cx + rotX * scale; 1244 const screenY = cy + rotY * scale * -1; 1245 1246 projected[star.domain] = { x: screenX, y: screenY, scale, z: rotZ }; 1247 } 1248 } 1249 1250 // 2. Draw connections 1251 const drawnConnections = new Set<string>(); 1252 1253 type RenderLine = { 1254 p1: { x: number; y: number; z: number }; 1255 p2: { x: number; y: number; z: number }; 1256 avgZ: number; 1257 }; 1258 1259 const linesToDraw: RenderLine[] = []; 1260 1261 for (const star of stars) { 1262 if (!projected[star.domain]) continue; 1263 1264 const p1 = projected[star.domain]; 1265 1266 if (star.visualConnections) { 1267 for (const target of star.visualConnections) { 1268 const key = [star.domain, target].sort().join('-'); 1269 if (drawnConnections.has(key)) continue; 1270 drawnConnections.add(key); 1271 1272 if (projected[target]) { 1273 const p2 = projected[target]; 1274 const avgZ = (p1.z + p2.z) / 2; 1275 linesToDraw.push({ p1, p2, avgZ }); 1276 } 1277 } 1278 } 1279 } 1280 1281 linesToDraw.sort((a, b) => b.avgZ - a.avgZ); 1282 1283 for (const line of linesToDraw) { 1284 const { p1, p2, avgZ } = line; 1285 1286 const opacity = Math.max(0.4, Math.min(1, 1 - avgZ / 3000)); 1287 const strokeWidth = Math.max(0.2 * RESOLUTION_SCALE, 1.5 * RESOLUTION_SCALE * (1000 / avgZ)); 1288 1289 // Halo (black line behind) 1290 // svgBody += `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="#000000" stroke-width="${strokeWidth + strokeWidth * opacity}" opacity="1" stroke-linecap="butt" />`; 1291 1292 // Actual Line 1293 svgBody += `<line x1="${fmt(p1.x)}" y1="${fmt(p1.y)}" x2="${fmt(p2.x)}" y2="${fmt(p2.y)}" stroke="#FFFFFF" stroke-width="${fmt(strokeWidth)}" opacity="${fmt(opacity)}" stroke-linecap="butt" />`; 1294 } 1295 1296 // for interactivity (we put links on these screen coordinates) 1297 const visibleStars: { domain: string; x: number; y: number; r: number }[] = []; 1298 // 3. Draw Stars 1299 for (const star of stars) { 1300 if (!projected[star.domain]) continue; 1301 const p = projected[star.domain]; 1302 1303 const connectionCount = star.connections ? star.connections.length : 0; 1304 // importance = Math.min(1.5, 1 + connectionCount * 0.1); 1305 // Max 1.8, add randomness so high-link constellations aren't uniformly huge 1306 let baseImportance = 0.6 + connectionCount * 0.15; 1307 1308 // random variation +/- 20% 1309 const sizeNoise = rng.float(0.8, 1.2); 1310 baseImportance *= sizeNoise; 1311 1312 const importance = Math.min(1.8, Math.max(0.6, baseImportance)); 1313 1314 const radius = Math.max(1 * RESOLUTION_SCALE, 25 * p.scale * importance) * 0.4; 1315 const haloRadius = radius * 1.85; 1316 1317 const strokeWidth = haloRadius - radius; 1318 1319 const opacity = Math.min(1, Math.max(0.2, 1000 / p.z)); 1320 const haloOpacity = opacity * 0.3; 1321 1322 // Animation logic 1323 const animType = rng.float(0, 1); 1324 let animClass = ''; 1325 let duration = 0; 1326 1327 if (animType > 0.85) { 1328 animClass = 'anim-flicker'; 1329 duration = rng.float(3.0, 7.0); 1330 } else if (animType > 0.4) { 1331 animClass = 'anim-twinkle'; 1332 duration = rng.float(3.0, 6.0); 1333 } 1334 1335 const delay = rng.float(0, 10); 1336 const style = animClass ? `style="animation-duration: ${fmt(duration)}s; animation-delay: -${fmt(delay)}s"` : ''; 1337 const cls = animClass ? `class="${animClass}"` : ''; 1338 1339 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" />`; 1340 1341 visibleStars.push({ domain: star.domain, x: p.x, y: p.y, r: radius * 1.75 }); 1342 } 1343 1344 const finalSvg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg"> 1345 <defs>${defsContent}</defs> 1346 ${svgBody} 1347 </svg>`; 1348 1349 await writeFile(OUTPUT_FILE, finalSvg); 1350 1351 await writeFile( 1352 STARS_FILE, 1353 JSON.stringify({ 1354 width, 1355 height, 1356 stars: visibleStars, 1357 meta: { 1358 timestamp: new Date().toISOString(), 1359 angleY, 1360 angleX, 1361 seed: constellationData.seed 1362 } 1363 }) 1364 ); 1365 1366 console.log('generating OG image...'); 1367 (async () => { 1368 const og_start = Date.now(); 1369 const h = 630; 1370 const s = sharp(OUTPUT_FILE).resize({ height: h }) 1371 const resized_svg = await s.toBuffer(); 1372 const og = sharp(DUST_FILE) 1373 .resize({ height: h }) 1374 .composite([{ input: resized_svg }]) 1375 .png(); 1376 await og.toFile(OG_IMAGE_FILE); 1377 console.log(`generated OG image in ${Date.now() - og_start}ms`); 1378 s.destroy(); 1379 og.destroy(); 1380 })(); 1381 1382 console.log(`rendered constellation in ${Date.now() - start}ms`); 1383 } catch (error) { 1384 console.error('error rendering constellation:', error); 1385 } 1386};