data endpoint for entity 90008 (aka. a website)

implement constellation background!!! yay

ptr.pet 0f26681c e7c0dcaf

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