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