data endpoint for entity 90008 (aka. a website)
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};