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
implement constellation background!!! yay
ptr.pet
2 months ago
0f26681c
e7c0dcaf
verified
This commit was signed with the committer's
known signature
.
ptr.pet
SSH Key Fingerprint:
SHA256:Abmvag+juovVufZTxyWY8KcVgrznxvBjQpJesv071Aw=
+1112
-109
10 changed files
expand all
collapse all
unified
split
deno.lock
eunomia
package.json
src
components
constellationOverlay.svelte
hooks.server.ts
lib
constellation.ts
routes
(site)
+layout.server.ts
+layout.svelte
_api
background
+server.ts
styles
app.css
flake.nix
+63
-24
deno.lock
reviewed
···
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": "10.0.12",
23
24
"npm:mdsvex@~0.12.6": "0.12.6_svelte@5.46.1__acorn@8.15.0",
24
25
"npm:nanoid@^5.1.6": "5.1.6",
25
26
"npm:node-fetch@^3.3.2": "3.3.2",
···
28
29
"npm:prettier@^3.7.4": "3.7.4",
29
30
"npm:prometheus-remote-write@~0.5.1": "0.5.1_node-fetch@3.3.2",
30
31
"npm:robots-parser@^3.0.1": "3.0.1",
32
32
+
"npm:skia-canvas@^3.0.8": "3.0.8",
31
33
"npm:steamgriddb@^2.2.1": "2.2.1",
32
34
"npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
33
35
"npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0",
···
35
37
"npm:tailwindcss@^3.4.19": "3.4.19_postcss@8.5.6_jiti@1.21.7",
36
38
"npm:toad-scheduler@^3.1.0": "3.1.0",
37
39
"npm:tslib@^2.8.1": "2.8.1",
38
38
-
"npm:typescript-eslint@^8.52.0": "8.52.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.52.0__eslint@9.39.2__typescript@5.9.3",
40
40
+
"npm:typescript-eslint@^8.53.0": "8.53.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.53.0__eslint@9.39.2__typescript@5.9.3",
39
41
"npm:typescript-svelte-plugin@~0.3.50": "0.3.50_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
40
42
"npm:typescript@^5.9.3": "5.9.3",
41
43
"npm:vite@^7.3.1": "7.3.1_@types+node@25.0.6_picomatch@4.0.3"
···
773
775
"@types/unist@2.0.11": {
774
776
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="
775
777
},
776
776
-
"@typescript-eslint/eslint-plugin@8.52.0_@typescript-eslint+parser@8.52.0__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": {
777
777
-
"integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==",
778
778
+
"@typescript-eslint/eslint-plugin@8.53.0_@typescript-eslint+parser@8.53.0__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": {
779
779
+
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
778
780
"dependencies": [
779
781
"@eslint-community/regexpp",
780
782
"@typescript-eslint/parser",
···
789
791
"typescript"
790
792
]
791
793
},
792
792
-
"@typescript-eslint/parser@8.52.0_eslint@9.39.2_typescript@5.9.3": {
793
793
-
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
794
794
+
"@typescript-eslint/parser@8.53.0_eslint@9.39.2_typescript@5.9.3": {
795
795
+
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
794
796
"dependencies": [
795
797
"@typescript-eslint/scope-manager",
796
798
"@typescript-eslint/types",
···
801
803
"typescript"
802
804
]
803
805
},
804
804
-
"@typescript-eslint/project-service@8.52.0_typescript@5.9.3": {
805
805
-
"integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==",
806
806
+
"@typescript-eslint/project-service@8.53.0_typescript@5.9.3": {
807
807
+
"integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==",
806
808
"dependencies": [
807
809
"@typescript-eslint/tsconfig-utils",
808
810
"@typescript-eslint/types",
···
810
812
"typescript"
811
813
]
812
814
},
813
813
-
"@typescript-eslint/scope-manager@8.52.0": {
814
814
-
"integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==",
815
815
+
"@typescript-eslint/scope-manager@8.53.0": {
816
816
+
"integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==",
815
817
"dependencies": [
816
818
"@typescript-eslint/types",
817
819
"@typescript-eslint/visitor-keys"
818
820
]
819
821
},
820
820
-
"@typescript-eslint/tsconfig-utils@8.52.0_typescript@5.9.3": {
821
821
-
"integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==",
822
822
+
"@typescript-eslint/tsconfig-utils@8.53.0_typescript@5.9.3": {
823
823
+
"integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==",
822
824
"dependencies": [
823
825
"typescript"
824
826
]
825
827
},
826
826
-
"@typescript-eslint/type-utils@8.52.0_eslint@9.39.2_typescript@5.9.3": {
827
827
-
"integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==",
828
828
+
"@typescript-eslint/type-utils@8.53.0_eslint@9.39.2_typescript@5.9.3": {
829
829
+
"integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==",
828
830
"dependencies": [
829
831
"@typescript-eslint/types",
830
832
"@typescript-eslint/typescript-estree",
···
835
837
"typescript"
836
838
]
837
839
},
838
838
-
"@typescript-eslint/types@8.52.0": {
839
839
-
"integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="
840
840
+
"@typescript-eslint/types@8.53.0": {
841
841
+
"integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="
840
842
},
841
841
-
"@typescript-eslint/typescript-estree@8.52.0_typescript@5.9.3": {
842
842
-
"integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==",
843
843
+
"@typescript-eslint/typescript-estree@8.53.0_typescript@5.9.3": {
844
844
+
"integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==",
843
845
"dependencies": [
844
846
"@typescript-eslint/project-service",
845
847
"@typescript-eslint/tsconfig-utils",
···
853
855
"typescript"
854
856
]
855
857
},
856
856
-
"@typescript-eslint/utils@8.52.0_eslint@9.39.2_typescript@5.9.3": {
857
857
-
"integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==",
858
858
+
"@typescript-eslint/utils@8.53.0_eslint@9.39.2_typescript@5.9.3": {
859
859
+
"integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==",
858
860
"dependencies": [
859
861
"@eslint-community/eslint-utils",
860
862
"@typescript-eslint/scope-manager",
···
864
866
"typescript"
865
867
]
866
868
},
867
867
-
"@typescript-eslint/visitor-keys@8.52.0": {
868
868
-
"integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==",
869
869
+
"@typescript-eslint/visitor-keys@8.53.0": {
870
870
+
"integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==",
869
871
"dependencies": [
870
872
"@typescript-eslint/types",
871
873
"eslint-visitor-keys@4.2.1"
···
881
883
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
882
884
"bin": true
883
885
},
886
886
+
"agent-base@7.1.4": {
887
887
+
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
888
888
+
},
884
889
"ajv@6.12.6": {
885
890
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
886
891
"dependencies": [
···
1090
1095
},
1091
1096
"delayed-stream@1.0.0": {
1092
1097
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
1098
1098
+
},
1099
1099
+
"detect-libc@2.1.2": {
1100
1100
+
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
1093
1101
},
1094
1102
"devalue@5.5.0": {
1095
1103
"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w=="
···
1463
1471
"html-entities@2.6.0": {
1464
1472
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="
1465
1473
},
1474
1474
+
"https-proxy-agent@7.0.6": {
1475
1475
+
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
1476
1476
+
"dependencies": [
1477
1477
+
"agent-base",
1478
1478
+
"debug"
1479
1479
+
]
1480
1480
+
},
1466
1481
"ignore@5.3.2": {
1467
1482
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
1468
1483
},
···
1552
1567
},
1553
1568
"known-css-properties@0.37.0": {
1554
1569
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="
1570
1570
+
},
1571
1571
+
"konva@10.0.12": {
1572
1572
+
"integrity": "sha512-DHmkeG5FbW6tLCkbMQTi1ihWycfzljrn0V7umUUuewxx7aoINcI71ksgBX9fTPNXhlsK4/JoMgKwI/iCde+BRw=="
1555
1573
},
1556
1574
"levn@0.4.1": {
1557
1575
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
···
1726
1744
"dependencies": [
1727
1745
"callsites"
1728
1746
]
1747
1747
+
},
1748
1748
+
"parenthesis@3.1.8": {
1749
1749
+
"integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw=="
1729
1750
},
1730
1751
"partysocket@1.1.6": {
1731
1752
"integrity": "sha512-LkEk8N9hMDDsDT0iDK0zuwUDFVrVMUXFXCeN3850Ng8wtjPqPBeJlwdeY6ROlJSEh3tPoTTasXoSBYH76y118w==",
···
2015
2036
"totalist"
2016
2037
]
2017
2038
},
2039
2039
+
"skia-canvas@3.0.8": {
2040
2040
+
"integrity": "sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==",
2041
2041
+
"dependencies": [
2042
2042
+
"detect-libc",
2043
2043
+
"follow-redirects",
2044
2044
+
"https-proxy-agent",
2045
2045
+
"string-split-by"
2046
2046
+
],
2047
2047
+
"scripts": true
2048
2048
+
},
2018
2049
"snappyjs@0.6.1": {
2019
2050
"integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg=="
2020
2051
},
···
2027
2058
"axios"
2028
2059
]
2029
2060
},
2061
2061
+
"string-split-by@1.0.0": {
2062
2062
+
"integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==",
2063
2063
+
"dependencies": [
2064
2064
+
"parenthesis"
2065
2065
+
]
2066
2066
+
},
2030
2067
"strip-json-comments@3.1.1": {
2031
2068
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
2032
2069
},
···
2202
2239
"prelude-ls"
2203
2240
]
2204
2241
},
2205
2205
-
"typescript-eslint@8.52.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.52.0__eslint@9.39.2__typescript@5.9.3": {
2206
2206
-
"integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==",
2242
2242
+
"typescript-eslint@8.53.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.53.0__eslint@9.39.2__typescript@5.9.3": {
2243
2243
+
"integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==",
2207
2244
"dependencies": [
2208
2245
"@typescript-eslint/eslint-plugin",
2209
2246
"@typescript-eslint/parser",
···
2357
2394
"npm:eslint-plugin-svelte@^3.14.0",
2358
2395
"npm:eslint@^9.39.2",
2359
2396
"npm:globals@^16.5.0",
2397
2397
+
"npm:konva@^10.0.12",
2360
2398
"npm:mdsvex@~0.12.6",
2361
2399
"npm:nanoid@^5.1.6",
2362
2400
"npm:node-fetch@^3.3.2",
···
2365
2403
"npm:prettier@^3.7.4",
2366
2404
"npm:prometheus-remote-write@~0.5.1",
2367
2405
"npm:robots-parser@^3.0.1",
2406
2406
+
"npm:skia-canvas@^3.0.8",
2368
2407
"npm:steamgriddb@^2.2.1",
2369
2408
"npm:svelte-check@^4.3.5",
2370
2409
"npm:svelte@^5.46.1",
···
2372
2411
"npm:tailwindcss@^3.4.19",
2373
2412
"npm:toad-scheduler@^3.1.0",
2374
2413
"npm:tslib@^2.8.1",
2375
2375
-
"npm:typescript-eslint@^8.52.0",
2414
2414
+
"npm:typescript-eslint@^8.53.0",
2376
2415
"npm:typescript-svelte-plugin@~0.3.50",
2377
2416
"npm:typescript@^5.9.3",
2378
2417
"npm:vite@^7.3.1"
+5
-3
eunomia/package.json
reviewed
···
31
31
"tailwindcss": "^3.4.19",
32
32
"tslib": "^2.8.1",
33
33
"typescript": "^5.9.3",
34
34
-
"typescript-eslint": "^8.52.0",
34
34
+
"typescript-eslint": "^8.53.0",
35
35
"typescript-svelte-plugin": "^0.3.50",
36
36
"vite": "^7.3.1"
37
37
},
···
48
48
"prometheus-remote-write": "^0.5.1",
49
49
"robots-parser": "^3.0.1",
50
50
"steamgriddb": "^2.2.1",
51
51
-
"toad-scheduler": "^3.1.0"
51
51
+
"toad-scheduler": "^3.1.0",
52
52
+
"konva": "^10.0.12",
53
53
+
"skia-canvas": "^3.0.8"
52
54
},
53
55
"trustedDependencies": [
54
56
"@sveltejs/kit",
···
57
59
"sharp",
58
60
"svelte-preprocess"
59
61
]
60
60
-
}
62
62
+
}
+114
eunomia/src/components/constellationOverlay.svelte
reviewed
···
1
1
+
<script lang="ts">
2
2
+
import { genDollcode } from '$lib/dollcode';
3
3
+
import { onMount } from 'svelte';
4
4
+
5
5
+
interface Star {
6
6
+
domain: string;
7
7
+
x: number;
8
8
+
y: number;
9
9
+
r: number;
10
10
+
}
11
11
+
12
12
+
interface StarsData {
13
13
+
width: number;
14
14
+
height: number;
15
15
+
stars: Star[];
16
16
+
meta?: {
17
17
+
timestamp: string;
18
18
+
angleY: number;
19
19
+
angleX: number;
20
20
+
};
21
21
+
}
22
22
+
23
23
+
interface Props {
24
24
+
stars: StarsData | null;
25
25
+
isUIHidden: boolean;
26
26
+
}
27
27
+
28
28
+
let { stars, isUIHidden }: Props = $props();
29
29
+
30
30
+
let containerWidth = $state(0);
31
31
+
let containerHeight = $state(0);
32
32
+
33
33
+
let scale = $derived.by(() => {
34
34
+
if (!stars || containerWidth === 0 || containerHeight === 0) return 0;
35
35
+
36
36
+
const imgRatio = stars.width / stars.height;
37
37
+
const containerRatio = containerWidth / containerHeight;
38
38
+
39
39
+
if (containerRatio > imgRatio) {
40
40
+
return containerWidth / stars.width;
41
41
+
} else {
42
42
+
return containerHeight / stars.height;
43
43
+
}
44
44
+
});
45
45
+
46
46
+
let offsetX = $derived.by(() => {
47
47
+
if (!stars || scale === 0) return 0;
48
48
+
return (containerWidth - stars.width * scale) / 2;
49
49
+
});
50
50
+
51
51
+
let offsetY = $derived.by(() => {
52
52
+
if (!stars || scale === 0) return 0;
53
53
+
return (containerHeight - stars.height * scale) / 2;
54
54
+
});
55
55
+
</script>
56
56
+
57
57
+
<svelte:window bind:innerWidth={containerWidth} bind:innerHeight={containerHeight} />
58
58
+
59
59
+
{#if stars && scale > 0}
60
60
+
<div class="fixed inset-0 pointer-events-none {isUIHidden ? 'z-[2000]' : 'z-0'} overflow-hidden">
61
61
+
{#if stars.meta}
62
62
+
<div class="absolute top-4 left-4 origin-top-left meta-text">
63
63
+
<div>DATE: {stars.meta.timestamp}</div>
64
64
+
<div>ASCENSION: {(stars.meta.angleY * (180 / Math.PI)).toFixed(4)}°</div>
65
65
+
<div>DECLINATION: {(stars.meta.angleX * (180 / Math.PI)).toFixed(4)}°</div>
66
66
+
</div>
67
67
+
<div class="absolute top-4 right-4 origin-top-right meta-text">
68
68
+
<!-- encode meta data in dollcode -->
69
69
+
{genDollcode(
70
70
+
new Date(stars.meta.timestamp).getTime() + stars.meta.angleY + stars.meta.angleX
71
71
+
)}
72
72
+
</div>
73
73
+
{/if}
74
74
+
{#each stars.stars as star}
75
75
+
{@const screenX = star.x * scale + offsetX}
76
76
+
{@const screenY = star.y * scale + offsetY}
77
77
+
{@const radius = star.r * scale}
78
78
+
79
79
+
<!-- Only render if potentially visible -->
80
80
+
{#if screenX > -50 && screenX < containerWidth + 50 && screenY > -50 && screenY < containerHeight + 50}
81
81
+
<a
82
82
+
href="https://{star.domain}"
83
83
+
class="absolute pointer-events-auto group cursor-pointer"
84
84
+
style="
85
85
+
left: {screenX - radius}px;
86
86
+
top: {screenY - radius}px;
87
87
+
width: {radius * 2}px;
88
88
+
height: {radius * 2}px;
89
89
+
"
90
90
+
title={star.domain}
91
91
+
>
92
92
+
<span class="sr-only">{star.domain}</span>
93
93
+
<!-- Tooltip -->
94
94
+
<div
95
95
+
class="
96
96
+
absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1
97
97
+
bg-black/80 text-white text-sm opacity-0 group-hover:opacity-100
98
98
+
transition-opacity whitespace-nowrap pointer-events-none
99
99
+
border border-white/20
100
100
+
"
101
101
+
>
102
102
+
{star.domain}
103
103
+
</div>
104
104
+
</a>
105
105
+
{/if}
106
106
+
{/each}
107
107
+
</div>
108
108
+
{/if}
109
109
+
110
110
+
<style lang="postcss">
111
111
+
.meta-text {
112
112
+
@apply scale-50 text-white/70 drop-shadow-[0_2px_2px_rgba(0,0,0,1.0)] select-none z-10 pointer-events-none flex flex-col gap-0.5;
113
113
+
}
114
114
+
</style>
+20
eunomia/src/hooks.server.ts
reviewed
···
14
14
import { error, type Handle } from '@sveltejs/kit';
15
15
import { _fetchEntries } from './routes/(site)/guestbook/+page.server.ts';
16
16
import { sequence } from '@sveltejs/kit/hooks';
17
17
+
import { initConstellation, renderConstellation } from '$lib/constellation.ts';
18
18
+
19
19
+
// Init constellation on startup (non-blocking)
20
20
+
initConstellation();
17
21
18
22
const updateNowPlaying = async () => {
19
23
try {
···
46
50
{ seconds: 30 },
47
51
new AsyncTask('refreshContent task', refreshContent, (err) =>
48
52
console.log(`error while refreshContent: ${err}`)
53
53
+
)
54
54
+
)
55
55
+
);
56
56
+
scheduler.addSimpleIntervalJob(
57
57
+
new SimpleIntervalJob(
58
58
+
{ minutes: 1 },
59
59
+
new AsyncTask('rotateConstellation task', renderConstellation, (err) =>
60
60
+
console.log(`error while rotateConstellation: ${err}`)
61
61
+
)
62
62
+
)
63
63
+
);
64
64
+
scheduler.addSimpleIntervalJob(
65
65
+
new SimpleIntervalJob(
66
66
+
{ days: 1 },
67
67
+
new AsyncTask('initConstellation task', initConstellation, (err) =>
68
68
+
console.log(`error while initConstellation: ${err}`)
49
69
)
50
70
)
51
71
);
+778
eunomia/src/lib/constellation.ts
reviewed
···
1
1
+
import Konva from 'konva';
2
2
+
import 'konva/skia-backend';
3
3
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
4
4
+
import { join } from 'node:path';
5
5
+
import { env } from '$env/dynamic/private';
6
6
+
7
7
+
const DATA_DIR = join(env.WEBSITE_DATA_DIR, 'constellation');
8
8
+
const GRAPH_FILE = join(DATA_DIR, 'graph_processed.json');
9
9
+
const OUTPUT_FILE = join(DATA_DIR, 'background.png');
10
10
+
const STARS_FILE = join(DATA_DIR, 'stars.json');
11
11
+
const GRAPH_URL = 'https://eightyeightthirty.one/graph.json';
12
12
+
13
13
+
type GraphData = {
14
14
+
linksTo: Record<string, string[]>;
15
15
+
}
16
16
+
17
17
+
type Star = {
18
18
+
domain: string;
19
19
+
x: number;
20
20
+
y: number;
21
21
+
z: number;
22
22
+
connections: string[];
23
23
+
visualConnections: string[];
24
24
+
}
25
25
+
26
26
+
export type Nebula = { x: number, y: number, z: number, density: number };
27
27
+
export type Dust = { x: number, y: number, z: number, alpha: number, sizeFactor: number, color: string };
28
28
+
29
29
+
export type ConstellationData = {
30
30
+
stars: Star[];
31
31
+
nebulae: Nebula[];
32
32
+
dust: Dust[];
33
33
+
};
34
34
+
35
35
+
// Deterministic implementation with SeededRNG
36
36
+
class SeededRNG {
37
37
+
private seed: number;
38
38
+
constructor(seed: number) {
39
39
+
this.seed = seed;
40
40
+
}
41
41
+
42
42
+
next(): number {
43
43
+
this.seed = (this.seed * 1664525 + 1013904223) % 4294967296;
44
44
+
return this.seed / 4294967296;
45
45
+
}
46
46
+
47
47
+
range(min: number, max: number): number {
48
48
+
return min + this.next() * (max - min);
49
49
+
}
50
50
+
}
51
51
+
52
52
+
export const generateConstellationData = (data: GraphData, seed: number = 123456): ConstellationData => {
53
53
+
const rng = new SeededRNG(seed);
54
54
+
55
55
+
// Configuration
56
56
+
const MAX_STARS = 2000;
57
57
+
const CLUSTER_SIZE_MAX = 8;
58
58
+
const MIN_DIST = 140;
59
59
+
const STEP_SIZE = 150;
60
60
+
const ATTEMPTS = 32;
61
61
+
62
62
+
// Limits
63
63
+
const ROOT_DOMAIN_LIMIT_RATIO = 0.025; // 2.5% of MAX_STARS
64
64
+
const TOTAL_ROOT_DOMAIN_LIMIT_RATIO = 0.2; // 20% of MAX_STARS
65
65
+
const MAX_PER_ROOT = Math.floor(MAX_STARS * ROOT_DOMAIN_LIMIT_RATIO);
66
66
+
const MAX_TOTAL_ROOT = Math.floor(MAX_STARS * TOTAL_ROOT_DOMAIN_LIMIT_RATIO);
67
67
+
const SPECIAL_ROOTS = new Set(['neocities.org', 'wordpress.com', 'blogspot.com', 'blogfree.net', 'forumfree.it', 'forumcommunity.net', 'proboards.com', 'boards.net', 'jcink.net', 'forumactif.net', 'forumactif.org', 'forumactif.com', 'freeforums.net']);
68
68
+
69
69
+
const getRootDomain = (domain: string): string => {
70
70
+
const parts = domain.split('.');
71
71
+
if (parts.length > 2) {
72
72
+
const root = parts.slice(-2).join('.');
73
73
+
if (SPECIAL_ROOTS.has(root)) return root;
74
74
+
}
75
75
+
return domain; // Default to full domain if not special
76
76
+
};
77
77
+
78
78
+
const rootDomainCounts = new Map<string, number>();
79
79
+
80
80
+
// 1. Filter and Collect Stars
81
81
+
const allDomains = Object.keys(data.linksTo);
82
82
+
const validDomains = new Set<string>(allDomains);
83
83
+
84
84
+
// 2. Greedy Clustering
85
85
+
const stars: Star[] = [];
86
86
+
const visited = new Set<string>();
87
87
+
const clusters: { center: { x: number, y: number, z: number }, stars: Star[] }[] = [];
88
88
+
89
89
+
// Shuffle domains deterministically
90
90
+
for (let i = allDomains.length - 1; i > 0; i--) {
91
91
+
const j = Math.floor(rng.next() * (i + 1));
92
92
+
[allDomains[i], allDomains[j]] = [allDomains[j], allDomains[i]];
93
93
+
}
94
94
+
95
95
+
for (const domain of allDomains) {
96
96
+
if (visited.has(domain)) continue;
97
97
+
if (stars.length >= MAX_STARS) break;
98
98
+
99
99
+
const root = getRootDomain(domain);
100
100
+
const currentCount = rootDomainCounts.get(root) || 0;
101
101
+
102
102
+
// Only limit if it is one of the special roots
103
103
+
if (SPECIAL_ROOTS.has(root) && currentCount >= MAX_PER_ROOT) continue;
104
104
+
105
105
+
// or if the total number of special roots exceeds the limit
106
106
+
const totalSpecialCount = rootDomainCounts.entries().filter(([root]) => SPECIAL_ROOTS.has(root)).reduce((a, [, b]) => a + b, 0);
107
107
+
if (totalSpecialCount >= MAX_TOTAL_ROOT) continue;
108
108
+
109
109
+
// Start a new cluster
110
110
+
const clusterStars: Star[] = [];
111
111
+
const stack: string[] = [domain];
112
112
+
113
113
+
// We do NOT add to visited yet, we do it when we actually push to clusterStars
114
114
+
115
115
+
const skeletonEdges: [string, string][] = [];
116
116
+
const parents = new Map<string, string>();
117
117
+
118
118
+
// Fill cluster (greedy DFS)
119
119
+
while (stack.length > 0 && clusterStars.length < CLUSTER_SIZE_MAX) {
120
120
+
const current = stack.pop()!;
121
121
+
122
122
+
if (visited.has(current)) continue;
123
123
+
124
124
+
const currentRoot = getRootDomain(current);
125
125
+
const count = rootDomainCounts.get(currentRoot) || 0;
126
126
+
if (SPECIAL_ROOTS.has(currentRoot) && count >= MAX_PER_ROOT) continue;
127
127
+
128
128
+
visited.add(current);
129
129
+
rootDomainCounts.set(currentRoot, count + 1);
130
130
+
131
131
+
const links = data.linksTo[current] || [];
132
132
+
133
133
+
clusterStars.push({
134
134
+
domain: current,
135
135
+
x: 0, y: 0, z: 0,
136
136
+
connections: links,
137
137
+
visualConnections: []
138
138
+
});
139
139
+
140
140
+
const parent = parents.get(current);
141
141
+
if (parent) skeletonEdges.push([parent, current]);
142
142
+
143
143
+
const neighbors: string[] = [];
144
144
+
for (const link of links) {
145
145
+
if (!visited.has(link) && validDomains.has(link)) {
146
146
+
neighbors.push(link);
147
147
+
// Do not mark visited here, wait until we pop
148
148
+
parents.set(link, current);
149
149
+
}
150
150
+
}
151
151
+
152
152
+
// Randomize neighbors for DFS
153
153
+
for (let i = neighbors.length - 1; i > 0; i--) {
154
154
+
const j = Math.floor(rng.next() * (i + 1));
155
155
+
[neighbors[i], neighbors[j]] = [neighbors[j], neighbors[i]];
156
156
+
}
157
157
+
stack.push(...neighbors);
158
158
+
}
159
159
+
160
160
+
if (clusterStars.length > 0) {
161
161
+
clusters.push({ center: { x: 0, y: 0, z: 0 }, stars: clusterStars });
162
162
+
163
163
+
// Map stars for quick lookup
164
164
+
const clusterMap = new Map<string, Star>();
165
165
+
clusterStars.forEach(s => clusterMap.set(s.domain, s));
166
166
+
167
167
+
// Build dual-linked visual connections
168
168
+
skeletonEdges.forEach(([src, dst]) => {
169
169
+
const s1 = clusterMap.get(src);
170
170
+
const s2 = clusterMap.get(dst);
171
171
+
if (s1 && s2) {
172
172
+
s1.visualConnections.push(dst);
173
173
+
s2.visualConnections.push(src);
174
174
+
}
175
175
+
});
176
176
+
177
177
+
stars.push(...clusterStars);
178
178
+
}
179
179
+
}
180
180
+
181
181
+
// 3. Layout Clusters on Fibonacci Sphere
182
182
+
const phi = Math.PI * (3 - Math.sqrt(5));
183
183
+
const sphereRadius = 1800;
184
184
+
185
185
+
for (let i = 0; i < clusters.length; i++) {
186
186
+
const y = 1 - (i / (clusters.length - 1)) * 2;
187
187
+
const radiusAtY = Math.sqrt(1 - y * y);
188
188
+
const theta = phi * i;
189
189
+
190
190
+
clusters[i].center = {
191
191
+
x: Math.cos(theta) * radiusAtY * sphereRadius,
192
192
+
y: y * sphereRadius,
193
193
+
z: Math.sin(theta) * radiusAtY * sphereRadius
194
194
+
};
195
195
+
}
196
196
+
197
197
+
// 4. Layout Stars (Directional Bias with Global Collision)
198
198
+
const placedStars: Star[] = [];
199
199
+
200
200
+
// Helper: Rotate vector v around random axis by angle
201
201
+
const rotateVector = (v: { dx: number, dy: number, dz: number }, angle: number) => {
202
202
+
// Random axis perpendicular to v
203
203
+
const rx = rng.next() - 0.5;
204
204
+
const ry = rng.next() - 0.5;
205
205
+
const rz = rng.next() - 0.5;
206
206
+
207
207
+
// Cross product v x r
208
208
+
let cpx = v.dy * rz - v.dz * ry;
209
209
+
let cpy = v.dz * rx - v.dx * rz;
210
210
+
let cpz = v.dx * ry - v.dy * rx;
211
211
+
212
212
+
let cpLen = Math.sqrt(cpx * cpx + cpy * cpy + cpz * cpz);
213
213
+
if (cpLen < 0.001) { cpx = 1; cpy = 0; cpz = 0; cpLen = 1; }
214
214
+
215
215
+
// Axis k (normalized)
216
216
+
const kx = cpx / cpLen;
217
217
+
const ky = cpy / cpLen;
218
218
+
const kz = cpz / cpLen;
219
219
+
220
220
+
// Rodrigues rotation: v_rot = v * cos(a) + (k x v) * sin(a)
221
221
+
const cos = Math.cos(angle);
222
222
+
const sin = Math.sin(angle);
223
223
+
224
224
+
// k x v
225
225
+
const kxv_x = ky * v.dz - kz * v.dy;
226
226
+
const kxv_y = kz * v.dx - kx * v.dz;
227
227
+
const kxv_z = kx * v.dy - ky * v.dx;
228
228
+
229
229
+
const newDx = v.dx * cos + kxv_x * sin;
230
230
+
const newDy = v.dy * cos + kxv_y * sin;
231
231
+
const newDz = v.dz * cos + kxv_z * sin;
232
232
+
233
233
+
const len = Math.sqrt(newDx * newDx + newDy * newDy + newDz * newDz);
234
234
+
return { dx: newDx / len, dy: newDy / len, dz: newDz / len };
235
235
+
};
236
236
+
237
237
+
// Helper: Check collision
238
238
+
const checkCollision = (x: number, y: number, z: number) => {
239
239
+
const minDistSq = MIN_DIST * MIN_DIST;
240
240
+
for (const s of placedStars) {
241
241
+
const d2 = (s.x - x) ** 2 + (s.y - y) ** 2 + (s.z - z) ** 2;
242
242
+
if (d2 < minDistSq) return true;
243
243
+
}
244
244
+
return false;
245
245
+
};
246
246
+
247
247
+
for (const cluster of clusters) {
248
248
+
if (cluster.stars.length === 0) continue;
249
249
+
250
250
+
// Root star
251
251
+
const first = cluster.stars[0];
252
252
+
first.x = cluster.center.x;
253
253
+
first.y = cluster.center.y;
254
254
+
first.z = cluster.center.z;
255
255
+
256
256
+
placedStars.push(first);
257
257
+
const placedInCluster = new Set<string>([first.domain]);
258
258
+
259
259
+
// Initial random direction
260
260
+
const u = rng.next();
261
261
+
const v = rng.next();
262
262
+
const theta = 2 * Math.PI * u;
263
263
+
const phi = Math.acos(2 * v - 1);
264
264
+
265
265
+
const directions = new Map<string, { dx: number, dy: number, dz: number }>();
266
266
+
directions.set(first.domain, {
267
267
+
dx: Math.sin(phi) * Math.cos(theta),
268
268
+
dy: Math.sin(phi) * Math.sin(theta),
269
269
+
dz: Math.cos(phi)
270
270
+
});
271
271
+
272
272
+
const layoutQueue = [first];
273
273
+
274
274
+
while (layoutQueue.length > 0) {
275
275
+
const current = layoutQueue.shift()!;
276
276
+
const prevDir = directions.get(current.domain)!;
277
277
+
278
278
+
const unplacedNeighbors = current.visualConnections
279
279
+
.map(id => cluster.stars.find(s => s.domain === id))
280
280
+
.filter(s => s && !placedInCluster.has(s.domain)) as Star[];
281
281
+
282
282
+
for (const target of unplacedNeighbors) {
283
283
+
let bestPos = { x: 0, y: 0, z: 0 };
284
284
+
let bestDir = prevDir;
285
285
+
let placed = false;
286
286
+
287
287
+
// Try multiple directions
288
288
+
for (let i = 0; i < ATTEMPTS; i++) {
289
289
+
const angle = (30 + rng.next() * 60) * (Math.PI / 180);
290
290
+
const newDir = rotateVector(prevDir, angle);
291
291
+
292
292
+
const cx = current.x + newDir.dx * STEP_SIZE;
293
293
+
const cy = current.y + newDir.dy * STEP_SIZE;
294
294
+
const cz = current.z + newDir.dz * STEP_SIZE;
295
295
+
296
296
+
if (!checkCollision(cx, cy, cz)) {
297
297
+
bestPos = { x: cx, y: cy, z: cz };
298
298
+
bestDir = newDir;
299
299
+
placed = true;
300
300
+
break;
301
301
+
}
302
302
+
}
303
303
+
304
304
+
// Fallback: Force straight line
305
305
+
if (!placed) {
306
306
+
bestPos = {
307
307
+
x: current.x + prevDir.dx * STEP_SIZE,
308
308
+
y: current.y + prevDir.dy * STEP_SIZE,
309
309
+
z: current.z + prevDir.dz * STEP_SIZE
310
310
+
};
311
311
+
bestDir = prevDir;
312
312
+
}
313
313
+
314
314
+
target.x = bestPos.x;
315
315
+
target.y = bestPos.y;
316
316
+
target.z = bestPos.z;
317
317
+
318
318
+
placedInCluster.add(target.domain);
319
319
+
placedStars.push(target);
320
320
+
directions.set(target.domain, bestDir);
321
321
+
layoutQueue.push(target);
322
322
+
}
323
323
+
}
324
324
+
325
325
+
// Handle isolated/leftover stars
326
326
+
for (const star of cluster.stars) {
327
327
+
if (!placedInCluster.has(star.domain)) {
328
328
+
// Try random spots near center
329
329
+
for (let k = 0; k < 10; k++) {
330
330
+
const cx = cluster.center.x + (rng.next() - 0.5) * 200;
331
331
+
const cy = cluster.center.y + (rng.next() - 0.5) * 200;
332
332
+
const cz = cluster.center.z + (rng.next() - 0.5) * 200;
333
333
+
334
334
+
if (!checkCollision(cx, cy, cz) || k === 9) {
335
335
+
star.x = cx; star.y = cy; star.z = cz;
336
336
+
break;
337
337
+
}
338
338
+
}
339
339
+
placedInCluster.add(star.domain);
340
340
+
placedStars.push(star);
341
341
+
}
342
342
+
}
343
343
+
}
344
344
+
345
345
+
// 5. Generate Nebulae (Density-based)
346
346
+
const PROBE_COUNT = 300;
347
347
+
const SEARCH_RADIUS = 400;
348
348
+
const DENSITY_THRESHOLD = 4;
349
349
+
type NebulaDef = { x: number, y: number, z: number, density: number };
350
350
+
let candidates: NebulaDef[] = [];
351
351
+
352
352
+
for (let i = 0; i < PROBE_COUNT; i++) {
353
353
+
if (stars.length === 0) break;
354
354
+
const p = stars[Math.floor(rng.next() * stars.length)];
355
355
+
356
356
+
let neighbors = 0;
357
357
+
let sumX = 0, sumY = 0, sumZ = 0;
358
358
+
359
359
+
for (const s of stars) {
360
360
+
const dx = s.x - p.x;
361
361
+
const dy = s.y - p.y;
362
362
+
const dz = s.z - p.z;
363
363
+
const d2 = dx * dx + dy * dy + dz * dz;
364
364
+
if (d2 < SEARCH_RADIUS * SEARCH_RADIUS) {
365
365
+
neighbors++;
366
366
+
sumX += s.x;
367
367
+
sumY += s.y;
368
368
+
sumZ += s.z;
369
369
+
}
370
370
+
}
371
371
+
372
372
+
if (neighbors >= DENSITY_THRESHOLD)
373
373
+
candidates.push({
374
374
+
x: sumX / neighbors,
375
375
+
y: sumY / neighbors,
376
376
+
z: sumZ / neighbors,
377
377
+
density: neighbors
378
378
+
});
379
379
+
380
380
+
}
381
381
+
382
382
+
const nebulae: Nebula[] = [];
383
383
+
const MERGE_DIST = 400;
384
384
+
candidates.sort((a, b) => b.density - a.density);
385
385
+
386
386
+
console.log(`found ${candidates.length} density-based nebula candidates.`);
387
387
+
388
388
+
const breakDensity = candidates[Math.floor(candidates.length * 0.7)].density;
389
389
+
console.log(`70th percentile density: ${breakDensity}`);
390
390
+
391
391
+
for (const c of candidates) {
392
392
+
let merged = false;
393
393
+
for (const n of nebulae) {
394
394
+
const dist = Math.sqrt((c.x - n.x) ** 2 + (c.y - n.y) ** 2 + (c.z - n.z) ** 2);
395
395
+
if (dist < MERGE_DIST) {
396
396
+
n.density = Math.max(n.density, c.density);
397
397
+
merged = true;
398
398
+
break;
399
399
+
}
400
400
+
}
401
401
+
if (!merged) nebulae.push(c);
402
402
+
403
403
+
if (c.density < breakDensity) break;
404
404
+
}
405
405
+
console.log(`generated ${nebulae.length} density-based nebulae.`);
406
406
+
407
407
+
// 6. Generate Dust (Void Noise)
408
408
+
const dust: Dust[] = [];
409
409
+
const DUST_COUNT = 50000;
410
410
+
console.log('generating void noise...');
411
411
+
412
412
+
for (let i = 0; i < DUST_COUNT; i++) {
413
413
+
const u = rng.next();
414
414
+
const v = rng.next();
415
415
+
const theta = 2 * Math.PI * u;
416
416
+
const phi = Math.acos(2 * v - 1);
417
417
+
const r = sphereRadius * Math.cbrt(rng.next());
418
418
+
419
419
+
const dx = r * Math.sin(phi) * Math.cos(theta);
420
420
+
const dy = r * Math.sin(phi) * Math.sin(theta);
421
421
+
const dz = r * Math.cos(phi);
422
422
+
423
423
+
// Uniform distribution, no avoidance
424
424
+
const baseAlpha = 0.1 + rng.next() * 0.2;
425
425
+
426
426
+
dust.push({
427
427
+
x: dx,
428
428
+
y: dy,
429
429
+
z: dz,
430
430
+
alpha: baseAlpha,
431
431
+
sizeFactor: (0.5 + rng.next() * 1.5),
432
432
+
color: rng.next() > 0.5 ? '#FFFFFF' : '#AAAAAA'
433
433
+
});
434
434
+
}
435
435
+
console.log(`generated ${dust.length} dust particles.`);
436
436
+
437
437
+
return { stars, nebulae, dust };
438
438
+
}
439
439
+
440
440
+
export const initConstellation = async () => {
441
441
+
try {
442
442
+
mkdirSync(DATA_DIR, { recursive: true });
443
443
+
444
444
+
let start = Date.now();
445
445
+
console.log('fetching 88x31s graph data...');
446
446
+
const response = await fetch(GRAPH_URL);
447
447
+
const data: GraphData = await response.json();
448
448
+
console.log(`fetched 88x31s graph data in ${Date.now() - start}ms`);
449
449
+
450
450
+
start = Date.now();
451
451
+
console.log('generating constellation data...');
452
452
+
const { stars, nebulae, dust } = generateConstellationData(data);
453
453
+
454
454
+
// Save legacy map for stars if strictly needed, but better to save the whole object
455
455
+
// The render function needs the whole object now.
456
456
+
// We'll save the structure exactly as ConstellationData
457
457
+
458
458
+
writeFileSync(GRAPH_FILE, JSON.stringify({ stars, nebulae, dust }));
459
459
+
console.log(`${stars.length} stars, ${nebulae.length} nebulae, ${dust.length} dust particles generated in ${Date.now() - start}ms`);
460
460
+
461
461
+
await renderConstellation();
462
462
+
} catch (error) {
463
463
+
console.error('error initializing constellation:', error);
464
464
+
}
465
465
+
}
466
466
+
467
467
+
type ProjectedTrans = { x: number, y: number, scale: number, z: number };
468
468
+
469
469
+
export const renderConstellation = async () => {
470
470
+
try {
471
471
+
if (!existsSync(GRAPH_FILE)) {
472
472
+
await initConstellation();
473
473
+
return;
474
474
+
}
475
475
+
476
476
+
const start = Date.now();
477
477
+
console.log('rendering constellation...');
478
478
+
479
479
+
const constellationData: ConstellationData = JSON.parse(readFileSync(GRAPH_FILE, 'utf-8'));
480
480
+
const { stars, nebulae, dust } = constellationData;
481
481
+
482
482
+
// Canvas setup
483
483
+
const RESOLUTION_SCALE = 2; // 4K resolution
484
484
+
const width = 1920 * RESOLUTION_SCALE;
485
485
+
const height = 1080 * RESOLUTION_SCALE;
486
486
+
487
487
+
// Create stage and layer
488
488
+
const stage = new Konva.Stage({
489
489
+
width,
490
490
+
height,
491
491
+
});
492
492
+
493
493
+
const layer = new Konva.Layer({ imageSmoothingEnabled: false });
494
494
+
stage.add(layer);
495
495
+
496
496
+
// Background
497
497
+
const rect = new Konva.Rect({
498
498
+
width,
499
499
+
height,
500
500
+
fill: '#000000',
501
501
+
});
502
502
+
layer.add(rect);
503
503
+
504
504
+
const fov = 400 * RESOLUTION_SCALE; // Field of view equivalent
505
505
+
const cx = width / 2;
506
506
+
const cy = height / 2;
507
507
+
508
508
+
// Calculate angle based on time: one full rotation per 3 hours (Y-axis)
509
509
+
const periodY = 3 * 60 * 60 * 1000;
510
510
+
// Secondary rotation on X-axis to see the poles (slightly different period to avoid repeating patterns)
511
511
+
const periodX = 5.14 * 60 * 60 * 1000;
512
512
+
513
513
+
const date = Date.now();
514
514
+
const angleY = ((date % periodY) / periodY) * Math.PI * 2;
515
515
+
const angleX = ((date % periodX) / periodX) * Math.PI * 2;
516
516
+
517
517
+
const cosY = Math.cos(angleY);
518
518
+
const sinY = Math.sin(angleY);
519
519
+
const cosX = Math.cos(angleX);
520
520
+
const sinX = Math.sin(angleX);
521
521
+
522
522
+
// Helper: Apply 3D rotation (Y then X)
523
523
+
const rotatePoint = (x: number, y: number, z: number) => {
524
524
+
// Yaw (Y-axis)
525
525
+
const x1 = x * cosY - z * sinY;
526
526
+
const z1 = z * cosY + x * sinY;
527
527
+
const y1 = y;
528
528
+
529
529
+
// Pitch (X-axis)
530
530
+
const y2 = y1 * cosX - z1 * sinX;
531
531
+
const z2 = z1 * cosX + y1 * sinX;
532
532
+
const x2 = x1;
533
533
+
534
534
+
return { x: x2, y: y2, z: z2 };
535
535
+
};
536
536
+
537
537
+
// Project and draw
538
538
+
const projected: Record<string, ProjectedTrans> = {};
539
539
+
540
540
+
// 0. Universe Noise / Heatmap (Background Nebulae)
541
541
+
// Draw this BEFORE everything else so it's in the background
542
542
+
543
543
+
// Algorithm: Find dense clusters of stars to place nebulae
544
544
+
// 1. We'll use a simplified density estimation.
545
545
+
// Pick N random 'probe' points (existing stars) and calculate how many neighbors they have within Radius R.
546
546
+
// Higher neighbor count = higher density = larger/brighter nebula.
547
547
+
548
548
+
for (const n of nebulae) {
549
549
+
// Rotate matches star rotation
550
550
+
const { x: rotX, y: rotY, z: rotZ } = rotatePoint(n.x, n.y, n.z);
551
551
+
552
552
+
// Render if in front of camera
553
553
+
if (rotZ > 100) {
554
554
+
const scale = fov / rotZ;
555
555
+
const screenX = cx + rotX * scale;
556
556
+
const screenY = cy + (rotY * scale * -1);
557
557
+
558
558
+
// Density -> Size & Opacity
559
559
+
// Normalize density: typical range 5 to 50?
560
560
+
const intensity = Math.min(1, n.density / 25);
561
561
+
562
562
+
// Hues: Blue/Purple/Pink. Denser = shifting towards Pink/White?
563
563
+
// Stable random hue for this nebula based on position
564
564
+
// Use position as seed-ish
565
565
+
const hueSeed = Math.abs(Math.sin(n.x * n.y * n.z));
566
566
+
const hue = 200 + hueSeed * 80;
567
567
+
568
568
+
// Radius: Denser areas get BIGGER nebulae to cover the cluster
569
569
+
const radius = (600 + intensity * 800) * scale;
570
570
+
571
571
+
// Opacity: Denser = more opaque
572
572
+
const alpha = 0.2 + intensity * 0.5; // 0.2 to 0.7
573
573
+
574
574
+
// Create gradient
575
575
+
const circle = new Konva.Circle({
576
576
+
x: screenX,
577
577
+
y: screenY,
578
578
+
radius: radius,
579
579
+
fillRadialGradientStartPoint: { x: 0, y: 0 },
580
580
+
fillRadialGradientStartRadius: 0,
581
581
+
fillRadialGradientEndPoint: { x: 0, y: 0 },
582
582
+
fillRadialGradientEndRadius: radius,
583
583
+
fillRadialGradientColorStops: [
584
584
+
0, `hsla(${hue}, 90%, 60%, ${alpha})`,
585
585
+
1, 'hsla(0, 0%, 0%, 0)'
586
586
+
],
587
587
+
opacity: 1,
588
588
+
});
589
589
+
layer.add(circle);
590
590
+
}
591
591
+
}
592
592
+
593
593
+
// 0.5. Void Noise / Space Dust
594
594
+
for (const d of dust) {
595
595
+
const { x: rotX, y: rotY, z: rotZ } = rotatePoint(d.x, d.y, d.z);
596
596
+
597
597
+
if (rotZ > 100) {
598
598
+
const scale = fov / rotZ;
599
599
+
const screenX = cx + rotX * scale;
600
600
+
const screenY = cy + (rotY * scale * -1);
601
601
+
602
602
+
const size = d.sizeFactor * scale;
603
603
+
604
604
+
const rect = new Konva.Rect({
605
605
+
x: screenX,
606
606
+
y: screenY,
607
607
+
width: size,
608
608
+
height: size,
609
609
+
fill: d.color,
610
610
+
opacity: d.alpha,
611
611
+
});
612
612
+
layer.add(rect);
613
613
+
}
614
614
+
}
615
615
+
616
616
+
// 1. Projection pass
617
617
+
for (const star of stars) {
618
618
+
// Rotate
619
619
+
const { x: rotX, y: rotY, z: rotZ } = rotatePoint(star.x, star.y, star.z);
620
620
+
621
621
+
if (rotZ > 10) {
622
622
+
const scale = fov / rotZ;
623
623
+
const screenX = cx + rotX * scale;
624
624
+
const screenY = cy + (rotY * scale * -1); // Flip Y for screen coords
625
625
+
626
626
+
projected[star.domain] = { x: screenX, y: screenY, scale, z: rotZ };
627
627
+
}
628
628
+
}
629
629
+
630
630
+
// 2. Draw connections (lines) first so they are behind stars
631
631
+
// Track drawn connections to avoid duplicates (A-B and B-A)
632
632
+
const drawnConnections = new Set<string>();
633
633
+
634
634
+
// Need quick lookup for stars now that we don't have the map handy (or we rebuild it)
635
635
+
const starMap = new Map<string, Star>();
636
636
+
stars.forEach(s => starMap.set(s.domain, s));
637
637
+
638
638
+
type RenderLine = {
639
639
+
p1: { x: number, y: number, z: number };
640
640
+
p2: { x: number, y: number, z: number };
641
641
+
avgZ: number;
642
642
+
};
643
643
+
644
644
+
const linesToDraw: RenderLine[] = [];
645
645
+
646
646
+
for (const star of stars) {
647
647
+
if (!projected[star.domain]) continue;
648
648
+
649
649
+
const p1 = projected[star.domain];
650
650
+
651
651
+
if (star.visualConnections) {
652
652
+
for (const target of star.visualConnections) {
653
653
+
// Unique key for connection
654
654
+
const key = [star.domain, target].sort().join('-');
655
655
+
if (drawnConnections.has(key)) continue;
656
656
+
drawnConnections.add(key);
657
657
+
658
658
+
if (projected[target]) {
659
659
+
const p2 = projected[target];
660
660
+
const avgZ = (p1.z + p2.z) / 2;
661
661
+
linesToDraw.push({ p1, p2, avgZ });
662
662
+
}
663
663
+
}
664
664
+
}
665
665
+
}
666
666
+
667
667
+
// Sort lines by depth (furthest first) for correct layering
668
668
+
// Higher Z = further away
669
669
+
linesToDraw.sort((a, b) => b.avgZ - a.avgZ);
670
670
+
671
671
+
for (const line of linesToDraw) {
672
672
+
const { p1, p2, avgZ } = line;
673
673
+
674
674
+
// distance fading
675
675
+
// Closer = more opaque. Max opacity 0.8 at z=0, drops to 0 at z=3000
676
676
+
const opacity = Math.max(0.4, Math.min(1, 1 - (avgZ / 3000)));
677
677
+
const strokeWidth = Math.max(0.2 * RESOLUTION_SCALE, 1.5 * RESOLUTION_SCALE * (1000 / avgZ));
678
678
+
679
679
+
// Halo (Occlusion) - Draw a thick black line behind the white line
680
680
+
// Only effective if there are things behind it (which sorted order ensures)
681
681
+
const halo = new Konva.Line({
682
682
+
points: [p1.x, p1.y, p2.x, p2.y],
683
683
+
stroke: '#000000',
684
684
+
strokeWidth: strokeWidth + strokeWidth * opacity, // 2px padding on each side
685
685
+
opacity: 1, // Full occlusion
686
686
+
tension: 0,
687
687
+
});
688
688
+
layer.add(halo);
689
689
+
690
690
+
// Actual Line
691
691
+
const l = new Konva.Line({
692
692
+
points: [p1.x, p1.y, p2.x, p2.y],
693
693
+
stroke: '#FFFFFF',
694
694
+
strokeWidth,
695
695
+
opacity,
696
696
+
tension: 0,
697
697
+
});
698
698
+
layer.add(l);
699
699
+
}
700
700
+
701
701
+
const drawnStars: { star: Star, radius: number, opacity: number, proj: ProjectedTrans }[] = [];
702
702
+
// 3. draw star halos
703
703
+
for (const star of stars) {
704
704
+
if (!projected[star.domain]) continue;
705
705
+
const p = projected[star.domain];
706
706
+
707
707
+
const connectionCount = star.connections ? star.connections.length : 0;
708
708
+
const importance = Math.min(1.5, 1 + connectionCount * 0.1);
709
709
+
710
710
+
const radius = Math.max(1 * RESOLUTION_SCALE, 25 * p.scale * importance) * 0.4;
711
711
+
const haloRadius = radius * 1.6;
712
712
+
const opacity = Math.min(1, Math.max(0.2, 1000 / p.z));
713
713
+
const haloOpacity = opacity * 0.4;
714
714
+
715
715
+
const rect = new Konva.Rect({
716
716
+
x: p.x - haloRadius / 2,
717
717
+
y: p.y - haloRadius / 2,
718
718
+
width: haloRadius,
719
719
+
height: haloRadius,
720
720
+
fill: '#FFFFFF',
721
721
+
opacity: haloOpacity,
722
722
+
});
723
723
+
724
724
+
layer.add(rect);
725
725
+
drawnStars.push({ star, radius, opacity, proj: p });
726
726
+
}
727
727
+
728
728
+
// 4. Draw actual stars
729
729
+
for (const { star, radius, opacity, proj } of drawnStars) {
730
730
+
const rect = new Konva.Rect({
731
731
+
x: proj.x - radius / 2,
732
732
+
y: proj.y - radius / 2,
733
733
+
width: radius,
734
734
+
height: radius,
735
735
+
fill: '#EEEEEE',
736
736
+
opacity,
737
737
+
});
738
738
+
739
739
+
layer.add(rect);
740
740
+
}
741
741
+
742
742
+
layer.draw();
743
743
+
744
744
+
const buffer = await (stage.toCanvas() as any).toBuffer('png');
745
745
+
writeFileSync(OUTPUT_FILE, buffer);
746
746
+
747
747
+
// Export projected coordinates for frontend interactivity
748
748
+
const visibleStars = stars
749
749
+
.filter(star => projected[star.domain])
750
750
+
.map(star => {
751
751
+
const p = projected[star.domain];
752
752
+
// Match render logic for radius approx
753
753
+
const connectionCount = star.connections ? star.connections.length : 0;
754
754
+
const importance = Math.min(1.5, 1 + connectionCount * 0.1);
755
755
+
return {
756
756
+
domain: star.domain,
757
757
+
x: p.x,
758
758
+
y: p.y,
759
759
+
r: Math.max(1 * RESOLUTION_SCALE, 25 * p.scale * importance) * 0.7
760
760
+
};
761
761
+
});
762
762
+
763
763
+
writeFileSync(STARS_FILE, JSON.stringify({
764
764
+
width,
765
765
+
height,
766
766
+
stars: visibleStars,
767
767
+
meta: {
768
768
+
timestamp: new Date().toISOString(),
769
769
+
angleY,
770
770
+
angleX
771
771
+
}
772
772
+
}));
773
773
+
774
774
+
console.log(`rendered constellation in ${Date.now() - start}ms`);
775
775
+
} catch (error) {
776
776
+
console.error('error rendering constellation:', error);
777
777
+
}
778
778
+
}
+16
-1
eunomia/src/routes/(site)/+layout.server.ts
reviewed
···
4
4
import { lastVisitors } from '$lib/visits.js';
5
5
import { isIPv6 } from 'node:net';
6
6
import { get } from 'svelte/store';
7
7
+
import { readFileSync, existsSync } from 'node:fs';
8
8
+
import { join } from 'node:path';
9
9
+
import { env } from '$env/dynamic/private';
7
10
8
11
export const csr = true;
9
12
export const ssr = true;
···
58
61
eyePositions.push([top, left]);
59
62
}
60
63
64
64
+
let starsData = null;
65
65
+
try {
66
66
+
const DATA_DIR = join(env.WEBSITE_DATA_DIR, 'constellation');
67
67
+
const STARS_FILE = join(DATA_DIR, 'stars.json');
68
68
+
if (existsSync(STARS_FILE)) {
69
69
+
starsData = JSON.parse(readFileSync(STARS_FILE, 'utf-8'));
70
70
+
}
71
71
+
} catch (e) {
72
72
+
console.error('Failed to load stars data', e);
73
73
+
}
74
74
+
61
75
return {
62
76
route: url.pathname,
63
77
petTotalBounce: bounceCount.get(),
···
66
80
recentVisitCount,
67
81
eyePositions,
68
82
apiToken: getApiToken(),
69
69
-
ipv6: isIPv6(request.headers.get('x-real-ip') ?? 'localhost')
83
83
+
ipv6: isIPv6(request.headers.get('x-real-ip') ?? 'localhost'),
84
84
+
stars: starsData
70
85
};
71
86
};
+53
-38
eunomia/src/routes/(site)/+layout.svelte
reviewed
···
7
7
import Pet, { localBounces, localDistanceTravelled } from '$components/pet.svelte';
8
8
import Tooltip from '$components/tooltip.svelte';
9
9
import '$styles/app.css';
10
10
+
import ConstellationOverlay from '$components/constellationOverlay.svelte';
10
11
11
12
interface Props {
12
13
// eslint-disable-next-line @typescript-eslint/no-explicit-any
···
15
16
}
16
17
17
18
let { data, children }: Props = $props();
19
19
+
20
20
+
let isUIHidden = $state(false);
18
21
19
22
interface MenuItem {
20
23
href: string;
···
63
66
64
67
<div
65
68
class="
66
66
-
app-grid-background motion-safe:app-grid-background-anim
69
69
+
app-grid-background
67
70
fixed -z-10 w-full [height:100%] top-0 left-0
68
71
"
69
72
></div>
70
70
-
<div
71
71
-
class="
72
72
-
app-grid-background-second-layer motion-safe:app-grid-background-second-layer-anim
73
73
-
fixed -z-20 w-full [height:100%] top-0 left-0
74
74
-
"
75
75
-
></div>
73
73
+
74
74
+
<ConstellationOverlay stars={data.stars} {isUIHidden} />
76
75
77
76
<svg
78
77
xmlns="http://www.w3.org/2000/svg"
···
100
99
<feComposite in="SourceGraphic" in2="a" operator="in" />
101
100
<feMorphology operator="dilate" radius="5" />
102
101
</filter>
103
103
-
<filter
104
104
-
id="dither"
105
105
-
color-interpolation-filters="sRGB"
106
106
-
x="0"
107
107
-
y="0"
108
108
-
width="100%"
109
109
-
height="100%"
110
110
-
>
102
102
+
<filter id="dither" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">
111
103
<feImage
112
104
width="4"
113
105
height="4"
···
153
145
</defs>
154
146
</svg>
155
147
156
156
-
{#if !isResumePage}
148
148
+
{#if !isResumePage && !isUIHidden}
157
149
{#each data.lastVisitors as [id, visitor], index (id)}
158
150
{@const pos = eyePositions.at(index)}
159
151
{#if pos !== undefined}
···
163
155
{/if}
164
156
165
157
<div
166
166
-
class="md:h-[96vh] pb-[8vh] lg:px-[1vw] 2xl:px-[2vw] lg:pb-[3vh] lg:pt-[1vh] overflow-x-hidden [scrollbar-gutter:stable]"
158
158
+
class="md:h-[96vh] pb-[8vh] lg:px-[1vw] 2xl:px-[2vw] lg:pb-[3vh] lg:pt-[1vh] overflow-x-hidden [scrollbar-gutter:stable] transition-opacity duration-500"
159
159
+
class:opacity-0={isUIHidden}
160
160
+
class:pointer-events-none={isUIHidden}
161
161
+
aria-hidden={isUIHidden}
167
162
>
168
163
{@render children?.()}
169
164
</div>
170
165
171
166
{#if !isResumePage}
172
172
-
<Pet apiToken={data.apiToken} />
167
167
+
<div
168
168
+
class="transition-opacity duration-500"
169
169
+
class:opacity-0={isUIHidden}
170
170
+
class:pointer-events-none={isUIHidden}
171
171
+
>
172
172
+
<Pet apiToken={data.apiToken} />
173
173
+
</div>
173
174
{/if}
174
175
175
175
-
<nav class="w-full fixed bottom-0 z-[999] bg-ralsei-black overflow-visible">
176
176
+
<nav
177
177
+
class="w-full fixed bottom-0 z-[999] bg-ralsei-black overflow-visible transition-transform duration-500"
178
178
+
class:translate-y-full={isUIHidden}
179
179
+
>
176
180
<div
177
181
class="
178
182
max-w-full max-h-fit p-1 z-[999]
···
194
198
/>
195
199
{/if}
196
200
{#if isResumePage && menuIdx === 2}
197
197
-
<NavButton
198
198
-
highlight
199
199
-
name="resume"
200
200
-
href="/resume.pdf"
201
201
-
iconUri="/icons/about.webp"
202
202
-
/>
201
201
+
<NavButton highlight name="resume" href="/resume.pdf" iconUri="/icons/about.webp" />
203
202
{/if}
204
203
{/each}
205
204
<div class="hidden md:block grow"></div>
205
205
+
<button
206
206
+
class="navbox hover:animate-squiggle group relative"
207
207
+
onclick={() => (isUIHidden = !isUIHidden)}
208
208
+
title="hide ui"
209
209
+
>
210
210
+
hide ui
211
211
+
</button>
206
212
<div class="navbox">
207
213
<a
208
214
title="previous site"
209
215
class="hover:underline"
210
216
href="https://stellophiliac.github.io/roboring/gazesys/previous">⮜</a
211
217
>
212
212
-
<a class="hover:underline" href="https://stellophiliac.github.io/roboring"
213
213
-
>roboring</a
214
214
-
>
218
218
+
<a class="hover:underline" href="https://stellophiliac.github.io/roboring">roboring</a>
215
219
<a
216
220
title="next site"
217
221
class="hover:underline"
···
219
223
>
220
224
</div>
221
225
<div class="navbox">
222
222
-
<a
223
223
-
title="previous site"
224
224
-
class="hover:underline"
225
225
-
href="https://xn--sr8hvo.ws/previous">⮜</a
226
226
-
>
226
226
+
<a title="previous site" class="hover:underline" href="https://xn--sr8hvo.ws/previous">⮜</a>
227
227
<a class="hover:underline" href="https://xn--sr8hvo.ws">indieweb</a>
228
228
<a title="next site" class="hover:underline" href="https://xn--sr8hvo.ws/next">⮞</a>
229
229
</div>
···
250
250
using <span
251
251
class={data.ipv6
252
252
? 'text-ralsei-green-light text-shadow-green'
253
253
-
: 'text-red-500 text-shadow-red'}
254
254
-
>{data.ipv6 ? 'ipv6' : 'ipv4'}</span
253
253
+
: 'text-red-500 text-shadow-red'}>{data.ipv6 ? 'ipv6' : 'ipv4'}</span
255
254
>
256
255
</p>
257
256
</div>
···
280
279
{/snippet}
281
280
<div class="navbox">
282
281
<p>
283
283
-
<span class="text-ralsei-green-light text-shadow-green"
284
284
-
>{data.recentVisitCount}</span
285
285
-
> recent clicks
282
282
+
<span class="text-ralsei-green-light text-shadow-green">{data.recentVisitCount}</span> recent
283
283
+
clicks
286
284
</p>
287
285
</div>
288
286
</Tooltip>
···
290
288
</div>
291
289
</nav>
292
290
291
291
+
{#if isUIHidden}
292
292
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
293
293
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
294
294
+
<div
295
295
+
class="fixed inset-0 z-[1000] cursor-pointer"
296
296
+
onclick={() => {
297
297
+
isUIHidden = false;
298
298
+
}}
299
299
+
></div>
300
300
+
{/if}
301
301
+
293
302
<style lang="postcss">
303
303
+
@import '../../styles/app.css';
304
304
+
294
305
.navbox {
295
306
@apply flex gap-2 px-1 text-nowrap align-middle items-center text-center place-content-center border-ralsei-white border-4;
296
307
border-style: groove;
308
308
+
}
309
309
+
310
310
+
.navbox a:hover {
311
311
+
@apply animate-squiggle;
297
312
}
298
313
</style>
+20
eunomia/src/routes/_api/background/+server.ts
reviewed
···
1
1
+
import { readFileSync } from 'node:fs';
2
2
+
import { join } from 'node:path';
3
3
+
4
4
+
export const GET = async () => {
5
5
+
const DATA_DIR = 'data/constellation';
6
6
+
const OUTPUT_FILE = join(DATA_DIR, 'background.png');
7
7
+
8
8
+
try {
9
9
+
const file = readFileSync(OUTPUT_FILE);
10
10
+
return new Response(file, {
11
11
+
headers: {
12
12
+
'Content-Type': 'image/png',
13
13
+
'Cache-Control': 'public, max-age=60' // match rotation interval
14
14
+
}
15
15
+
});
16
16
+
} catch (e) {
17
17
+
console.error('Error serving background:', e);
18
18
+
return new Response('Not found', { status: 404 });
19
19
+
}
20
20
+
};
+40
-42
eunomia/src/styles/app.css
reviewed
···
39
39
.prose h1::before {
40
40
content: '[ ';
41
41
}
42
42
+
42
43
.prose h1::after {
43
44
content: ' ]';
44
45
}
···
46
47
.prose h2::before {
47
48
content: '[= ';
48
49
}
50
50
+
49
51
.prose h2::after {
50
52
content: ' =]';
51
53
}
···
53
55
.prose h3::before {
54
56
content: '[== ';
55
57
}
58
58
+
56
59
.prose h3::after {
57
60
content: ' ==]';
58
61
}
···
60
63
.prose h4::before {
61
64
content: '[=== ';
62
65
}
66
66
+
63
67
.prose h4::after {
64
68
content: ' ===]';
65
69
}
···
133
137
0% {
134
138
filter: url('#squiggly-0');
135
139
}
140
140
+
136
141
25% {
137
142
filter: url('#squiggly-1');
138
143
}
144
144
+
139
145
50% {
140
146
filter: url('#squiggly-2');
141
147
}
148
148
+
142
149
75% {
143
150
filter: url('#squiggly-3');
144
151
}
152
152
+
145
153
100% {
146
154
filter: url('#squiggly-4');
147
155
}
···
151
159
0% {
152
160
opacity: 1;
153
161
}
162
162
+
154
163
50% {
155
164
opacity: 0;
156
165
}
166
166
+
157
167
100% {
158
168
opacity: 1;
159
169
}
···
173
183
border-style: ridge;
174
184
}
175
185
176
176
-
.app-grid-background-anim {
177
177
-
animation: 4s linear app-grid-move-first-layer infinite;
178
178
-
}
179
179
-
180
180
-
.app-grid-background-second-layer-anim {
181
181
-
animation: 12s linear app-grid-move-second-layer infinite;
182
182
-
}
183
183
-
184
184
-
@keyframes app-grid-move-first-layer {
185
185
-
0% {
186
186
-
background-position: 0px 0px;
187
187
-
}
188
188
-
100% {
189
189
-
background-position: 126px 84px;
190
190
-
}
191
191
-
}
192
192
-
193
193
-
@keyframes app-grid-move-second-layer {
194
194
-
0% {
195
195
-
background-position: 96px 120px;
196
196
-
}
197
197
-
100% {
198
198
-
background-position: 0px 0px;
199
199
-
}
200
200
-
}
201
201
-
202
186
@media (prefers-reduced-motion: no-preference) {
203
187
@keyframes bounce-reverse {
188
188
+
204
189
0%,
205
190
100% {
206
191
transform: none;
207
192
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
208
193
}
194
194
+
209
195
50% {
210
196
transform: translateY(-25%);
211
197
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
212
198
}
213
199
}
214
200
}
201
201
+
215
202
@media (prefers-reduced-motion: no-preference) {
216
203
.animate-bounce-reverse:hover {
217
204
animation: bounce-reverse 1s infinite;
···
226
213
}
227
214
228
215
.app-grid-background {
229
229
-
background-image:
230
230
-
linear-gradient(theme(colors.ralsei.green.light / 0.4), transparent 2px),
231
231
-
linear-gradient(to right, theme(colors.ralsei.green.light / 0.4), transparent 2px);
232
232
-
background-size:
233
233
-
100% 42px,
234
234
-
42px 100%;
235
235
-
}
236
236
-
237
237
-
.app-grid-background-second-layer {
238
238
-
background-image:
239
239
-
linear-gradient(theme(colors.ralsei.pink.neon / 0.4), transparent 1px),
240
240
-
linear-gradient(to right, theme(colors.ralsei.pink.neon / 0.4), transparent 1px);
241
241
-
background-size:
242
242
-
100% 24px,
243
243
-
24px 100%;
216
216
+
background-image: url('/_api/background');
217
217
+
background-size: cover;
218
218
+
background-position: center;
219
219
+
background-repeat: no-repeat;
244
220
}
245
221
246
222
@media (prefers-reduced-motion: no-preference) {
···
283
259
scale: 0;
284
260
opacity: 0;
285
261
}
262
262
+
286
263
20% {
287
264
scale: 0;
288
265
}
266
266
+
289
267
60% {
290
268
opacity: 0.5;
291
269
}
270
270
+
292
271
100% {
293
272
scale: 1;
294
273
opacity: 1;
···
300
279
scale: 1 0;
301
280
opacity: 0;
302
281
}
282
282
+
303
283
20% {
304
284
scale: 1 0;
305
285
}
286
286
+
306
287
60% {
307
288
opacity: 0.5;
308
289
}
290
290
+
309
291
100% {
310
292
scale: 1 1;
311
293
opacity: 1;
···
317
299
scale: 0 1;
318
300
opacity: 0;
319
301
}
302
302
+
320
303
20% {
321
304
scale: 0 1;
322
305
}
306
306
+
323
307
60% {
324
308
opacity: 0.5;
325
309
}
310
310
+
326
311
100% {
327
312
scale: 1 1;
328
313
opacity: 1;
···
334
319
translate: 0 10rem;
335
320
opacity: 0;
336
321
}
322
322
+
337
323
20% {
338
324
translate: 0 10rem;
339
325
}
326
326
+
340
327
60% {
341
328
opacity: 0.5;
342
329
}
330
330
+
343
331
100% {
344
332
translate: normal;
345
333
opacity: 1;
···
351
339
translate: 0 -10rem;
352
340
opacity: 0;
353
341
}
342
342
+
354
343
20% {
355
344
translate: 0 -10rem;
356
345
}
346
346
+
357
347
60% {
358
348
opacity: 0.5;
359
349
}
350
350
+
360
351
100% {
361
352
translate: normal;
362
353
opacity: 1;
···
368
359
translate: 10rem 0;
369
360
opacity: 0;
370
361
}
362
362
+
371
363
20% {
372
364
translate: 10rem 0;
373
365
}
366
366
+
374
367
60% {
375
368
opacity: 0.5;
376
369
}
370
370
+
377
371
100% {
378
372
translate: normal;
379
373
opacity: 1;
···
385
379
translate: -10rem 0;
386
380
opacity: 0;
387
381
}
382
382
+
388
383
20% {
389
384
translate: -10rem 0;
390
385
}
386
386
+
391
387
60% {
392
388
opacity: 0.5;
393
389
}
390
390
+
394
391
100% {
395
392
translate: normal;
396
393
opacity: 1;
···
401
398
0% {
402
399
overflow: hidden;
403
400
}
401
401
+
404
402
100% {
405
403
overflow: auto;
406
404
}
407
405
}
408
408
-
}
406
406
+
}
+3
-1
flake.nix
reviewed
···
13
13
# inp.nci.flakeModule
14
14
];
15
15
perSystem = {
16
16
+
lib,
16
17
config,
17
18
pkgs,
18
19
...
···
20
21
devShells.default = pkgs.mkShell {
21
22
name = "eunomia-devshell";
22
23
packages = with pkgs; [
23
23
-
nodejs-slim_latest deno
24
24
+
nodejs-slim_latest deno skia
24
25
nodePackages.svelte-language-server
25
26
nodePackages.typescript-language-server
26
27
rustc rust-analyzer cargo wasm-pack wasm-bindgen-cli lld rustfmt binaryen
27
28
];
28
29
shellHook = ''
29
30
export PATH="$PATH:$PWD/node_modules/.bin"
31
31
+
export LD_LIBRARY_PATH="${lib.makeLibraryPath [pkgs.skia]}"
30
32
'';
31
33
};
32
34
packages.eunomia-modules = pkgs.callPackage ./nix/modules.nix {};