authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-21 16:04:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
log579f44c68f4fd91813fbb5adcac82a4736b6c12d
tree69de6288f87dc5ea9528563f49c59391fe0c535d
parent00306a3d8c31b6a24d62794b40df738acd543577
signature Commit is signed but in an unrecognized format.

get clo file viewer running


54 files changed, 3858 insertions(+), 3144 deletions(-)

framework/bundle.ts+23-12
...@@ -28,24 +28,28 @@ export async function bundleClientJavaScript(...@@ -28,24 +28,28 @@ export async function bundleClientJavaScript(
28 ];28 ];
2929
30 const bundle = await esbuild.build({30 const bundle = await esbuild.build({
31 assetNames: "/asset/[hash]",
31 bundle: true,32 bundle: true,
32 chunkNames: "/js/c.[hash]",33 chunkNames: "/js/c.[hash]",
33 entryNames: "/js/[name]",34 entryNames: "/js/[name]",
34 assetNames: "/asset/[hash]",
35 entryPoints,35 entryPoints,
36 format: "esm",36 format: "esm",
37 jsx: "automatic",
38 jsxDev: dev,
39 jsxImportSource: "#ssr",
40 logLevel: "silent",
41 metafile: true,
37 minify: !dev,42 minify: !dev,
38 outdir: "/out!",43 outdir: "/out!",
39 plugins: clientPlugins,44 plugins: clientPlugins,
40 write: false,45 write: false,
41 metafile: true,
42 external: ["node_modules/"],
43 jsx: "automatic",
44 jsxImportSource: "#ssr",
45 jsxDev: dev,
46 define: {46 define: {
47 "ASSERT": "console.assert",47 "ASSERT": "console.assert",
48 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
48 },49 },
50 }).catch((err: any) => {
51 err.message = `Client ${err.message}`;
52 throw err;
49 });53 });
50 if (bundle.errors.length || bundle.warnings.length) {54 if (bundle.errors.length || bundle.warnings.length) {
51 throw new AggregateError(55 throw new AggregateError(
...@@ -66,7 +70,8 @@ export async function bundleClientJavaScript(...@@ -66,7 +70,8 @@ export async function bundleClientJavaScript(
66 const { text } = file;70 const { text } = file;
67 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");71 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
68 const { inputs } = UNWRAP(metafile.outputs["out!" + route]);72 const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
69 const sources = Object.keys(inputs);73 const sources = Object.keys(inputs)
74 .filter((x) => !x.startsWith("<define:"));
7075
71 // Register non-chunks as script entries.76 // Register non-chunks as script entries.
72 const chunk = route.startsWith("/js/c.");77 const chunk = route.startsWith("/js/c.");
...@@ -172,11 +177,15 @@ export async function bundleServerJavaScript(...@@ -172,11 +177,15 @@ export async function bundleServerJavaScript(
172 outdir: "/out!",177 outdir: "/out!",
173 plugins: serverPlugins,178 plugins: serverPlugins,
174 splitting: true,179 splitting: true,
180 logLevel: "silent",
175 write: false,181 write: false,
176 metafile: true,182 metafile: true,
177 jsx: "automatic",183 jsx: "automatic",
178 jsxImportSource: "#ssr",184 jsxImportSource: "#ssr",
179 jsxDev: false,185 jsxDev: false,
186 define: {
187 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
188 },
180 external: Object.keys(pkg.dependencies)189 external: Object.keys(pkg.dependencies)
181 .filter((x) => !x.startsWith("@paperclover")),190 .filter((x) => !x.startsWith("@paperclover")),
182 });191 });
...@@ -204,6 +213,7 @@ export async function bundleServerJavaScript(...@@ -204,6 +213,7 @@ export async function bundleServerJavaScript(
204 fileWithMagicWord,213 fileWithMagicWord,
205 },214 },
206 sources: Object.keys(metafile.inputs).filter((x) =>215 sources: Object.keys(metafile.inputs).filter((x) =>
216 !x.includes("<define:") &&
207 !x.startsWith("vfs:") &&217 !x.startsWith("vfs:") &&
208 !x.startsWith("dropped:") &&218 !x.startsWith("dropped:") &&
209 !x.includes("node_modules")219 !x.includes("node_modules")
...@@ -283,6 +293,9 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {...@@ -283,6 +293,9 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
283 .replaceAll("\\", "/");293 .replaceAll("\\", "/");
284 const cacheEntry = incr.out.serverMarko.get(key);294 const cacheEntry = incr.out.serverMarko.get(key);
285 if (!cacheEntry) {295 if (!cacheEntry) {
296 if (!fs.existsSync(file)) {
297 console.log(`File does not exist: ${file}`);
298 }
286 throw new Error("Marko file not in cache: " + file);299 throw new Error("Marko file not in cache: " + file);
287 }300 }
288 return ({301 return ({
...@@ -295,15 +308,13 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {...@@ -295,15 +308,13 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
295 },308 },
296 };309 };
297}310}
311
298import * as esbuild from "esbuild";312import * as esbuild from "esbuild";
299import * as path from "node:path";313import * as path from "node:path";
300import process from "node:process";314import process from "node:process";
301import * as hot from "./hot.ts";315import * as hot from "./hot.ts";
302import {316import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts";
303 banFiles,
304 projectRelativeResolution,
305 virtualFiles,
306} from "./esbuild-support.ts";
307import { Incremental } from "./incremental.ts";317import { Incremental } from "./incremental.ts";
308import * as css from "./css.ts";318import * as css from "./css.ts";
309import * as fs from "#sitegen/fs";319import * as fs from "#sitegen/fs";
320import * as mime from "#sitegen/mime";
framework/engine/ssr.ts+1-1
...@@ -86,7 +86,7 @@ export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];...@@ -86,7 +86,7 @@ export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
86 * to avoid functions that are missing a return statement. 86 * to avoid functions that are missing a return statement.
87 */ 87 */
88export type Component = ( 88export type Component = (
89 props: Record<string, unknown>, 89 props: Record<any, any>,
90) => Exclude<Node, undefined>; 90) => Exclude<Node, undefined>;
91 91
92/** 92/**
framework/lib/mime.ts+9-8
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1const entries = fs.readFileSync(1declare const MIME_INLINE_DATA: never;
2 path.join(import.meta.dirname, "mime.txt"),2const entries = typeof MIME_INLINE_DATA !== "undefined"
3 "utf8",3 ? MIME_INLINE_DATA
4)4 : fs.readFileSync(path.join(import.meta.dirname, "mime.txt"), "utf8")
5 .split("\n")5 .split("\n")
6 .map((line) => line.trim())6 .map((line) => line.trim())
7 .filter((line) => line && !line.startsWith("#"))7 .filter((line) => line && !line.startsWith("#"))
8 .map((line) => line.split(/\s+/, 2) as [string, string]);8 .map((line) => line.split(/\s+/, 2) as [string, string]);
9export const rawEntriesText = entries;
9const extensions = new Map(entries.filter((x) => x[0].startsWith(".")));10const extensions = new Map(entries.filter((x) => x[0].startsWith(".")));
10const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));11const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));
1112
framework/lib/sqlite.ts+8-1
...@@ -48,7 +48,14 @@ export class WrappedDatabase {...@@ -48,7 +48,14 @@ export class WrappedDatabase {
48 prepare<Args extends unknown[] = [], Result = unknown>(48 prepare<Args extends unknown[] = [], Result = unknown>(
49 query: string,49 query: string,
50 ): Stmt<Args, Result> {50 ): Stmt<Args, Result> {
51 return new Stmt(this.node.prepare(query));51 let prepared;
52 try {
53 prepared = this.node.prepare(query);
54 } catch (err) {
55 if (err) (err as { query: string }).query = query;
56 throw err;
57 }
58 return new Stmt(prepared);
52 }59 }
53}60}
5461
meow.txt deleted
Binary files a/meow.txt and /dev/null differ
package.json+2-2
...@@ -34,10 +34,10 @@...@@ -34,10 +34,10 @@
34 "node": "marko/debug/html"34 "node": "marko/debug/html"
35 },35 },
36 "#hono": "hono",36 "#hono": "hono",
37 "#hono/platform": {37 "#hono/conninfo": {
38 "bun": "hono/bun",38 "bun": "hono/bun",
39 "deno": "hono/deno",39 "deno": "hono/deno",
40 "node": "@hono/node-server",40 "node": "@hono/node-server/conninfo",
41 "worker": "hono/cloudflare-workers"41 "worker": "hono/cloudflare-workers"
42 }42 }
43 }43 }
repl.js+2-1
...@@ -11,7 +11,7 @@ process.stderr.write("Loading...");...@@ -11,7 +11,7 @@ process.stderr.write("Loading...");
11const { hot } = await import("./run.js"); // get plugins ready11const { hot } = await import("./run.js"); // get plugins ready
12const { errorAllWidgets } = hot.load("@paperclover/console/Widget");12const { errorAllWidgets } = hot.load("@paperclover/console/Widget");
13process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r");13process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r");
14hot.load("node:repl").start({14const repl = hot.load("node:repl").start({
15 prompt: "% ",15 prompt: "% ",
16 eval(code, _global, _id, done) {16 eval(code, _global, _id, done) {
17 evaluate(code)17 evaluate(code)
...@@ -25,6 +25,7 @@ hot.load("node:repl").start({...@@ -25,6 +25,7 @@ hot.load("node:repl").start({
25 ignoreUndefined: true,25 ignoreUndefined: true,
26 //completer,26 //completer,
27});27});
28repl.setupHistory(".clover/repl-history.txt", () => {});
2829
29setTimeout(() => {30setTimeout(() => {
30 hot.reloadRecursive("./framework/generate.ts");31 hot.reloadRecursive("./framework/generate.ts");
src/backend.ts+1-1
...@@ -10,7 +10,7 @@ app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));...@@ -10,7 +10,7 @@ app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
10app.use(admin.middleware); 10app.use(admin.middleware);
11 11
12app.route("", require("./q+a/backend.ts").app); 12app.route("", require("./q+a/backend.ts").app);
13// app.route("", require("./file-viewer/backend.tsx").app); 13app.route("", require("./file-viewer/backend.tsx").app);
14// app.route("", require("./friends/backend.tsx").app); 14// app.route("", require("./friends/backend.tsx").app);
15 15
16app.use(assets.middleware); 16app.use(assets.middleware);
src/blog/helpers.ts created
src/blog/layout.tsx created
src/blog/pages/25/marko-intro.markodown+1
...@@ -5,6 +5,7 @@ export const blog: BlogMeta = {...@@ -5,6 +5,7 @@ export const blog: BlogMeta = {
5 draft: true, 5 draft: true,
6}; 6};
7export const meta = formatBlogMeta(blob); 7export const meta = formatBlogMeta(blob);
8export * as layout from "@/blog/layout.tsx";
8 9
9I've been recently playing around [Marko][1], and after adding limited support 10I've been recently playing around [Marko][1], and after adding limited support
10for it in my website generator, [sitegen][2], I instantly fell in love with how 11for it in my website generator, [sitegen][2], I instantly fell in love with how
src/file-viewer/backend.tsx+29-30
...@@ -1,17 +1,4 @@...@@ -1,17 +1,4 @@
1import { type Context, Hono } from "hono";1export const app = new Hono();
2import * as path from "node:path";
3import { etagMatches, serveAsset } from "../assets.ts";
4import { FilePermissions, MediaFile } from "../db.ts";
5import { renderDynamicPage } from "../framework/dynamic-pages.ts";
6import { renderToStringSync } from "../framework/render-to-string.ts";
7import { MediaPanel } from "../pages-dynamic/file_viewer.tsx";
8import mimeTypeDb from "./mime.json" with { type: "json" };
9import { Speedbump } from "./cotyledon.tsx";
10import { hasAsset } from "../assets.ts";
11import { CompressionFormat, fetchFile, prefetchFile } from "./cache.ts";
12import { requireFriendAuth } from "../journal/backend.ts";
13
14const app = new Hono();
152
16interface APIDirectoryList {3interface APIDirectoryList {
17 path: string;4 path: string;
...@@ -86,13 +73,13 @@ app.get("/file/*", async (c, next) => {...@@ -86,13 +73,13 @@ app.get("/file/*", async (c, next) => {
86 }73 }
8774
88 // File listings75 // File listings
89 if (file.kind === MediaFile.Kind.directory) {76 if (file.kind === MediaFileKind.directory) {
90 if (c.req.header("Accept")?.includes("application/json")) {77 if (c.req.header("Accept")?.includes("application/json")) {
91 const json = {78 const json = {
92 path: file.path,79 path: file.path,
93 files: file.getPublicChildren().map((f) => ({80 files: file.getPublicChildren().map((f) => ({
94 basename: f.basename,81 basename: f.basename,
95 dir: f.kind === MediaFile.Kind.directory,82 dir: f.kind === MediaFileKind.directory,
96 time: f.date.getTime(),83 time: f.date.getTime(),
97 size: f.size,84 size: f.size,
98 duration: f.duration ? f.duration : null,85 duration: f.duration ? f.duration : null,
...@@ -101,7 +88,7 @@ app.get("/file/*", async (c, next) => {...@@ -101,7 +88,7 @@ app.get("/file/*", async (c, next) => {
101 } satisfies APIDirectoryList;88 } satisfies APIDirectoryList;
102 return c.json(json);89 return c.json(json);
103 }90 }
104 c.res = await renderDynamicPage(c.req.raw, "file_viewer", {91 c.res = await renderView(c, "file-viewer/clofi", {
105 file,92 file,
106 hasCotyledonCookie,93 hasCotyledonCookie,
107 });94 });
...@@ -115,7 +102,7 @@ app.get("/file/*", async (c, next) => {...@@ -115,7 +102,7 @@ app.get("/file/*", async (c, next) => {
115 }102 }
116 if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {103 if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {
117 prefetchFile(file.path);104 prefetchFile(file.path);
118 c.res = await renderDynamicPage(c.req.raw, "file_viewer", {105 c.res = await renderView(c, "file-viewer/clofi", {
119 file,106 file,
120 hasCotyledonCookie,107 hasCotyledonCookie,
121 });108 });
...@@ -220,7 +207,7 @@ app.get("/canvas/:script", async (c, next) => {...@@ -220,7 +207,7 @@ app.get("/canvas/:script", async (c, next) => {
220 if (!hasAsset(`/js/canvas/${script}.js`)) {207 if (!hasAsset(`/js/canvas/${script}.js`)) {
221 return next();208 return next();
222 }209 }
223 return renderDynamicPage(c.req.raw, "canvas", {210 return renderView(c, "file-viewer/canvas", {
224 script,211 script,
225 });212 });
226});213});
...@@ -238,7 +225,7 @@ function fileHeaders(...@@ -238,7 +225,7 @@ function fileHeaders(
238) {225) {
239 return {226 return {
240 Vary: "Accept-Encoding, Accept",227 Vary: "Accept-Encoding, Accept",
241 "Content-Type": mimeType(file.path),228 "Content-Type": contentTypeFor(file.path),
242 "Content-Length": size.toString(),229 "Content-Length": size.toString(),
243 ETag: file.hash,230 ETag: file.hash,
244 "Last-Modified": file.date.toUTCString(),231 "Last-Modified": file.date.toUTCString(),
...@@ -318,7 +305,7 @@ function applyRangesToBuffer(...@@ -318,7 +305,7 @@ function applyRangesToBuffer(
318 const result = new Uint8Array(rangeSize);305 const result = new Uint8Array(rangeSize);
319 let offset = 0;306 let offset = 0;
320 for (const [start, end] of ranges) {307 for (const [start, end] of ranges) {
321 result.set(buffer.slice(start, end + 1), offset);308 result.set(buffer.subarray(start, end + 1), offset);
322 offset += end - start + 1;309 offset += end - start + 1;
323 }310 }
324 return result;311 return result;
...@@ -379,18 +366,14 @@ function applySingleRangeToStream(...@@ -379,18 +366,14 @@ function applySingleRangeToStream(
379 });366 });
380}367}
381368
382function mimeType(file: string) {
383 return (mimeTypeDb as any)[path.extname(file)] ?? "application/octet-stream";
384}
385
386function getPartialPage(c: Context, rawFilePath: string) {369function getPartialPage(c: Context, rawFilePath: string) {
387 if (isCotyledonPath(rawFilePath)) {370 if (isCotyledonPath(rawFilePath)) {
388 if (!checkCotyledonCookie(c)) {371 if (!checkCotyledonCookie(c)) {
389 let root = Speedbump();372 let root = Speedbump();
390 // Remove the root element, it's created client side!373 // Remove the root element, it's created client side!
391 root = root.props.children;374 root = root[2].children as ssr.Element;
392375
393 const html = renderToStringSync(root);376 const html = ssr.ssrSync(root).text;
394 c.header("X-Cotyledon", "true");377 c.header("X-Cotyledon", "true");
395 return c.html(html);378 return c.html(html);
396 }379 }
...@@ -416,10 +399,26 @@ function getPartialPage(c: Context, rawFilePath: string) {...@@ -416,10 +399,26 @@ function getPartialPage(c: Context, rawFilePath: string) {
416 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),399 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
417 });400 });
418 // Remove the root element, it's created client side!401 // Remove the root element, it's created client side!
419 root = root.props.children;402 root = root[2].children as ssr.Element;
420403
421 const html = renderToStringSync(root);404 const html = ssr.ssrSync(root).text;
422 return c.html(html);405 return c.html(html);
423}406}
424407
425export { app as mediaApp };408import { type Context, Hono } from "hono";
409
410import * as ssr from "#ssr";
411import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
412import { renderView } from "#sitegen/view";
413import { contentTypeFor } from "#sitegen/mime";
414
415import { requireFriendAuth } from "@/friend-auth.ts";
416import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
417import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts";
418import { MediaPanel } from "@/file-viewer/views/clofi.tsx";
419import { Speedbump } from "@/file-viewer/cotyledon.tsx";
420import {
421 type CompressionFormat,
422 fetchFile,
423 prefetchFile,
424} from "@/file-viewer/cache.ts";
src/file-viewer/bin/scan3.ts created+2
...@@ -0,0 +1,2 @@
1import "@/file-viewer/models/MediaFile.ts";
2import "@/file-viewer/models/BlobAsset.ts";
src/file-viewer/cache.ts+3-3
...@@ -3,16 +3,16 @@ import * as fs from "node:fs";...@@ -3,16 +3,16 @@ import * as fs from "node:fs";
3import * as path from "node:path";3import * as path from "node:path";
4import { Buffer } from "node:buffer";4import { Buffer } from "node:buffer";
5import type { ClientRequest } from "node:http";5import type { ClientRequest } from "node:http";
6import { LRUCache } from "lru-cache";6import LRUCache from "lru-cache";
7import { open } from "node:fs/promises";7import { open } from "node:fs/promises";
8import { createHash } from "node:crypto";8import { createHash } from "node:crypto";
9import { scoped } from "@paperclover/console";9import { scoped } from "@paperclover/console";
10import { escapeUri } from "./share.ts";10import { escapeUri } from "./format.ts";
1111
12declare const Deno: any;12declare const Deno: any;
1313
14const sourceOfTruth = "https://nas.paperclover.net:43250";14const sourceOfTruth = "https://nas.paperclover.net:43250";
15const caCert = fs.readFileSync(path.join(import.meta.dirname, "cert.pem"));15const caCert = fs.readFileSync("src/file-viewer/cert.pem");
1616
17const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/");17const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/");
18const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB18const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB
src/file-viewer/cotyledon.tsx+209-149
...@@ -137,155 +137,215 @@ export function Readme() {...@@ -137,155 +137,215 @@ export function Readme() {
137}137}
138138
139export function ForEveryone() {139export function ForEveryone() {
140 // deno-fmt-ignore140 return (
141 return <><div class="for_everyone">141 <>
142<p>today is my 21st birthday. april 30th, 2025.</p>142 <div class="for_everyone">
143<p>it's been nearly six months starting hormones.</p>143 <p>today is my 21st birthday. april 30th, 2025.</p>
144<p>sometimes i feel great,</p>144 <p>it's been nearly six months starting hormones.</p>
145<p>sometimes i get dysphoria.</p>145 <p>sometimes i feel great,</p>
146<p>with the walls around me gone</p>146 <p>sometimes i get dysphoria.</p>
147<p>that shit hits way harder than it did before.</p>147 <p>with the walls around me gone</p>
148<p>ugh..</p>148 <p>that shit hits way harder than it did before.</p>
149<p>i'm glad the pain i felt is now explained,</p>149 <p>ugh..</p>
150<p>but now rendered in high definition.</p>150 <p>i'm glad the pain i felt is now explained,</p>
151<p>the smallest strands of hair on my face and belly act</p>151 <p>but now rendered in high definition.</p>
152<p>as sharpened nails to pierce my soul.</p>152 <p>the smallest strands of hair on my face and belly act</p>
153<p></p>153 <p>as sharpened nails to pierce my soul.</p>
154<p>it's all a pathway to better days; the sun had risen.</p>154 <p></p>
155<p>one little step at a time for both of us.</p>155 <p>it's all a pathway to better days; the sun had risen.</p>
156<p>today i quit my job. free falling, it feels so weird.</p>156 <p>one little step at a time for both of us.</p>
157<p>like sky diving.</p>157 <p>today i quit my job. free falling, it feels so weird.</p>
158<p>the only thing i feel is cold wind.</p>158 <p>like sky diving.</p>
159<p>the only thing i see is everything,</p>159 <p>the only thing i feel is cold wind.</p>
160<p>and it's beautiful.</p>160 <p>the only thing i see is everything,</p>
161<p>i have a month of falling before the parachute activates,</p>161 <p>and it's beautiful.</p>
162<p>gonna spend as much time of it on art as i can.</p>162 <p>i have a month of falling before the parachute activates,</p>
163<p>that was, after all, my life plan:</p>163 <p>gonna spend as much time of it on art as i can.</p>
164<p>i wanted to make art, all the time,</p>164 <p>that was, after all, my life plan:</p>
165<p>for everyone.</p>165 <p>i wanted to make art, all the time,</p>
166<p></p>166 <p>for everyone.</p>
167<p>then you see what happened</p>167 <p></p>
168<p>to the world and the internet.</p>168 <p>then you see what happened</p>
169<p>i never really got to live through that golden age,</p>169 <p>to the world and the internet.</p>
170<p>it probably sucked back then too.</p>170 <p>i never really got to live through that golden age,</p>
171<p>but now the big sites definitely stopped being fun.</p>171 <p>it probably sucked back then too.</p>
172<p>they slide their cold hands up my body</p>172 <p>but now the big sites definitely stopped being fun.</p>
173<p>and feel me around. it's unwelcoming, and</p>173 <p>they slide their cold hands up my body</p>
174<p>inconsiderate to how sensitive my skin is.</p>174 <p>and feel me around. it's unwelcoming, and</p>
175<p>i'm so fucking glad i broke up with YouTube</p>175 <p>inconsiderate to how sensitive my skin is.</p>
176<p>and their devilish friends.</p>176 <p>i'm so fucking glad i broke up with YouTube</p>
177<p>my NAS is at 5 / 24 TB</p>177 <p>and their devilish friends.</p>
178<p>and probably wont fill for the next decade.</p>178 <p>my NAS is at 5 / 24 TB</p>
179<p></p>179 <p>and probably wont fill for the next decade.</p>
180<p>it took 2 months for me to notice my body changed.</p>180 <p></p>
181<p>that day was really nice, but it hurt a lot.</p>181 <p>it took 2 months for me to notice my body changed.</p>
182<p>a sharp, satisfying pain in my chest gave me life.</p>182 <p>that day was really nice, but it hurt a lot.</p>
183<p>learned new instincts for my arms</p>183 <p>a sharp, satisfying pain in my chest gave me life.</p>
184<p>so they'd stop poking my new shape.</p>184 <p>learned new instincts for my arms</p>
185<p>when i look at my face</p>185 <p>so they'd stop poking my new shape.</p>
186<p>it's like a different person.</p>186 <p>when i look at my face</p>
187<p>she was the same as before, but completely new.</p>187 <p>it's like a different person.</p>
188<p>something changed</p>188 <p>she was the same as before, but completely new.</p>
189<p>or i'm now used to seeing what makes me smile.</p>189 <p>something changed</p>
190<p>regardless, whatever i see in the mirror, i smile.</p>190 <p>or i'm now used to seeing what makes me smile.</p>
191<p>and, i don't hear that old name much anymore</p>191 <p>regardless, whatever i see in the mirror, i smile.</p>
192<p>aside from nightmares. and you'll never repeat it, ok?</p>192 <p>and, i don't hear that old name much anymore</p>
193<p>okay.</p>193 <p>aside from nightmares. and you'll never repeat it, ok?</p>
194<p></p>194 <p>okay.</p>
195<p>been playing 'new canaan' by 'bill wurtz' on loop</p>195 <p></p>
196<p>in the background.</p>196 <p>been playing 'new canaan' by 'bill wurtz' on loop</p>
197<p>it kinda just feels right.</p>197 <p>in the background.</p>
198<p>especially when that verse near the end comes on.</p>198 <p>it kinda just feels right.</p>
199<p></p>199 <p>especially when that verse near the end comes on.</p>
200<p>more people have been allowed to visit me.</p>200 <p></p>
201<p>my apartment used to be just for me,</p>201 <p>more people have been allowed to visit me.</p>
202<p>but the more i felt like a person</p>202 <p>my apartment used to be just for me,</p>
203<p>the more i felt like having others over.</p>203 <p>but the more i felt like a person</p>
204<p>still have to decorate and clean it a little,</p>204 <p>the more i felt like having others over.</p>
205<p>but it isn't a job to do alone.</p>205 <p>still have to decorate and clean it a little,</p>
206<p>we dragged a giant a rug across the city one day,</p>206 <p>but it isn't a job to do alone.</p>
207<p>and it felt was like anything was possible.</p>207 <p>we dragged a giant a rug across the city one day,</p>
208<p>sometimes i have ten people visit in a day,</p>208 <p>and it felt was like anything was possible.</p>
209<p>or sometimes i focus my little eyes on just one.</p>209 <p>sometimes i have ten people visit in a day,</p>
210<p>i never really know what i want to do</p>210 <p>or sometimes i focus my little eyes on just one.</p>
211<p>until the time actually comes.</p>211 <p>i never really know what i want to do</p>
212<p></p>212 <p>until the time actually comes.</p>
213{/* FILIP */}213 <p></p>
214<p>i think about the times i was by the water with you.</p>214 {/* FILIP */}
215<p>the sun setting warmly, icy air fell on our shoulders.</p>215 <p>i think about the times i was by the water with you.</p>
216{/* NATALIE */}216 <p>the sun setting warmly, icy air fell on our shoulders.</p>
217<p>and how we walked up to the top of that hill,</p>217 {/* NATALIE */}
218<p>you picked up and disposed a nail on the ground,</p>218 <p>and how we walked up to the top of that hill,</p>
219<p>walking the city thru places i've never been.</p>219 <p>you picked up and disposed a nail on the ground,</p>
220{/* BEN */}220 <p>walking the city thru places i've never been.</p>
221<p>or hiking through the park talking about compilers,</p>221 {/* BEN */}
222<p>tiring me out until i'd fall asleep in your arms.</p>222 <p>or hiking through the park talking about compilers,</p>
223{/* ELENA */}223 <p>tiring me out until i'd fall asleep in your arms.</p>
224<p>and the way you held on to my hand as i woke up,</p>224 {/* ELENA */}
225<p>noticing how i was trying to hide nightmare's tears.</p>225 <p>and the way you held on to my hand as i woke up,</p>
226<p></p>226 <p>noticing how i was trying to hide nightmare's tears.</p>
227{/* HIGH SCHOOL */}227 <p></p>
228<p>i remember we were yelling lyrics loudly,</p>228 {/* HIGH SCHOOL */}
229<p>out of key yet cheered on because it was fun.</p>229 <p>i remember we were yelling lyrics loudly,</p>
230{/* ADVAITH/NATALIE */}230 <p>out of key yet cheered on because it was fun.</p>
231<p>and when we all toured the big corporate office,</p>231 {/* ADVAITH/NATALIE */}
232{/* AYU/HARRIS */}232 <p>and when we all toured the big corporate office,</p>
233<p>then snuck in to some startup's office after hours;</p>233 {/* AYU/HARRIS */}
234<p>i don't remember what movie we watched.</p>234 <p>then snuck in to some startup's office after hours;</p>
235{/* COLLEGE, DAY 1 IN EV's ROOM */}235 <p>i don't remember what movie we watched.</p>
236<p>i remember laying on the bunk bed,</p>236 {/* COLLEGE, DAY 1 IN EV's ROOM */}
237<p>while the rest played a card game.</p>237 <p>i remember laying on the bunk bed,</p>
238{/* MEGHAN/MORE */}238 <p>while the rest played a card game.</p>
239<p>with us all laying on the rug, staring at the TV</p>239 {/* MEGHAN/MORE */}
240<p>as the ending twist to {/* SEVERANCE */'that show'} was revealed.</p>240 <p>with us all laying on the rug, staring at the TV</p>
241<p></p>241 <p>
242<p>all the moments i cherish,</p>242 as the ending twist to {/* SEVERANCE */ "that show"} was revealed.
243<p>i love because it was always me.</p>243 </p>
244<p>i didn't have to pretend,</p>244 <p></p>
245<p>even if i didn't know who i was at the time.</p>245 <p>all the moments i cherish,</p>
246<p>you all were there. for me.</p>246 <p>i love because it was always me.</p>
247<p></p>247 <p>i didn't have to pretend,</p>
248<p>i don't want to pretend any more</p>248 <p>even if i didn't know who i was at the time.</p>
249<p>i want to be myself. for everyone.</p>249 <p>you all were there. for me.</p>
250<p></p>250 <p></p>
251<p>oh, the song ended. i thought it was on loop?</p>251 <p>i don't want to pretend any more</p>
252<p>it's late... can hear the crickets...</p>252 <p>i want to be myself. for everyone.</p>
253<p>and i can almost see the moon... mmmm...</p>253 <p></p>
254<p>...nah, too much light pollution.</p>254 <p>oh, the song ended. i thought it was on loop?</p>
255<p></p>255 <p>it's late... can hear the crickets...</p>
256<p>one day. one day.</p>256 <p>and i can almost see the moon... mmmm...</p>
257<p></p>257 <p>...nah, too much light pollution.</p>
258<p class="normal">before i go, i want to show the uncensored version of "journal about a girl", because i can trust you at least. keep in mind, i think you're one of the first people to ever see this.</p>258 <p></p>
259</div>259 <p>one day. one day.</p>
260<div class="for_everyone" style="max-width:80ch;">260 <p></p>
261<blockquote>261 <p class="normal">
262<p>journal - 2024-09-14</p>262 before i go, i want to show the uncensored version of "journal about a
263<p>been at HackMIT today on behalf of the company. it's fun. me and zack were running around looking for people that might be good hires. he had this magic arbitrary criteria to tell "oh this person is probably cracked let's talk to them" and we go to the first one. they were a nerd, perfect. they seemed to be extremely talented with some extreme software projects.<br/>263 girl", because i can trust you at least. keep in mind, i think you're
264okay.. oof... its still clouding my mind<br/>264 one of the first people to ever see this.
265i cant shake that feeling away</p>265 </p>
266<p>hold on...</p>266 </div>
267<p>at some point they open one of their profiles to navigate to some code, and it displays for a couple of seconds: "pronouns: she/they". i don't actually know anything about this person, but it was my perception that she is trans. their appearance, physique, and age felt similar to me, which tends makes people think you are male.</p>267 <div class="for_everyone" style="max-width:80ch;">
268<p>but... she was having fun being herself. being a legend of identity and of her skill in computer science. winning the physics major. making cool shit at the hackathon, and probably in life. my perception of her was the exact essence of who i myself wanted to be. i was jealous of her life.</p>268 <blockquote>
269<p>i tried hard to avoid a breakdown. success. but i was feeling distant. the next hour or so was disorienting, trying not to think about it too hard. i think there was one possibly interesting person we talked to. i don't remember any of the other conversations. they were not important. but i couldn't think through them regardless.</p>269 <p>journal - 2024-09-14</p>
270<p>later, i decided to read some of her code. i either have a huge dislike towards the Rust programming language and/or it was not high quality code. welp, so just is a person studying. my perception was just a perception, inaccurate but impacting. i know i need to become myself, whoever that is. otherwise, i'm just going to feel this shit at higher doses. i think about this every day, and the amount of time i feel being consumed by these problems only grows.</p>270 <p>
271<p>getting through it all is a lonely feeling. not because no one is around, but because i am isolated emotionally. i know other people hit these feelings, but we all are too afraid to speak up, and it's all lonely.</p>271 been at HackMIT today on behalf of the company. it's fun. me and
272<p>waiting on a reply from someone from healthcare. it'll be slow, but it will be okay.</p>272 zack were running around looking for people that might be good
273</blockquote>273 hires. he had this magic arbitrary criteria to tell "oh this person
274</div>274 is probably cracked let's talk to them" and we go to the first one.
275<div class="for_everyone">275 they were a nerd, perfect. they seemed to be extremely talented with
276<p class="normal">276 some extreme software projects.<br />
277i've learned that even when i feel alone, it doesn't have to feel lonely. i know it's hard, dear. i know it's scary. but i promise it's possible. we're all in this together. struggling together. sacrificing together. we dedicate our lives to each you, and our art for everyone.277 okay.. oof... its still clouding my mind<br />
278</p>278 i cant shake that feeling away
279 </p>
280 <p>hold on...</p>
281 <p>
282 at some point they open one of their profiles to navigate to some
283 code, and it displays for a couple of seconds: "pronouns: she/they".
284 i don't actually know anything about this person, but it was my
285 perception that she is trans. their appearance, physique, and age
286 felt similar to me, which tends makes people think you are male.
287 </p>
288 <p>
289 but... she was having fun being herself. being a legend of identity
290 and of her skill in computer science. winning the physics major.
291 making cool shit at the hackathon, and probably in life. my
292 perception of her was the exact essence of who i myself wanted to
293 be. i was jealous of her life.
294 </p>
295 <p>
296 i tried hard to avoid a breakdown. success. but i was feeling
297 distant. the next hour or so was disorienting, trying not to think
298 about it too hard. i think there was one possibly interesting person
299 we talked to. i don't remember any of the other conversations. they
300 were not important. but i couldn't think through them regardless.
301 </p>
302 <p>
303 later, i decided to read some of her code. i either have a huge
304 dislike towards the Rust programming language and/or it was not high
305 quality code. welp, so just is a person studying. my perception was
306 just a perception, inaccurate but impacting. i know i need to become
307 myself, whoever that is. otherwise, i'm just going to feel this shit
308 at higher doses. i think about this every day, and the amount of
309 time i feel being consumed by these problems only grows.
310 </p>
311 <p>
312 getting through it all is a lonely feeling. not because no one is
313 around, but because i am isolated emotionally. i know other people
314 hit these feelings, but we all are too afraid to speak up, and it's
315 all lonely.
316 </p>
317 <p>
318 waiting on a reply from someone from healthcare. it'll be slow, but
319 it will be okay.
320 </p>
321 </blockquote>
322 </div>
323 <div class="for_everyone">
324 <p class="normal">
325 i've learned that even when i feel alone, it doesn't have to feel
326 lonely. i know it's hard, dear. i know it's scary. but i promise it's
327 possible. we're all in this together. struggling together. sacrificing
328 together. we dedicate our lives to each you, and our art for everyone.
329 </p>
279330
280<p class="normal" style="font-size:2rem;color:#9C91FF;font-family:times,serif;font-style:italic">331 <p
281 and then we knew,<br/>332 class="normal"
282 just like paper airplanes: that we could fly...333 style="font-size:2rem;color:#9C91FF;font-family:times,serif;font-style:italic"
283</p>334 >
284<br />335 and then we knew,<br />
285<p class="normal">336 just like paper airplanes: that we could fly...
286 <a href="/" style='text-decoration:underline;text-underline-offset:0.2em;'>fin.</a>337 </p>
287</p>338 <br />
288</div>339 <p class="normal">
289</>340 <a
341 href="/"
342 style="text-decoration:underline;text-underline-offset:0.2em;"
343 >
344 fin.
345 </a>
346 </p>
347 </div>
348 </>
349 );
290}350}
291ForEveryone.class = "text";351ForEveryone.class = "text";
src/file-viewer/format.ts+14-19
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1const findDomain = "paperclover.net";
2
1export function formatSize(bytes: number) {3export function formatSize(bytes: number) {
2 if (bytes < 1024) return `${bytes} bytes`;4 if (bytes < 1024) return `${bytes} bytes`;
3 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;5 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
...@@ -6,21 +8,25 @@ export function formatSize(bytes: number) {...@@ -6,21 +8,25 @@ export function formatSize(bytes: number) {
6 }8 }
7 return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;9 return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
8}10}
11
9export function formatDate(date: Date) {12export function formatDate(date: Date) {
10 // YYYY-MM-DD, format in PST timezone13 // YYYY-MM-DD, format in PST timezone
11 return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });14 return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });
12}15}
16
13export function formatShortDate(date: Date) {17export function formatShortDate(date: Date) {
14 // YY-MM-DD, format in PST timezone18 // YY-MM-DD, format in PST timezone
15 return formatDate(date).slice(2);19 return formatDate(date).slice(2);
16}20}
21
17export function formatDuration(seconds: number) {22export function formatDuration(seconds: number) {
18 const minutes = Math.floor(seconds / 60);23 const minutes = Math.floor(seconds / 60);
19 const remainingSeconds = seconds % 60;24 const remainingSeconds = seconds % 60;
20 return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;25 return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
21}26}
22export const escapeUri = (uri: string) =>27
23 encodeURIComponent(uri)28export function escapeUri(uri: string) {
29 return encodeURIComponent(uri)
24 .replace(/%2F/gi, "/")30 .replace(/%2F/gi, "/")
25 .replace(/%3A/gi, ":")31 .replace(/%3A/gi, ":")
26 .replace(/%2B/gi, "+")32 .replace(/%2B/gi, "+")
...@@ -29,10 +35,7 @@ export const escapeUri = (uri: string) =>...@@ -29,10 +35,7 @@ export const escapeUri = (uri: string) =>
29 .replace(/%5F/gi, "_")35 .replace(/%5F/gi, "_")
30 .replace(/%2E/gi, ".")36 .replace(/%2E/gi, ".")
31 .replace(/%2C/gi, ",");37 .replace(/%2C/gi, ",");
3238}
33import type { MediaFile } from "../db.ts";
34import { escapeHTML } from "../framework/bun-polyfill.ts";
35const findDomain = "paperclover.net";
3639
37// Returns escaped HTML40// Returns escaped HTML
38// Features:41// Features:
...@@ -43,7 +46,7 @@ const findDomain = "paperclover.net";...@@ -43,7 +46,7 @@ const findDomain = "paperclover.net";
43// - via name of a sibling file's basename46// - via name of a sibling file's basename
44// - reformat (c) into ©47// - reformat (c) into ©
45//48//
46// This formatter was written with AI.49// This formatter was written with AI. Then manually fixed since AI does not work.
47export function highlightLinksInTextView(50export function highlightLinksInTextView(
48 text: string,51 text: string,
49 siblingFiles: MediaFile[] = [],52 siblingFiles: MediaFile[] = [],
...@@ -55,7 +58,7 @@ export function highlightLinksInTextView(...@@ -55,7 +58,7 @@ export function highlightLinksInTextView(
55 );58 );
5659
57 // First escape the HTML to prevent XSS60 // First escape the HTML to prevent XSS
58 let processedText = escapeHTML(text);61 let processedText = escapeHtml(text);
5962
60 // Replace (c) with ©63 // Replace (c) with ©
61 processedText = processedText.replace(/\(c\)/gi, "©");64 processedText = processedText.replace(/\(c\)/gi, "©");
...@@ -116,14 +119,11 @@ export function highlightLinksInTextView(...@@ -116,14 +119,11 @@ export function highlightLinksInTextView(
116 // Case 4: ./ relative paths119 // Case 4: ./ relative paths
117 if (match.startsWith("./")) {120 if (match.startsWith("./")) {
118 const filename = match.substring(2);121 const filename = match.substring(2);
119
120 // Check if the filename exists in sibling files
121 const siblingFile = siblingFiles.find((f) => f.basename === filename);122 const siblingFile = siblingFiles.find((f) => f.basename === filename);
122 if (siblingFile) {123 if (siblingFile) {
123 return `<a href="/file/${siblingFile.path}">${match}</a>`;124 return `<a href="/file/${siblingFile.path}">${match}</a>`;
124 }125 }
125126
126 // If no exact match but we have sibling files, try to create a reasonable link
127 if (siblingFiles.length > 0) {127 if (siblingFiles.length > 0) {
128 const currentDir = siblingFiles[0].path128 const currentDir = siblingFiles[0].path
129 .split("/")129 .split("/")
...@@ -138,21 +138,13 @@ export function highlightLinksInTextView(...@@ -138,21 +138,13 @@ export function highlightLinksInTextView(
138138
139 // Match sibling file names (only if they're not already part of a link)139 // Match sibling file names (only if they're not already part of a link)
140 if (siblingFiles.length > 0) {140 if (siblingFiles.length > 0) {
141 // Create a regex pattern that matches any of the sibling file basenames
142 // We need to escape special regex characters in the filenames
143 const escapedBasenames = siblingFiles.map((f) =>141 const escapedBasenames = siblingFiles.map((f) =>
144 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")142 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
145 );143 );
146
147 // Join all basenames with | for the regex alternation
148 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");144 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
149
150 // We need to be careful not to replace text that's already in a link
151 // So we'll split the text by HTML tags and only process the text parts
152 const parts = processedText.split(/(<[^>]*>)/);145 const parts = processedText.split(/(<[^>]*>)/);
153146
154 for (let i = 0; i < parts.length; i += 2) {147 for (let i = 0; i < parts.length; i += 2) {
155 // Only process text parts (even indices), not HTML tags (odd indices)
156 if (i < parts.length) {148 if (i < parts.length) {
157 parts[i] = parts[i].replace(pattern, (match: string) => {149 parts[i] = parts[i].replace(pattern, (match: string) => {
158 const file = siblingLookup[match];150 const file = siblingLookup[match];
...@@ -262,3 +254,6 @@ export function highlightHashComments(text: string) {...@@ -262,3 +254,6 @@ export function highlightHashComments(text: string) {
262 })254 })
263 .join("\n");255 .join("\n");
264}256}
257
258import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
259import { escapeHtml } from "#ssr";
src/file-viewer/models/BlobAsset.ts+57
...@@ -0,0 +1,57 @@...@@ -0,0 +1,57 @@
1const db = getDb("cache.sqlite");
2db.table(
3 "blob_assets",
4 /* SQL */ `
5 CREATE TABLE IF NOT EXISTS blob_assets (
6 hash TEXT PRIMARY KEY,
7 refs INTEGER NOT NULL DEFAULT 0
8 );
9`,
10);
11
12/**
13 * Uncompressed files are read directly from the media store root. Compressed
14 * files are stored as `<compress store>/<first 2 chars of hash>/<hash>` Since
15 * multiple files can share the same hash, the number of references is tracked
16 * so that when a file is deleted, the compressed data is only removed when all
17 * references are gone.
18 */
19export class BlobAsset {
20 /** sha1 of the contents */
21 hash!: string;
22 refs!: number;
23
24 decrementOrDelete() {
25 BlobAsset.decrementOrDelete(this.hash);
26 }
27
28 static get(hash: string) {
29 return getQuery.get(hash);
30 }
31 static putOrIncrement(hash: string) {
32 ASSERT(hash.length === 40);
33 putOrIncrementQuery.get(hash);
34 return BlobAsset.get(hash)!;
35 }
36 static decrementOrDelete(hash: string) {
37 ASSERT(hash.length === 40);
38 decrementQuery.run(hash);
39 return deleteQuery.run(hash).changes > 0;
40 }
41}
42
43const getQuery = db.prepare<[hash: string]>(/* SQL */ `
44 SELECT * FROM blob_assets WHERE hash = ?;
45`).as(BlobAsset);
46const putOrIncrementQuery = db.prepare<[hash: string]>(/* SQL */ `
47 INSERT INTO blob_assets (hash, refs) VALUES (?, 1)
48 ON CONFLICT(hash) DO UPDATE SET refs = refs + 1;
49`);
50const decrementQuery = db.prepare<[hash: string]>(/* SQL */ `
51 UPDATE blob_assets SET refs = refs - 1 WHERE hash = ? AND refs > 0;
52`);
53const deleteQuery = db.prepare<[hash: string]>(/* SQL */ `
54 DELETE FROM blob_assets WHERE hash = ? AND refs <= 0;
55`);
56
57import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/FilePermission.ts deleted
src/file-viewer/models/FilePermissions.ts created+59
...@@ -0,0 +1,59 @@
1const db = getDb("cache.sqlite");
2
3db.table(
4 "permissions",
5 /* SQL */ `
6 CREATE TABLE IF NOT EXISTS permissions (
7 prefix TEXT PRIMARY KEY,
8 allow INTEGER NOT NULL
9 );
10`,
11);
12export class FilePermissions {
13 prefix!: string;
14 /** Currently set to 1 always */
15 allow!: number;
16
17 // -- static ops --
18 static getByPrefix(filePath: string): number {
19 return getByPrefixQuery.get(filePath)?.allow ?? 0;
20 }
21
22 static getExact(filePath: string): number {
23 return getExactQuery.get(filePath)?.allow ?? 0;
24 }
25
26 static setPermissions(prefix: string, allow: number) {
27 if (allow) {
28 insertQuery.run({ prefix, allow });
29 } else {
30 deleteQuery.run(prefix);
31 }
32 }
33}
34
35const getByPrefixQuery = db.prepare<
36 [prefix: string],
37 Pick<FilePermissions, "allow">
38>(/* SQL */ `
39 SELECT allow
40 FROM permissions
41 WHERE ? GLOB prefix || '*'
42 ORDER BY LENGTH(prefix) DESC
43 LIMIT 1;
44`);
45const getExactQuery = db.prepare<
46 [file: string],
47 Pick<FilePermissions, "allow">
48>(/* SQL */ `
49 SELECT allow FROM permissions WHERE ? == prefix
50`);
51
52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
53 REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
54`);
55const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
56 DELETE FROM permissions WHERE prefix = ?;
57`);
58
59import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+336
...@@ -0,0 +1,336 @@...@@ -0,0 +1,336 @@
1const db = getDb("cache.sqlite");
2db.table(
3 "media_files",
4 /* SQL */ `
5 CREATE TABLE IF NOT EXISTS media_files (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 parent_id INTEGER,
8 path TEXT UNIQUE,
9 kind INTEGER NOT NULL,
10 timestamp INTEGER NOT NULL,
11 timestamp_updated INTEGER NOT NULL DEFAULT NOW,
12 hash TEXT NOT NULL,
13 size INTEGER NOT NULL,
14 duration INTEGER NOT NULL DEFAULT 0,
15 dimensions TEXT NOT NULL DEFAULT "",
16 contents TEXT NOT NULL,
17 dirsort TEXT,
18 processed INTEGER NOT NULL,
19 FOREIGN KEY (parent_id) REFERENCES media_files(id)
20 );
21 -- Index for quickly looking up files by path
22 CREATE INDEX IF NOT EXISTS media_files_path ON media_files (path);
23 -- Index for finding directories that need to be processed
24 CREATE INDEX IF NOT EXISTS media_files_directory_processed ON media_files (kind, processed);
25`,
26);
27
28export enum MediaFileKind {
29 directory = 0,
30 file = 1,
31}
32export class MediaFile {
33 id!: number;
34 parent_id!: number;
35 /**
36 * Has leading slash, does not have `/file` prefix.
37 * @example "/2025/waterfalls/waterfalls.mp3"
38 */
39 path!: string;
40 kind!: MediaFileKind;
41 private timestamp!: number;
42 private timestamp_updated!: number;
43 /** for mp3/mp4 files, measured in seconds */
44 duration?: number;
45 /** for images and videos, the dimensions. Two numbers split by `x` */
46 dimensions?: string;
47 /**
48 * sha1 of
49 * - files: the contents
50 * - directories: the JSON array of strings + the content of `readme.txt`
51 * this is used
52 * - to inform changes in caching mechanisms (etag, page render cache)
53 * - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
54 */
55 hash!: string;
56 /**
57 * Depends on the file kind.
58 *
59 * - For directories, this is the contents of `readme.txt`, if it exists.
60 * - Otherwise, it is an empty string.
61 */
62 contents!: string;
63 /**
64 * For directories, if this is set, it is a JSON-encoded array of the explicit
65 * sorting order. Derived off of `.dirsort` files.
66 */
67 dirsort!: string | null;
68 /** in bytes */
69 size!: number;
70 /**
71 * 0 - not processed
72 * 1 - processed
73 *
74 * file: this is for compression
75 * directory: this is for re-indexing contents
76 */
77 processed!: number;
78
79 // -- instance ops --
80 get date() {
81 return new Date(this.timestamp);
82 }
83 get lastUpdateDate() {
84 return new Date(this.timestamp_updated);
85 }
86 parseDimensions() {
87 const dimensions = this.dimensions;
88 if (!dimensions) return null;
89 const [width, height] = dimensions.split("x").map(Number);
90 return { width, height };
91 }
92 get basename() {
93 return path.basename(this.path);
94 }
95 get basenameWithoutExt() {
96 return path.basename(this.path, path.extname(this.path));
97 }
98 get extension() {
99 return path.extname(this.path);
100 }
101 getChildren() {
102 return MediaFile.getChildren(this.id)
103 .filter((file) => !file.basename.startsWith("."));
104 }
105 getPublicChildren() {
106 const children = MediaFile.getChildren(this.id);
107 if (FilePermissions.getByPrefix(this.path) == 0) {
108 return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
109 }
110 return children;
111 }
112 getParent() {
113 const dirPath = this.path;
114 if (dirPath === "/") return null;
115 const parentPath = path.dirname(dirPath);
116 if (parentPath === dirPath) return null;
117 const result = MediaFile.getByPath(parentPath);
118 if (!result) return null;
119 ASSERT(result.kind === MediaFileKind.directory);
120 return result;
121 }
122 setCompressed(compressed: boolean) {
123 MediaFile.markCompressed(this.id, compressed);
124 }
125
126 // -- static ops --
127 static getByPath(filePath: string): MediaFile | null {
128 const result = getByPathQuery.get(filePath);
129 if (result) return result;
130 if (filePath === "/") {
131 return Object.assign(new MediaFile(), {
132 id: 0,
133 parent_id: 0,
134 path: "/",
135 kind: MediaFileKind.directory,
136 timestamp: 0,
137 timestamp_updated: Date.now(),
138 hash: "0".repeat(40),
139 contents: "the file scanner has not been run yet",
140 dirsort: null,
141 size: 0,
142 processed: 1,
143 });
144 }
145 return null;
146 }
147 static createFile({
148 path: filePath,
149 date,
150 hash,
151 size,
152 duration = 0,
153 dimensions = "",
154 content = "",
155 }: CreateFile) {
156 createFileQuery.get({
157 path: filePath,
158 parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
159 timestamp: date.getTime(),
160 timestampUpdated: Date.now(),
161 hash,
162 size,
163 duration,
164 dimensions,
165 contents: content,
166 });
167 }
168 static getOrPutDirectoryId(filePath: string) {
169 filePath = path.posix.normalize(filePath);
170 const row = getDirectoryIdQuery.get(filePath) as { id: number };
171 if (row) return row.id;
172 let current = filePath;
173 let parts = [];
174 let parentId: null | number = 0;
175 if (filePath === "/") {
176 return createDirectoryQuery.run(filePath, 0).lastInsertRowid as number;
177 }
178 // walk down the path until we find a directory that exists
179 do {
180 parts.unshift(path.basename(current));
181 current = path.dirname(current);
182 parentId = (getDirectoryIdQuery.get(current) as { id: number })?.id;
183 } while (parentId == undefined && current !== "/");
184 if (parentId == undefined) {
185 parentId = createDirectoryQuery.run({
186 path: current,
187 parentId: 0,
188 }).lastInsertRowid as number;
189 }
190 // walk back up the path, creating directories as needed
191 for (const part of parts) {
192 current = path.join(current, part);
193 ASSERT(parentId != undefined);
194 parentId = createDirectoryQuery.run({ path: current, parentId })
195 .lastInsertRowid as number;
196 }
197 return parentId;
198 }
199 static markDirectoryProcessed({
200 id,
201 timestamp,
202 contents,
203 size,
204 hash,
205 dirsort,
206 }: MarkDirectoryProcessed) {
207 markDirectoryProcessedQuery.get({
208 id,
209 timestamp: timestamp.getTime(),
210 contents,
211 dirsort: dirsort ? JSON.stringify(dirsort) : "",
212 hash,
213 size,
214 });
215 }
216 static markCompressed(id: number, compressed: boolean) {
217 markCompressedQuery.run({ id, processed: compressed ? 2 : 1 });
218 }
219 static createOrUpdateDirectory(dirPath: string) {
220 const id = MediaFile.getOrPutDirectoryId(dirPath);
221 return updateDirectoryQuery.get(id);
222 }
223 static getChildren(id: number) {
224 return getChildrenQuery.array(id);
225 }
226}
227
228// Create a `file` entry with a given path, date, file hash, size, and duration
229// If the file already exists, update the date and duration.
230// If the file exists and the hash is different, sets `compress` to 0.
231interface CreateFile {
232 path: string;
233 date: Date;
234 hash: string;
235 size: number;
236 duration?: number;
237 dimensions?: string;
238 content?: string;
239}
240
241// Set the `processed` flag true and update the metadata for a directory
242export interface MarkDirectoryProcessed {
243 id: number;
244 timestamp: Date;
245 contents: string;
246 size: number;
247 hash: string;
248 dirsort: null | string[];
249}
250
251export interface DirConfig {
252 /** Overridden sorting */
253 sort: string[];
254}
255
256// -- queries --
257
258// Get a directory ID by path, creating it if it doesn't exist
259const createDirectoryQuery = db.prepare<[{ path: string; parentId: number }]>(
260 /* SQL */ `
261 insert into media_files (
262 path, parent_id, kind, timestamp, hash, size,
263 duration, dimensions, contents, dirsort, processed)
264 values (
265 $path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
266 0, '', '', '', 0);
267`,
268);
269const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
270 SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
271`);
272const createFileQuery = db.prepare<[{
273 path: string;
274 parentId: number;
275 timestamp: number;
276 timestampUpdated: number;
277 hash: string;
278 size: number;
279 duration: number;
280 dimensions: string;
281 contents: string;
282}], void>(/* SQL */ `
283 insert into media_files (
284 path, parent_id, kind, timestamp, timestamp_updated, hash,
285 size, duration, dimensions, contents, processed)
286 values (
287 $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
288 $hash, $size, $duration, $dimensions, $contents, 0)
289 on conflict(path) do update set
290 timestamp = excluded.timestamp,
291 timestamp_updated = excluded.timestamp_updated,
292 duration = excluded.duration,
293 size = excluded.size,
294 contents = excluded.contents,
295 processed = case
296 when media_files.hash != excluded.hash then 0
297 else media_files.processed
298 end;
299`);
300const markCompressedQuery = db.prepare<[{
301 id: number;
302 processed: number;
303}]>(/* SQL */ `
304 update media_files set processed = $processed where id = $id;
305`);
306const getByPathQuery = db.prepare<[string]>(/* SQL */ `
307 select * from media_files where path = ?;
308`).as(MediaFile);
309const markDirectoryProcessedQuery = db.prepare<[{
310 timestamp: number;
311 contents: string;
312 dirsort: string;
313 hash: string;
314 size: number;
315 id: number;
316}]>(/* SQL */ `
317 update media_files set
318 processed = 1,
319 timestamp = $timestamp,
320 contents = $contents,
321 dirsort = $dirsort,
322 hash = $hash,
323 size = $size
324 where id = $id;
325`);
326const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
327 update media_files set processed = 0 where id = ?;
328`);
329
330const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
331 select * from media_files where parent_id = ?;
332`).as(MediaFile);
333
334import { getDb } from "#sitegen/sqlite";
335import * as path from "node:path";
336import { FilePermissions } from "./FilePermissions.ts";
src/file-viewer/pages/file.cotyledon_enterance.tsx+7-7
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { MediaFile } from "../db";1import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
2import { useInlineScript } from "../framework/page-resources";2import { addScript } from "#sitegen";
3import { Readme } from "../media/cotyledon";3import { Readme } from "@/file-viewer/cotyledon.tsx";
4import { MediaPanel } from "../pages-dynamic/file_viewer";4import { MediaPanel } from "../views/clofi.tsx";
5import "../media/files.css";
65
7export const theme = {6export const theme = {
8 bg: "#312652",7 bg: "#312652",
...@@ -10,9 +9,10 @@ export const theme = {...@@ -10,9 +9,10 @@ export const theme = {
10 primary: "#fabe32",9 primary: "#fabe32",
11};10};
1211
12export const meta = { title: "living room" };
13
13export default function CotyledonPage() {14export default function CotyledonPage() {
14 useInlineScript("canvas_cotyledon");15 addScript("../scripts/canvas_cotyledon.client.ts");
15 useInlineScript("file_viewer");
16 return (16 return (
17 <div class="files ctld ctld-et">17 <div class="files ctld ctld-et">
18 <MediaPanel18 <MediaPanel
src/file-viewer/pages/file.cotyledon_speedbump.tsx+7-7
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { MediaFile } from "../db";1import { MediaFile } from "../models/MediaFile.ts";
2import { useInlineScript } from "../framework/page-resources";2import { Speedbump } from "../cotyledon.tsx";
3import { Speedbump } from "../media/cotyledon";3import { MediaPanel } from "../views/clofi.tsx";
4import { MediaPanel } from "../pages-dynamic/file_viewer";4import { addScript } from "#sitegen";
5import "../media/files.css";
65
7export const theme = {6export const theme = {
8 bg: "#312652",7 bg: "#312652",
...@@ -10,9 +9,10 @@ export const theme = {...@@ -10,9 +9,10 @@ export const theme = {
10 primary: "#fabe32",9 primary: "#fabe32",
11};10};
1211
12export const meta = { title: "the front door" };
13
13export default function CotyledonPage() {14export default function CotyledonPage() {
14 useInlineScript("canvas_cotyledon");15 addScript("../scripts/canvas_cotyledon.client.ts");
15 useInlineScript("file_viewer");
16 return (16 return (
17 <div class="files ctld ctld-sb">17 <div class="files ctld ctld-sb">
18 <MediaPanel18 <MediaPanel
src/file-viewer/rules.ts created+143
...@@ -0,0 +1,143 @@
1// -- file extension rules --
2
3/** Extensions that must have EXIF/etc data stripped */
4const extScrubExif = new Set([
5 ".jpg",
6 ".jpeg",
7 ".png",
8 ".mov",
9 ".mp4",
10 ".m4a",
11]);
12/** Extensions that rendered syntax-highlighted code */
13const extsCode = new Map<string, highlight.Language>(Object.entries({
14 ".json": "json",
15 ".toml": "toml",
16 ".ts": "ts",
17 ".js": "ts",
18 ".tsx": "tsx",
19 ".jsx": "tsx",
20 ".css": "css",
21 ".py": "python",
22 ".lua": "lua",
23 ".sh": "shell",
24 ".bat": "dosbatch",
25 ".ps1": "powershell",
26 ".cmd": "dosbatch",
27 ".yaml": "yaml",
28 ".yml": "yaml",
29 ".zig": "zig",
30 ".astro": "astro",
31 ".mdx": "mdx",
32 ".xml": "xml",
33 ".jsonc": "json",
34 ".php": "php",
35 ".patch": "diff",
36 ".diff": "diff",
37}));
38/** These files show an audio embed. */
39const extsAudio = new Set([
40 ".mp3",
41 ".flac",
42 ".wav",
43 ".ogg",
44 ".m4a",
45]);
46/** These files show a video embed. */
47const extsVideo = new Set([
48 ".mp4",
49 ".mkv",
50 ".webm",
51 ".avi",
52 ".mov",
53]);
54/** These files show an image embed */
55const extsImage = new Set([
56 ".jpg",
57 ".jpeg",
58 ".png",
59 ".gif",
60 ".webp",
61 ".avif",
62 ".heic",
63 ".svg",
64]);
65
66/** These files populate `duration` using `ffprobe` */
67export const extsDuration = new Set([...extsAudio, ...extsVideo]);
68/** These files populate `dimensions` using `ffprobe` */
69export const extsDimensions = new Set([...extsImage, ...extsVideo]);
70
71/** These files read file contents into `contents`, as-is */
72export const extsReadContents = new Set([".txt", ".chat"]);
73
74export const extsArchive = new Set([
75 ".zip",
76 ".rar",
77 ".7z",
78 ".tar",
79 ".gz",
80 ".bz2",
81 ".xz",
82]);
83
84/**
85 * Formats which are already compression formats, meaning a pass
86 * through zstd would offer little to negative benefits
87 */
88export const extsHaveCompression = new Set([
89 ...extsAudio,
90 ...extsVideo,
91 ...extsImage,
92 ...extsArchive,
93 // TODO: are any of these NOT good for compression
94]);
95
96export function fileIcon(
97 file: Pick<MediaFile, "kind" | "basename" | "path">,
98 dirOpen?: boolean,
99) {
100 const { kind, basename } = file;
101 if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
102
103 // -- special cases --
104 if (file.path === "/2024/for everyone") return "snow";
105
106 // -- basename cases --
107 if (basename === "readme.txt") return "readme";
108
109 // -- extension cases --
110 const ext = path.extname(file.basename).toLowerCase();
111 if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
112 if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
113 if (ext === ".blend") return "blend";
114 if (ext === ".chat") return "chat";
115 if (ext === ".html") return "webpage";
116 if (ext === ".lnk") return "link";
117 if (ext === ".txt" || ext === ".md") return "text";
118
119 // -- extension categories --
120 if (extsVideo.has(ext)) return "video";
121 if (extsAudio.has(ext)) return "audio";
122 if (extsImage.has(ext)) return "image";
123 if (extsArchive.has(ext)) return "archive";
124 if (extsCode.has(ext)) return "code";
125
126 return "file";
127}
128
129// -- viewer rules --
130const pathToCanvas = new Map<string, string>(Object.entries({
131 "/2017": "2017",
132 "/2018": "2018",
133 "/2019": "2019",
134 "/2020": "2020",
135 "/2021": "2021",
136 "/2022": "2022",
137 "/2023": "2023",
138 "/2024": "2024",
139}));
140
141import type * as highlight from "./highlight.ts";
142import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
143import * as path from "node:path";
src/file-viewer/scripts/canvas_2017.client.ts created+233
...@@ -0,0 +1,233 @@
1// Vibe coded with AI
2(globalThis as any).canvas_2017 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Configuration interface for the checkerboard effect
5 interface CheckerboardConfig {
6 fps: number; // frames per second
7 color1: string; // first checkerboard color
8 color2: string; // second checkerboard color
9 opacity: number; // opacity of each checkerboard (0-1)
10 speedX1: number; // horizontal speed of first checkerboard (pixels per second)
11 speedY1: number; // vertical speed of first checkerboard (pixels per second)
12 speedX2: number; // horizontal speed of second checkerboard (pixels per second)
13 speedY2: number; // vertical speed of second checkerboard (pixels per second)
14 baseTileSize: number; // base size of checkerboard tiles
15 sizeVariation: number; // maximum variation in tile size (pixels)
16 sineFrequency1: number; // frequency of first sine wave for size variation
17 sineFrequency2: number; // frequency of second sine wave for size variation
18 sineOffset: number; // offset between the two sine waves (radians)
19 rotation: number; // rotation in degrees for the entire pattern
20 rotation2: number; // rotation in degrees for the entire pattern
21 }
22
23 // Default configuration
24 const config: CheckerboardConfig = {
25 fps: 30,
26 color1: "#1A1C17",
27 color2: "#1A1C17",
28 opacity: 0.3,
29 speedX1: -0.02, // moving left slowly
30 speedY1: -0.01, // moving up slowly
31 speedX2: -0.015, // moving left (slightly slower)
32 speedY2: 0.012, // moving down slowly
33 baseTileSize: 200,
34 sizeVariation: 1.5,
35 sineFrequency1: 0.0005,
36 sineFrequency2: 0.0008,
37 sineOffset: Math.PI / 2, // 90 degrees offset
38 rotation: 2, // 5 degree rotation
39 rotation2: -2, // 5 degree rotation
40 };
41
42 // Get the canvas context
43 const ctx = canvas.getContext("2d");
44 if (!ctx) {
45 console.error("Could not get canvas context");
46 return () => {};
47 }
48
49 // Make canvas transparent
50 if (isStandalone) {
51 canvas.style.backgroundColor = "#737D60";
52 } else {
53 canvas.style.backgroundColor = "transparent";
54 }
55
56 // Variables to track position and animation
57 let width = canvas.width;
58 let height = canvas.height;
59 let animationFrameId: number;
60 let lastFrameTime = 0;
61 const frameInterval = 1000 / config.fps;
62
63 // Position offsets for the two checkerboards (centered)
64 let offset1X = 0;
65 let offset1Y = 0;
66 let offset2X = 0;
67 let offset2Y = 0;
68
69 // Time variable for sine wave calculation
70 let time = 0;
71
72 // Convert rotation to radians
73 const rotationRad = (config.rotation * Math.PI) / 180;
74 const rotationRad2 = (config.rotation2 * Math.PI) / 180;
75
76 // Update canvas dimensions when resized
77 const updateDimensions = () => {
78 width = canvas.width = canvas.clientWidth;
79 height = canvas.height = canvas.clientHeight;
80 };
81
82 // Calculate the diagonal length of the canvas (to ensure rotation covers corners)
83 const calculateDiagonal = () => {
84 return Math.sqrt(width * width + height * height);
85 };
86
87 // Draw a single checkerboard pattern scaled from center with rotation
88 const drawCheckerboard = (
89 offsetX: number,
90 offsetY: number,
91 tileSize: number,
92 color1: string,
93 color2: string,
94 opacity: number,
95 rotationRad: number,
96 ) => {
97 ctx.globalAlpha = opacity;
98
99 // Get the center of the viewport
100 const centerX = width / 2;
101 const centerY = height / 2;
102
103 // Save the current transformation state
104 ctx.save();
105
106 // Move to the center of the canvas, rotate, then move back
107 ctx.translate(centerX, centerY);
108 ctx.rotate(rotationRad);
109
110 // Calculate the number of tiles needed to cover the rotated canvas
111 // We need to use the diagonal length to ensure we cover the corners when rotated
112 const diagonal = calculateDiagonal();
113 const tilesX = Math.ceil(diagonal / tileSize) + 6; // Added extra tiles for rotation
114 const tilesY = Math.ceil(diagonal / tileSize) + 6;
115
116 // Calculate how many tiles fit from center to edge (in each direction)
117 const halfTilesX = Math.ceil(tilesX / 2);
118 const halfTilesY = Math.ceil(tilesY / 2);
119
120 // Adjust the offset to be relative to the center
121 // The modulo ensures the pattern repeats smoothly even with scaling
122 const adjustedOffsetX = offsetX % (tileSize * 2);
123 const adjustedOffsetY = offsetY % (tileSize * 2);
124
125 // Draw the checker pattern, centered on the viewport
126 for (let y = -halfTilesY; y <= halfTilesY; y++) {
127 for (let x = -halfTilesX; x <= halfTilesX; x++) {
128 // Determine if this tile should be colored (creating checker pattern)
129 // We add a large number to ensure (x+y) is always positive for the modulo
130 if ((x + y + 1000) % 2 === 0) {
131 ctx.fillStyle = color1;
132 } else {
133 ctx.fillStyle = color2;
134 }
135
136 // Calculate the position of this tile relative to the center
137 // The adjusted offset creates the movement effect
138 const posX = (x * tileSize) + adjustedOffsetX;
139 const posY = (y * tileSize) + adjustedOffsetY;
140
141 // Draw the tile
142 ctx.fillRect(
143 posX - tileSize / 2,
144 posY - tileSize / 2,
145 tileSize,
146 tileSize,
147 );
148 }
149 }
150
151 // Restore the transformation state
152 ctx.restore();
153 };
154
155 // Animation loop
156 const animate = (currentTime: number) => {
157 animationFrameId = requestAnimationFrame(animate);
158
159 // Control frame rate
160 if (currentTime - lastFrameTime < frameInterval) {
161 return;
162 }
163
164 // Calculate the time elapsed since the last frame
165 const dt = currentTime - lastFrameTime;
166 lastFrameTime = currentTime;
167
168 // Increment time for sine wave calculation
169 time += dt;
170
171 // Update the position offsets based on speed and elapsed time
172 offset1X += config.speedX1 * dt;
173 offset1Y += config.speedY1 * dt;
174 offset2X += config.speedX2 * dt;
175 offset2Y += config.speedY2 * dt;
176
177 // Calculate the tile sizes using sine waves
178 const tileSize1 = config.baseTileSize +
179 Math.sin(time * config.sineFrequency1) * config.sizeVariation;
180 const tileSize2 = config.baseTileSize +
181 Math.sin(time * config.sineFrequency2 + config.sineOffset) *
182 config.sizeVariation;
183
184 // Clear canvas
185 ctx.clearRect(0, 0, width, height);
186
187 // Draw the two checkerboards
188 drawCheckerboard(
189 offset1X,
190 offset1Y,
191 tileSize1,
192 config.color1,
193 "transparent",
194 config.opacity,
195 rotationRad,
196 );
197
198 drawCheckerboard(
199 offset2X,
200 offset2Y,
201 tileSize2,
202 config.color2,
203 "transparent",
204 config.opacity,
205 rotationRad2,
206 );
207
208 // Reset global alpha
209 ctx.globalAlpha = 1.0;
210 };
211
212 // Initialize the animation
213 const init = () => {
214 // Set up resize handler
215 window.addEventListener("resize", updateDimensions);
216
217 // Initial setup
218 updateDimensions();
219
220 // Start animation
221 lastFrameTime = performance.now();
222 animationFrameId = requestAnimationFrame(animate);
223 };
224
225 // Start the animation
226 init();
227
228 // Return cleanup function
229 return () => {
230 window.removeEventListener("resize", updateDimensions);
231 cancelAnimationFrame(animationFrameId);
232 };
233};
src/file-viewer/scripts/canvas_2017.ts deleted-233
...@@ -1,233 +0,0 @@
1// Vibe coded with AI
2(globalThis as any).canvas_2017 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Configuration interface for the checkerboard effect
5 interface CheckerboardConfig {
6 fps: number; // frames per second
7 color1: string; // first checkerboard color
8 color2: string; // second checkerboard color
9 opacity: number; // opacity of each checkerboard (0-1)
10 speedX1: number; // horizontal speed of first checkerboard (pixels per second)
11 speedY1: number; // vertical speed of first checkerboard (pixels per second)
12 speedX2: number; // horizontal speed of second checkerboard (pixels per second)
13 speedY2: number; // vertical speed of second checkerboard (pixels per second)
14 baseTileSize: number; // base size of checkerboard tiles
15 sizeVariation: number; // maximum variation in tile size (pixels)
16 sineFrequency1: number; // frequency of first sine wave for size variation
17 sineFrequency2: number; // frequency of second sine wave for size variation
18 sineOffset: number; // offset between the two sine waves (radians)
19 rotation: number; // rotation in degrees for the entire pattern
20 rotation2: number; // rotation in degrees for the entire pattern
21 }
22
23 // Default configuration
24 const config: CheckerboardConfig = {
25 fps: 30,
26 color1: "#1A1C17",
27 color2: "#1A1C17",
28 opacity: 0.3,
29 speedX1: -0.02, // moving left slowly
30 speedY1: -0.01, // moving up slowly
31 speedX2: -0.015, // moving left (slightly slower)
32 speedY2: 0.012, // moving down slowly
33 baseTileSize: 200,
34 sizeVariation: 1.5,
35 sineFrequency1: 0.0005,
36 sineFrequency2: 0.0008,
37 sineOffset: Math.PI / 2, // 90 degrees offset
38 rotation: 2, // 5 degree rotation
39 rotation2: -2, // 5 degree rotation
40 };
41
42 // Get the canvas context
43 const ctx = canvas.getContext("2d");
44 if (!ctx) {
45 console.error("Could not get canvas context");
46 return () => {};
47 }
48
49 // Make canvas transparent
50 if (isStandalone) {
51 canvas.style.backgroundColor = "#737D60";
52 } else {
53 canvas.style.backgroundColor = "transparent";
54 }
55
56 // Variables to track position and animation
57 let width = canvas.width;
58 let height = canvas.height;
59 let animationFrameId: number;
60 let lastFrameTime = 0;
61 const frameInterval = 1000 / config.fps;
62
63 // Position offsets for the two checkerboards (centered)
64 let offset1X = 0;
65 let offset1Y = 0;
66 let offset2X = 0;
67 let offset2Y = 0;
68
69 // Time variable for sine wave calculation
70 let time = 0;
71
72 // Convert rotation to radians
73 const rotationRad = (config.rotation * Math.PI) / 180;
74 const rotationRad2 = (config.rotation2 * Math.PI) / 180;
75
76 // Update canvas dimensions when resized
77 const updateDimensions = () => {
78 width = canvas.width = canvas.clientWidth;
79 height = canvas.height = canvas.clientHeight;
80 };
81
82 // Calculate the diagonal length of the canvas (to ensure rotation covers corners)
83 const calculateDiagonal = () => {
84 return Math.sqrt(width * width + height * height);
85 };
86
87 // Draw a single checkerboard pattern scaled from center with rotation
88 const drawCheckerboard = (
89 offsetX: number,
90 offsetY: number,
91 tileSize: number,
92 color1: string,
93 color2: string,
94 opacity: number,
95 rotationRad: number,
96 ) => {
97 ctx.globalAlpha = opacity;
98
99 // Get the center of the viewport
100 const centerX = width / 2;
101 const centerY = height / 2;
102
103 // Save the current transformation state
104 ctx.save();
105
106 // Move to the center of the canvas, rotate, then move back
107 ctx.translate(centerX, centerY);
108 ctx.rotate(rotationRad);
109
110 // Calculate the number of tiles needed to cover the rotated canvas
111 // We need to use the diagonal length to ensure we cover the corners when rotated
112 const diagonal = calculateDiagonal();
113 const tilesX = Math.ceil(diagonal / tileSize) + 6; // Added extra tiles for rotation
114 const tilesY = Math.ceil(diagonal / tileSize) + 6;
115
116 // Calculate how many tiles fit from center to edge (in each direction)
117 const halfTilesX = Math.ceil(tilesX / 2);
118 const halfTilesY = Math.ceil(tilesY / 2);
119
120 // Adjust the offset to be relative to the center
121 // The modulo ensures the pattern repeats smoothly even with scaling
122 const adjustedOffsetX = offsetX % (tileSize * 2);
123 const adjustedOffsetY = offsetY % (tileSize * 2);
124
125 // Draw the checker pattern, centered on the viewport
126 for (let y = -halfTilesY; y <= halfTilesY; y++) {
127 for (let x = -halfTilesX; x <= halfTilesX; x++) {
128 // Determine if this tile should be colored (creating checker pattern)
129 // We add a large number to ensure (x+y) is always positive for the modulo
130 if ((x + y + 1000) % 2 === 0) {
131 ctx.fillStyle = color1;
132 } else {
133 ctx.fillStyle = color2;
134 }
135
136 // Calculate the position of this tile relative to the center
137 // The adjusted offset creates the movement effect
138 const posX = (x * tileSize) + adjustedOffsetX;
139 const posY = (y * tileSize) + adjustedOffsetY;
140
141 // Draw the tile
142 ctx.fillRect(
143 posX - tileSize / 2,
144 posY - tileSize / 2,
145 tileSize,
146 tileSize,
147 );
148 }
149 }
150
151 // Restore the transformation state
152 ctx.restore();
153 };
154
155 // Animation loop
156 const animate = (currentTime: number) => {
157 animationFrameId = requestAnimationFrame(animate);
158
159 // Control frame rate
160 if (currentTime - lastFrameTime < frameInterval) {
161 return;
162 }
163
164 // Calculate the time elapsed since the last frame
165 const dt = currentTime - lastFrameTime;
166 lastFrameTime = currentTime;
167
168 // Increment time for sine wave calculation
169 time += dt;
170
171 // Update the position offsets based on speed and elapsed time
172 offset1X += config.speedX1 * dt;
173 offset1Y += config.speedY1 * dt;
174 offset2X += config.speedX2 * dt;
175 offset2Y += config.speedY2 * dt;
176
177 // Calculate the tile sizes using sine waves
178 const tileSize1 = config.baseTileSize +
179 Math.sin(time * config.sineFrequency1) * config.sizeVariation;
180 const tileSize2 = config.baseTileSize +
181 Math.sin(time * config.sineFrequency2 + config.sineOffset) *
182 config.sizeVariation;
183
184 // Clear canvas
185 ctx.clearRect(0, 0, width, height);
186
187 // Draw the two checkerboards
188 drawCheckerboard(
189 offset1X,
190 offset1Y,
191 tileSize1,
192 config.color1,
193 "transparent",
194 config.opacity,
195 rotationRad,
196 );
197
198 drawCheckerboard(
199 offset2X,
200 offset2Y,
201 tileSize2,
202 config.color2,
203 "transparent",
204 config.opacity,
205 rotationRad2,
206 );
207
208 // Reset global alpha
209 ctx.globalAlpha = 1.0;
210 };
211
212 // Initialize the animation
213 const init = () => {
214 // Set up resize handler
215 window.addEventListener("resize", updateDimensions);
216
217 // Initial setup
218 updateDimensions();
219
220 // Start animation
221 lastFrameTime = performance.now();
222 animationFrameId = requestAnimationFrame(animate);
223 };
224
225 // Start the animation
226 init();
227
228 // Return cleanup function
229 return () => {
230 window.removeEventListener("resize", updateDimensions);
231 cancelAnimationFrame(animationFrameId);
232 };
233};
src/file-viewer/scripts/canvas_2018.client.ts created+431
...@@ -0,0 +1,431 @@
1// This canvas is based on the maze generation algo in Tanks. This was
2// originally written in C++ as a single function in 2018, and was ported to TS
3// by Chloe in 2025 for the cotyledon canvas.
4//
5// The main difference is that this version is a visualization, rather than the
6// practical function. Instead of taking a millisecond, only 5 steps are
7// performed per second, visualizing the whole ordeal. It also isn't a playable
8// game, obviously.
9//
10// Ported with love because I care about my old self
11// She deserves the world, but instead gave it to me.
12(globalThis as any).canvas_2018 = function (canvas: HTMLCanvasElement) {
13 const isStandalone = canvas.getAttribute("data-standalone") === "true";
14 if (isStandalone) {
15 canvas.style.backgroundColor = "#27201E";
16 }
17 interface Cell {
18 down: boolean;
19 right: boolean;
20 visited: boolean;
21
22 cell_flash: number;
23 down_flash: number;
24 right_flash: number;
25 }
26 interface Pos {
27 x: number;
28 y: number;
29 /** Where the wall is relative to x, y. */
30 dir: "left" | "right" | "up" | "down";
31 }
32 interface Maze {
33 grid: Grid;
34 cursor: { x: number; y: number };
35 lastTick: number;
36 /* Pixels */
37 transform: number;
38 newCellsToVisit: Pos[];
39 randomWallBag: Cell[];
40 randomWallTarget: number;
41 renderOffset: { x: number; y: number };
42 done: boolean;
43 }
44 const hex = (color: number[]) =>
45 "#" + color.map((c) => c.toString(16).padStart(2, "0")).join("");
46 let cellSize: number;
47 let borderThickness: number;
48 const cellFlashModifier = isStandalone ? 0.4 : 0.2;
49 const color = isStandalone ? "#170d0b" : "#231C1A";
50 const bg = [0x27, 0x20, 0x1E];
51 const wallFlashColor = [0xFF, 0xA8, 0x7A];
52 const cellFlashColor = "#FFA87A";
53 const updateTime = 1000 / 7;
54 const randomWallBreakInterval = [6, 12]; // every 10 to 18 walls.
55 function randomBetween(min: number, max: number) {
56 return Math.round(
57 Math.random() * (max - min),
58 ) + min;
59 }
60 function randomOf<T>(array: T[]): T {
61 return array[randomBetween(0, array.length - 1)];
62 }
63 function randomWallTarget() {
64 return randomBetween(
65 randomWallBreakInterval[0],
66 randomWallBreakInterval[1],
67 );
68 }
69
70 // Originally, this used a 2-dimensional array. However, I wanted to make sure
71 // that the grid could be infinitely sized. This grid constructs new cells on
72 // demand, as needed.
73 class Grid {
74 cells = new Map<number, Cell>();
75 cell({ x, y }: { x: number; y: number }) {
76 const k = ((x | 0) << 16) + (y | 0);
77 const { cells } = this;
78 let existing = this.cells.get(k);
79 if (!existing) {
80 existing = {
81 cell_flash: 0,
82 down: true,
83 down_flash: 0,
84 right: true,
85 right_flash: 0,
86 visited: false,
87 };
88 cells.set(k, existing);
89 }
90 return existing;
91 }
92 forAll(
93 renderOffset: { x: number; y: number },
94 width: number,
95 height: number,
96 cb: (cell: Cell, pos: { x: number; y: number }) => void,
97 ) {
98 const { x: offsetX, y: offsetY } = renderOffset;
99 const startX = Math.floor(-offsetX / cellSize);
100 const startY = Math.floor(-offsetY / cellSize);
101 const endX = Math.ceil((width - offsetX) / cellSize);
102 const endY = Math.ceil((height - offsetY) / cellSize);
103 for (let x = startX; x <= endX; x++) {
104 for (let y = startY; y <= endY; y++) {
105 const cellX = offsetX + x * cellSize;
106 const cellY = offsetY + y * cellSize;
107 cb(this.cell({ x, y }), { x: cellX, y: cellY });
108 }
109 }
110 }
111 }
112
113 const ctx = canvas.getContext("2d")!;
114 if (!ctx) {
115 console.error("Could not get canvas context");
116 return () => {};
117 }
118
119 let width: number, height: number;
120 const updateDimensions = () => {
121 width = canvas.width = canvas.offsetWidth;
122 height = canvas.height = canvas.offsetHeight;
123 cellSize = 100;
124 borderThickness = 8;
125 };
126 updateDimensions();
127
128 setTimeout(() => {
129 updateDimensions();
130 }, 10);
131
132 let maze = initMaze();
133 let nextMaze: Maze | null = null;
134 let completeFade = 0;
135 function initMaze(): Maze {
136 return {
137 grid: new Grid(),
138 transform: 0,
139 cursor: {
140 x: randomBetween(0, Math.ceil(width / cellSize)),
141 y: randomBetween(0, Math.ceil(height / cellSize)),
142 },
143 lastTick: performance.now(),
144 randomWallBag: [],
145 randomWallTarget: randomWallTarget(),
146 newCellsToVisit: [],
147 renderOffset: { x: 0, y: 0 },
148 done: false,
149 };
150 }
151
152 function isOnScreen(maze: Maze, x: number, y: number) {
153 const { x: offsetX, y: offsetY } = maze.renderOffset;
154 const cellX = offsetX + x * cellSize;
155 const cellY = offsetY + y * cellSize;
156 return (
157 cellX + cellSize > 0 &&
158 cellX < width &&
159 cellY + cellSize > 0 &&
160 cellY < height
161 );
162 }
163
164 function tick(maze: Maze, other?: Maze) {
165 if (maze.done) return;
166
167 // The original maze algorithm broke down 4%-8% of random right facing
168 // walls, and 4%-8% of down facing walls. It did this at the end.
169 // To make this visual more interesting, two random walls will be broken
170 // down every 12-25 cell visits. This way, the main trail is always running.
171 if (maze.randomWallBag.length > maze.randomWallTarget) {
172 const down: Cell = randomOf(maze.randomWallBag);
173 const right: Cell = randomOf(maze.randomWallBag);
174 maze.randomWallBag.forEach((cell) =>
175 cell.cell_flash = Math.min(cell.cell_flash + 0.2, 1)
176 );
177 down.cell_flash = 1;
178 down.down = false;
179 down.down_flash = 1;
180 right.cell_flash = 1;
181 right.right = false;
182 right.right_flash = 1;
183 maze.randomWallBag = [];
184 maze.randomWallTarget = randomWallTarget();
185 return;
186 }
187
188 // The main algorithm was simple: Have a cursor position, and move it in a
189 // random direction that it had not seen before. Once it had run out of
190 // options, branch off of a previous location. Only visit each cell once.
191 //
192 // In this visualization, cells that are too far offscreen are softly
193 // treated as "visited", which is how the simulation always stays in frame.
194 const current = maze.grid.cell(maze.cursor);
195 current.visited = true;
196 current.cell_flash = 1;
197 maze.randomWallBag.push(current);
198 const adjacent = ([
199 { x: maze.cursor.x + 1, y: maze.cursor.y, dir: "left" },
200 { x: maze.cursor.x - 1, y: maze.cursor.y, dir: "right" },
201 { x: maze.cursor.x, y: maze.cursor.y + 1, dir: "up" },
202 { x: maze.cursor.x, y: maze.cursor.y - 1, dir: "down" },
203 ] as Pos[]).filter((pos) =>
204 isOnScreen(maze, pos.x, pos.y) &&
205 maze.grid.cell(pos).visited === false
206 );
207 if (adjacent.length === 0) {
208 // move cursor to a random cell that has not been visited.
209 const cells = maze.newCellsToVisit.filter((pos) =>
210 isOnScreen(maze, pos.x, pos.y) &&
211 maze.grid.cell(pos).visited === false
212 );
213 if (cells.length === 0) {
214 maze.done = true;
215 return;
216 }
217 const continuePos = randomOf(cells);
218 breakWall(maze, continuePos, other);
219 maze.cursor = { x: continuePos.x, y: continuePos.y };
220 return;
221 }
222
223 // break a random wall
224 const toBreak = randomOf(adjacent);
225 breakWall(maze, toBreak, other);
226 maze.cursor = { x: toBreak.x, y: toBreak.y };
227
228 // add the other directions to the new cells to visit.
229 maze.newCellsToVisit.push(
230 ...adjacent.filter((pos) => pos.dir !== toBreak.dir),
231 );
232 }
233
234 function breakWall(maze: Maze, pos: Pos, other?: Maze) {
235 if (pos.dir === "right") {
236 const cell = maze.grid.cell(pos);
237 cell.right = false;
238 cell.right_flash = 1;
239 if (other) cell.right = false;
240 } else if (pos.dir === "down") {
241 const cell = maze.grid.cell(pos);
242 cell.down = false;
243 cell.down_flash = 1;
244 if (other) cell.down = false;
245 } else if (pos.dir === "left") {
246 const cell = maze.grid.cell({ x: pos.x - 1, y: pos.y });
247 cell.right = false;
248 cell.right_flash = 1;
249 if (other) cell.right = false;
250 } else if (pos.dir === "up") {
251 const cell = maze.grid.cell({ x: pos.x, y: pos.y - 1 });
252 cell.down = false;
253 cell.down_flash = 1;
254 if (other) cell.down = false;
255 }
256 }
257
258 function renderOffset(maze: Maze) {
259 return { x: maze.transform, y: maze.transform };
260 }
261
262 let animationFrameId: number;
263 let last = performance.now();
264 let dt: number = 0;
265
266 function renderMazeBorders(maze: Maze, opacity: number) {
267 ctx.globalAlpha = opacity;
268 maze.grid.forAll(
269 maze.renderOffset,
270 width,
271 height,
272 (cell, { x: cellX, y: cellY }) => {
273 // Walls
274 if (cell.right) {
275 ctx.fillStyle = color;
276 ctx.fillRect(
277 cellX + cellSize - borderThickness / 2,
278 cellY - borderThickness / 2,
279 borderThickness,
280 cellSize + borderThickness,
281 );
282 }
283 if (cell.down) {
284 ctx.fillStyle = color;
285 ctx.fillRect(
286 cellX - borderThickness / 2,
287 cellY + cellSize - borderThickness / 2,
288 cellSize + borderThickness,
289 borderThickness,
290 );
291 }
292 },
293 );
294 ctx.globalAlpha = 1;
295 }
296
297 function renderCellFlash(maze: Maze) {
298 maze.grid.forAll(
299 maze.renderOffset,
300 width,
301 height,
302 (cell, { x: cellX, y: cellY }) => {
303 // Cell flash to show visiting path.
304 if (cell.cell_flash > 0) {
305 cell.cell_flash = Math.max(0, cell.cell_flash - dt / 1000);
306 ctx.fillStyle = cellFlashColor;
307 ctx.globalAlpha = cell.cell_flash * cellFlashModifier;
308 ctx.fillRect(cellX, cellY, cellSize, cellSize);
309 ctx.globalAlpha = 1;
310 }
311 },
312 );
313 }
314
315 function renderBorderFlash(maze: Maze) {
316 maze.grid.forAll(
317 maze.renderOffset,
318 width,
319 height,
320 (cell, { x: cellX, y: cellY }) => {
321 if (cell.right_flash == 0 && cell.down_flash == 0) {
322 return;
323 }
324
325 // Walls
326 const cellFlash = cell.cell_flash * cellFlashModifier;
327 if (cell.right_flash > 0) {
328 cell.right_flash = Math.max(0, cell.right_flash - dt / 500);
329 ctx.fillStyle = interpolateColor(
330 bg,
331 wallFlashColor,
332 Math.max(cell.right_flash, cellFlash),
333 );
334 if (cellFlash > cell.right_flash) {
335 ctx.globalAlpha = cell.right_flash / cellFlash;
336 }
337 ctx.fillRect(
338 cellX + cellSize - borderThickness / 2,
339 cellY + borderThickness / 2,
340 borderThickness,
341 cellSize - borderThickness,
342 );
343 ctx.globalAlpha = 1;
344 }
345 if (cell.down_flash > 0) {
346 if (cellFlash > cell.down_flash) {
347 ctx.globalAlpha = cell.down_flash / cellFlash;
348 }
349 cell.down_flash = Math.max(0, cell.down_flash - dt / 500);
350 ctx.fillStyle = interpolateColor(
351 bg,
352 wallFlashColor,
353 Math.max(cell.down_flash, cellFlash),
354 );
355 ctx.fillRect(
356 cellX + borderThickness / 2,
357 cellY + cellSize - borderThickness / 2,
358 cellSize - borderThickness,
359 borderThickness,
360 );
361 ctx.globalAlpha = 1;
362 }
363 },
364 );
365 }
366
367 function render() {
368 const now = performance.now();
369 dt = now - last;
370 maze.transform += dt * 0.005;
371 maze.renderOffset = renderOffset(maze);
372 if (!maze.done) {
373 if (now - maze.lastTick >= updateTime) {
374 tick(maze);
375 maze.lastTick = now;
376
377 if (maze.done) {
378 nextMaze = initMaze();
379 nextMaze.transform = (maze.transform % cellSize) - dt * 0.005;
380 nextMaze.lastTick = now;
381 completeFade = 0;
382 }
383 }
384 }
385 if (nextMaze) {
386 nextMaze.transform += dt * 0.005;
387 nextMaze.renderOffset = renderOffset(nextMaze);
388 if (!nextMaze.done && now - nextMaze.lastTick >= updateTime) {
389 tick(nextMaze, maze);
390 nextMaze.lastTick = now;
391 }
392 }
393 last = now;
394
395 ctx.clearRect(0, 0, width, height);
396
397 renderCellFlash(maze);
398 if (nextMaze) renderCellFlash(nextMaze);
399
400 renderMazeBorders(maze, 1);
401 if (nextMaze) {
402 renderMazeBorders(nextMaze, completeFade);
403 completeFade += dt / 3000;
404 if (completeFade >= 1) {
405 maze = nextMaze;
406 nextMaze = null;
407 }
408 }
409
410 renderBorderFlash(maze);
411 if (nextMaze) {
412 renderCellFlash(nextMaze);
413 renderBorderFlash(nextMaze);
414 }
415
416 animationFrameId = requestAnimationFrame(render);
417 }
418
419 function interpolateColor(start: number[], end: number[], t: number) {
420 return hex(start.map((s, i) => Math.round(s + (end[i] - s) * t)));
421 }
422
423 window.addEventListener("resize", updateDimensions);
424 animationFrameId = requestAnimationFrame(render);
425
426 // cleanup function
427 return () => {
428 window.removeEventListener("resize", updateDimensions);
429 cancelAnimationFrame(animationFrameId);
430 };
431};
src/file-viewer/scripts/canvas_2018.ts deleted-431
...@@ -1,431 +0,0 @@
1// This canvas is based on the maze generation algo in Tanks. This was
2// originally written in C++ as a single function in 2018, and was ported to TS
3// by Chloe in 2025 for the cotyledon canvas.
4//
5// The main difference is that this version is a visualization, rather than the
6// practical function. Instead of taking a millisecond, only 5 steps are
7// performed per second, visualizing the whole ordeal. It also isn't a playable
8// game, obviously.
9//
10// Ported with love because I care about my old self
11// She deserves the world, but instead gave it to me.
12(globalThis as any).canvas_2018 = function (canvas: HTMLCanvasElement) {
13 const isStandalone = canvas.getAttribute("data-standalone") === "true";
14 if (isStandalone) {
15 canvas.style.backgroundColor = "#27201E";
16 }
17 interface Cell {
18 down: boolean;
19 right: boolean;
20 visited: boolean;
21
22 cell_flash: number;
23 down_flash: number;
24 right_flash: number;
25 }
26 interface Pos {
27 x: number;
28 y: number;
29 /** Where the wall is relative to x, y. */
30 dir: "left" | "right" | "up" | "down";
31 }
32 interface Maze {
33 grid: Grid;
34 cursor: { x: number; y: number };
35 lastTick: number;
36 /* Pixels */
37 transform: number;
38 newCellsToVisit: Pos[];
39 randomWallBag: Cell[];
40 randomWallTarget: number;
41 renderOffset: { x: number; y: number };
42 done: boolean;
43 }
44 const hex = (color: number[]) =>
45 "#" + color.map((c) => c.toString(16).padStart(2, "0")).join("");
46 let cellSize: number;
47 let borderThickness: number;
48 const cellFlashModifier = isStandalone ? 0.4 : 0.2;
49 const color = isStandalone ? "#170d0b" : "#231C1A";
50 const bg = [0x27, 0x20, 0x1E];
51 const wallFlashColor = [0xFF, 0xA8, 0x7A];
52 const cellFlashColor = "#FFA87A";
53 const updateTime = 1000 / 7;
54 const randomWallBreakInterval = [6, 12]; // every 10 to 18 walls.
55 function randomBetween(min: number, max: number) {
56 return Math.round(
57 Math.random() * (max - min),
58 ) + min;
59 }
60 function randomOf<T>(array: T[]): T {
61 return array[randomBetween(0, array.length - 1)];
62 }
63 function randomWallTarget() {
64 return randomBetween(
65 randomWallBreakInterval[0],
66 randomWallBreakInterval[1],
67 );
68 }
69
70 // Originally, this used a 2-dimensional array. However, I wanted to make sure
71 // that the grid could be infinitely sized. This grid constructs new cells on
72 // demand, as needed.
73 class Grid {
74 cells = new Map<number, Cell>();
75 cell({ x, y }: { x: number; y: number }) {
76 const k = ((x | 0) << 16) + (y | 0);
77 const { cells } = this;
78 let existing = this.cells.get(k);
79 if (!existing) {
80 existing = {
81 cell_flash: 0,
82 down: true,
83 down_flash: 0,
84 right: true,
85 right_flash: 0,
86 visited: false,
87 };
88 cells.set(k, existing);
89 }
90 return existing;
91 }
92 forAll(
93 renderOffset: { x: number; y: number },
94 width: number,
95 height: number,
96 cb: (cell: Cell, pos: { x: number; y: number }) => void,
97 ) {
98 const { x: offsetX, y: offsetY } = renderOffset;
99 const startX = Math.floor(-offsetX / cellSize);
100 const startY = Math.floor(-offsetY / cellSize);
101 const endX = Math.ceil((width - offsetX) / cellSize);
102 const endY = Math.ceil((height - offsetY) / cellSize);
103 for (let x = startX; x <= endX; x++) {
104 for (let y = startY; y <= endY; y++) {
105 const cellX = offsetX + x * cellSize;
106 const cellY = offsetY + y * cellSize;
107 cb(this.cell({ x, y }), { x: cellX, y: cellY });
108 }
109 }
110 }
111 }
112
113 const ctx = canvas.getContext("2d")!;
114 if (!ctx) {
115 console.error("Could not get canvas context");
116 return () => {};
117 }
118
119 let width: number, height: number;
120 const updateDimensions = () => {
121 width = canvas.width = canvas.offsetWidth;
122 height = canvas.height = canvas.offsetHeight;
123 cellSize = 100;
124 borderThickness = 8;
125 };
126 updateDimensions();
127
128 setTimeout(() => {
129 updateDimensions();
130 }, 10);
131
132 let maze = initMaze();
133 let nextMaze: Maze | null = null;
134 let completeFade = 0;
135 function initMaze(): Maze {
136 return {
137 grid: new Grid(),
138 transform: 0,
139 cursor: {
140 x: randomBetween(0, Math.ceil(width / cellSize)),
141 y: randomBetween(0, Math.ceil(height / cellSize)),
142 },
143 lastTick: performance.now(),
144 randomWallBag: [],
145 randomWallTarget: randomWallTarget(),
146 newCellsToVisit: [],
147 renderOffset: { x: 0, y: 0 },
148 done: false,
149 };
150 }
151
152 function isOnScreen(maze: Maze, x: number, y: number) {
153 const { x: offsetX, y: offsetY } = maze.renderOffset;
154 const cellX = offsetX + x * cellSize;
155 const cellY = offsetY + y * cellSize;
156 return (
157 cellX + cellSize > 0 &&
158 cellX < width &&
159 cellY + cellSize > 0 &&
160 cellY < height
161 );
162 }
163
164 function tick(maze: Maze, other?: Maze) {
165 if (maze.done) return;
166
167 // The original maze algorithm broke down 4%-8% of random right facing
168 // walls, and 4%-8% of down facing walls. It did this at the end.
169 // To make this visual more interesting, two random walls will be broken
170 // down every 12-25 cell visits. This way, the main trail is always running.
171 if (maze.randomWallBag.length > maze.randomWallTarget) {
172 const down: Cell = randomOf(maze.randomWallBag);
173 const right: Cell = randomOf(maze.randomWallBag);
174 maze.randomWallBag.forEach((cell) =>
175 cell.cell_flash = Math.min(cell.cell_flash + 0.2, 1)
176 );
177 down.cell_flash = 1;
178 down.down = false;
179 down.down_flash = 1;
180 right.cell_flash = 1;
181 right.right = false;
182 right.right_flash = 1;
183 maze.randomWallBag = [];
184 maze.randomWallTarget = randomWallTarget();
185 return;
186 }
187
188 // The main algorithm was simple: Have a cursor position, and move it in a
189 // random direction that it had not seen before. Once it had run out of
190 // options, branch off of a previous location. Only visit each cell once.
191 //
192 // In this visualization, cells that are too far offscreen are softly
193 // treated as "visited", which is how the simulation always stays in frame.
194 const current = maze.grid.cell(maze.cursor);
195 current.visited = true;
196 current.cell_flash = 1;
197 maze.randomWallBag.push(current);
198 const adjacent = ([
199 { x: maze.cursor.x + 1, y: maze.cursor.y, dir: "left" },
200 { x: maze.cursor.x - 1, y: maze.cursor.y, dir: "right" },
201 { x: maze.cursor.x, y: maze.cursor.y + 1, dir: "up" },
202 { x: maze.cursor.x, y: maze.cursor.y - 1, dir: "down" },
203 ] as Pos[]).filter((pos) =>
204 isOnScreen(maze, pos.x, pos.y) &&
205 maze.grid.cell(pos).visited === false
206 );
207 if (adjacent.length === 0) {
208 // move cursor to a random cell that has not been visited.
209 const cells = maze.newCellsToVisit.filter((pos) =>
210 isOnScreen(maze, pos.x, pos.y) &&
211 maze.grid.cell(pos).visited === false
212 );
213 if (cells.length === 0) {
214 maze.done = true;
215 return;
216 }
217 const continuePos = randomOf(cells);
218 breakWall(maze, continuePos, other);
219 maze.cursor = { x: continuePos.x, y: continuePos.y };
220 return;
221 }
222
223 // break a random wall
224 const toBreak = randomOf(adjacent);
225 breakWall(maze, toBreak, other);
226 maze.cursor = { x: toBreak.x, y: toBreak.y };
227
228 // add the other directions to the new cells to visit.
229 maze.newCellsToVisit.push(
230 ...adjacent.filter((pos) => pos.dir !== toBreak.dir),
231 );
232 }
233
234 function breakWall(maze: Maze, pos: Pos, other?: Maze) {
235 if (pos.dir === "right") {
236 const cell = maze.grid.cell(pos);
237 cell.right = false;
238 cell.right_flash = 1;
239 if (other) cell.right = false;
240 } else if (pos.dir === "down") {
241 const cell = maze.grid.cell(pos);
242 cell.down = false;
243 cell.down_flash = 1;
244 if (other) cell.down = false;
245 } else if (pos.dir === "left") {
246 const cell = maze.grid.cell({ x: pos.x - 1, y: pos.y });
247 cell.right = false;
248 cell.right_flash = 1;
249 if (other) cell.right = false;
250 } else if (pos.dir === "up") {
251 const cell = maze.grid.cell({ x: pos.x, y: pos.y - 1 });
252 cell.down = false;
253 cell.down_flash = 1;
254 if (other) cell.down = false;
255 }
256 }
257
258 function renderOffset(maze: Maze) {
259 return { x: maze.transform, y: maze.transform };
260 }
261
262 let animationFrameId: number;
263 let last = performance.now();
264 let dt: number = 0;
265
266 function renderMazeBorders(maze: Maze, opacity: number) {
267 ctx.globalAlpha = opacity;
268 maze.grid.forAll(
269 maze.renderOffset,
270 width,
271 height,
272 (cell, { x: cellX, y: cellY }) => {
273 // Walls
274 if (cell.right) {
275 ctx.fillStyle = color;
276 ctx.fillRect(
277 cellX + cellSize - borderThickness / 2,
278 cellY - borderThickness / 2,
279 borderThickness,
280 cellSize + borderThickness,
281 );
282 }
283 if (cell.down) {
284 ctx.fillStyle = color;
285 ctx.fillRect(
286 cellX - borderThickness / 2,
287 cellY + cellSize - borderThickness / 2,
288 cellSize + borderThickness,
289 borderThickness,
290 );
291 }
292 },
293 );
294 ctx.globalAlpha = 1;
295 }
296
297 function renderCellFlash(maze: Maze) {
298 maze.grid.forAll(
299 maze.renderOffset,
300 width,
301 height,
302 (cell, { x: cellX, y: cellY }) => {
303 // Cell flash to show visiting path.
304 if (cell.cell_flash > 0) {
305 cell.cell_flash = Math.max(0, cell.cell_flash - dt / 1000);
306 ctx.fillStyle = cellFlashColor;
307 ctx.globalAlpha = cell.cell_flash * cellFlashModifier;
308 ctx.fillRect(cellX, cellY, cellSize, cellSize);
309 ctx.globalAlpha = 1;
310 }
311 },
312 );
313 }
314
315 function renderBorderFlash(maze: Maze) {
316 maze.grid.forAll(
317 maze.renderOffset,
318 width,
319 height,
320 (cell, { x: cellX, y: cellY }) => {
321 if (cell.right_flash == 0 && cell.down_flash == 0) {
322 return;
323 }
324
325 // Walls
326 const cellFlash = cell.cell_flash * cellFlashModifier;
327 if (cell.right_flash > 0) {
328 cell.right_flash = Math.max(0, cell.right_flash - dt / 500);
329 ctx.fillStyle = interpolateColor(
330 bg,
331 wallFlashColor,
332 Math.max(cell.right_flash, cellFlash),
333 );
334 if (cellFlash > cell.right_flash) {
335 ctx.globalAlpha = cell.right_flash / cellFlash;
336 }
337 ctx.fillRect(
338 cellX + cellSize - borderThickness / 2,
339 cellY + borderThickness / 2,
340 borderThickness,
341 cellSize - borderThickness,
342 );
343 ctx.globalAlpha = 1;
344 }
345 if (cell.down_flash > 0) {
346 if (cellFlash > cell.down_flash) {
347 ctx.globalAlpha = cell.down_flash / cellFlash;
348 }
349 cell.down_flash = Math.max(0, cell.down_flash - dt / 500);
350 ctx.fillStyle = interpolateColor(
351 bg,
352 wallFlashColor,
353 Math.max(cell.down_flash, cellFlash),
354 );
355 ctx.fillRect(
356 cellX + borderThickness / 2,
357 cellY + cellSize - borderThickness / 2,
358 cellSize - borderThickness,
359 borderThickness,
360 );
361 ctx.globalAlpha = 1;
362 }
363 },
364 );
365 }
366
367 function render() {
368 const now = performance.now();
369 dt = now - last;
370 maze.transform += dt * 0.005;
371 maze.renderOffset = renderOffset(maze);
372 if (!maze.done) {
373 if (now - maze.lastTick >= updateTime) {
374 tick(maze);
375 maze.lastTick = now;
376
377 if (maze.done) {
378 nextMaze = initMaze();
379 nextMaze.transform = (maze.transform % cellSize) - dt * 0.005;
380 nextMaze.lastTick = now;
381 completeFade = 0;
382 }
383 }
384 }
385 if (nextMaze) {
386 nextMaze.transform += dt * 0.005;
387 nextMaze.renderOffset = renderOffset(nextMaze);
388 if (!nextMaze.done && now - nextMaze.lastTick >= updateTime) {
389 tick(nextMaze, maze);
390 nextMaze.lastTick = now;
391 }
392 }
393 last = now;
394
395 ctx.clearRect(0, 0, width, height);
396
397 renderCellFlash(maze);
398 if (nextMaze) renderCellFlash(nextMaze);
399
400 renderMazeBorders(maze, 1);
401 if (nextMaze) {
402 renderMazeBorders(nextMaze, completeFade);
403 completeFade += dt / 3000;
404 if (completeFade >= 1) {
405 maze = nextMaze;
406 nextMaze = null;
407 }
408 }
409
410 renderBorderFlash(maze);
411 if (nextMaze) {
412 renderCellFlash(nextMaze);
413 renderBorderFlash(nextMaze);
414 }
415
416 animationFrameId = requestAnimationFrame(render);
417 }
418
419 function interpolateColor(start: number[], end: number[], t: number) {
420 return hex(start.map((s, i) => Math.round(s + (end[i] - s) * t)));
421 }
422
423 window.addEventListener("resize", updateDimensions);
424 animationFrameId = requestAnimationFrame(render);
425
426 // cleanup function
427 return () => {
428 window.removeEventListener("resize", updateDimensions);
429 cancelAnimationFrame(animationFrameId);
430 };
431};
src/file-viewer/scripts/canvas_2019.client.ts created+213
...@@ -0,0 +1,213 @@
1// Ported from CanvasAPI, allegedly written on 2019-08-26.
2(globalThis as any).canvas_2019 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 if (isStandalone) {
5 canvas.parentElement!.style.backgroundColor = "#121013";
6 }
7 // Canvas.tsx
8 abstract class CanvasAPI {
9 canvas: HTMLCanvasElement;
10 ctx: CanvasRenderingContext2D;
11 width = 0;
12 height = 0;
13 private _disposed = false;
14 private _running = false;
15 private _last = 0;
16
17 constructor(canvas: HTMLCanvasElement) {
18 this.canvas = canvas;
19 this.width = canvas.width = canvas.clientWidth;
20 this.height = canvas.height = canvas.clientHeight;
21 const ctx = this.canvas.getContext("2d");
22 if (!ctx) {
23 throw new Error("Canvas2D Not Supported!");
24 }
25 this.ctx = ctx;
26 }
27
28 stopRenderLoop() {
29 this._running = false;
30 }
31
32 startRenderLoop() {
33 if (this._disposed) return;
34 this._running = true;
35 this._last = performance.now();
36 requestAnimationFrame(this._renderLoop);
37 }
38
39 private _renderLoop = (delta: number) => {
40 if (!this._running) return;
41 this.render(delta - this._last);
42 this._last = delta;
43 requestAnimationFrame(this._renderLoop);
44 };
45
46 abstract render(delta: number): void;
47 }
48
49 // VaultBackground.ts
50 function addGSHelper(
51 grad: CanvasGradient,
52 color: string,
53 dotOpacity: number,
54 gradStop: number,
55 gradOpacity: number,
56 ) {
57 grad.addColorStop(gradStop, `rgba(${color},${dotOpacity * gradOpacity})`);
58 }
59
60 function randAround(target: number, dist: number) {
61 return (Math.random() - 0.5) * dist * 2 + target;
62 }
63
64 class Dot {
65 x = Math.random() * 1.1 - 0.05;
66 y = Math.random() / 4 + 0.9;
67 size = Math.random() * 200 + 50;
68 opacity = 0;
69 opacityRandom = Math.random() / 3 + 0.3;
70 fadeInOpacity = 1;
71 color = `${randAround(217, 30)}, ${randAround(170, 30)}, ${
72 randAround(255, 20)
73 }`;
74
75 life = 0;
76
77 ySpeed_1 = 0;
78 ySpeed_2 = -0.0000063;
79 ySpeed_3 = 0.000000016;
80 ySpeed_4 = 0.000000000009;
81
82 seed = Math.random();
83
84 delete = false;
85
86 update(init: boolean) {
87 this.life += 0.8;
88 if (this.life < 115) {
89 this.opacity = this.life / 230;
90 } else if (this.life > 450) {
91 this.delete = true;
92 } else if (this.life > 300) {
93 this.opacity = (150 + 300 - this.life) / 300;
94 }
95
96 this.ySpeed_3 += this.ySpeed_4;
97 this.ySpeed_2 += this.ySpeed_3;
98 this.ySpeed_1 += this.ySpeed_2;
99 this.y += this.ySpeed_1 * 0.5;
100
101 this.size -= 0.08;
102
103 if (this.delete) {
104 Object.assign(this, new Dot());
105 }
106 }
107
108 render(scene: VaultBackground) {
109 const ctx = scene.ctx;
110
111 if (this.fadeInOpacity < 1) {
112 this.fadeInOpacity += 0.0075;
113 }
114
115 const finalX = this.x +
116 Math.sin(this.seed * Math.PI * 2 + Date.now() / 15000) * 0.2;
117
118 const drawX = scene.shakeX +
119 finalX * Math.max(700, scene.width) -
120 (Math.max(700, scene.width) - scene.width) / 2;
121 const drawY = scene.shakeY + (this.y * 1.5 - 0.5) * scene.height;
122
123 const opacity = this.opacity * this.opacityRandom * this.fadeInOpacity;
124
125 const grad = ctx.createRadialGradient(
126 drawX,
127 drawY,
128 0,
129 drawX,
130 drawY,
131 this.size,
132 );
133 addGSHelper(grad, this.color, opacity, 0, 1);
134 addGSHelper(grad, this.color, opacity, 0.8, 0.7);
135 addGSHelper(grad, this.color, opacity, 0.87, 0.5);
136 addGSHelper(grad, this.color, opacity, 0.93, 0.3);
137 addGSHelper(grad, this.color, opacity, 1, 0);
138
139 ctx.fillStyle = grad;
140 ctx.fillRect(
141 drawX - this.size,
142 drawY - this.size,
143 this.size * 2,
144 this.size * 2,
145 );
146 }
147 }
148
149 class VaultBackground extends CanvasAPI {
150 private items = new Set<Dot>();
151
152 private shakeVar = 0;
153 private dom?: HTMLElement;
154 shakeX = 0;
155 shakeY = 0;
156
157 constructor(canvas: HTMLCanvasElement) {
158 super(canvas);
159 for (let i = 0; i < 450; i++) {
160 if (i % 7 === 0) {
161 this.items.add(new Dot());
162 }
163 this.items.forEach((x) => x.update(true));
164 }
165 this.items.forEach((x) => x.fadeInOpacity = 0);
166 }
167
168 render(): void {
169 this.ctx.clearRect(0, 0, this.width, this.height);
170
171 this.items.forEach((x) => (x.update(false), x.render(this)));
172
173 if (this.shakeVar >= 0.0001) {
174 this.shakeVar *= 0.97 - 0.22 * this.shakeVar;
175
176 if (this.shakeVar >= 0.0001) {
177 this.shakeX = (Math.random() * 2 - 1) * this.shakeVar * 65;
178 this.shakeY = (Math.random() * 2 - 1) * this.shakeVar * 65;
179 if (this.dom) {
180 this.dom.style.transform =
181 `translate(${this.shakeX}px,${this.shakeY}px)`;
182 }
183 } else {
184 this.shakeX = 0;
185 this.shakeY = 0;
186 if (this.dom) this.dom.style.removeProperty("transform");
187 this.dom = undefined;
188 }
189 }
190 }
191
192 shake(dom?: HTMLElement | null) {
193 this.dom = dom || document.body;
194 this.shakeVar = 1;
195 }
196 }
197
198 // Binding code
199 let bg = new VaultBackground(canvas);
200 bg.startRenderLoop();
201 canvas.style.opacity = "0.2";
202 function onResize() {
203 bg.width = canvas.width = canvas.clientWidth;
204 bg.height = canvas.height = canvas.clientHeight;
205 }
206 window.addEventListener("resize", onResize);
207 onResize();
208 (globalThis as any).vault = bg;
209 return () => {
210 bg.stopRenderLoop();
211 window.removeEventListener("resize", onResize);
212 };
213};
src/file-viewer/scripts/canvas_2019.ts deleted-213
...@@ -1,213 +0,0 @@
1// Ported from CanvasAPI, allegedly written on 2019-08-26.
2(globalThis as any).canvas_2019 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 if (isStandalone) {
5 canvas.parentElement!.style.backgroundColor = "#121013";
6 }
7 // Canvas.tsx
8 abstract class CanvasAPI {
9 canvas: HTMLCanvasElement;
10 ctx: CanvasRenderingContext2D;
11 width = 0;
12 height = 0;
13 private _disposed = false;
14 private _running = false;
15 private _last = 0;
16
17 constructor(canvas: HTMLCanvasElement) {
18 this.canvas = canvas;
19 this.width = canvas.width = canvas.clientWidth;
20 this.height = canvas.height = canvas.clientHeight;
21 const ctx = this.canvas.getContext("2d");
22 if (!ctx) {
23 throw new Error("Canvas2D Not Supported!");
24 }
25 this.ctx = ctx;
26 }
27
28 stopRenderLoop() {
29 this._running = false;
30 }
31
32 startRenderLoop() {
33 if (this._disposed) return;
34 this._running = true;
35 this._last = performance.now();
36 requestAnimationFrame(this._renderLoop);
37 }
38
39 private _renderLoop = (delta: number) => {
40 if (!this._running) return;
41 this.render(delta - this._last);
42 this._last = delta;
43 requestAnimationFrame(this._renderLoop);
44 };
45
46 abstract render(delta: number): void;
47 }
48
49 // VaultBackground.ts
50 function addGSHelper(
51 grad: CanvasGradient,
52 color: string,
53 dotOpacity: number,
54 gradStop: number,
55 gradOpacity: number,
56 ) {
57 grad.addColorStop(gradStop, `rgba(${color},${dotOpacity * gradOpacity})`);
58 }
59
60 function randAround(target: number, dist: number) {
61 return (Math.random() - 0.5) * dist * 2 + target;
62 }
63
64 class Dot {
65 x = Math.random() * 1.1 - 0.05;
66 y = Math.random() / 4 + 0.9;
67 size = Math.random() * 200 + 50;
68 opacity = 0;
69 opacityRandom = Math.random() / 3 + 0.3;
70 fadeInOpacity = 1;
71 color = `${randAround(217, 30)}, ${randAround(170, 30)}, ${
72 randAround(255, 20)
73 }`;
74
75 life = 0;
76
77 ySpeed_1 = 0;
78 ySpeed_2 = -0.0000063;
79 ySpeed_3 = 0.000000016;
80 ySpeed_4 = 0.000000000009;
81
82 seed = Math.random();
83
84 delete = false;
85
86 update(init: boolean) {
87 this.life += 0.8;
88 if (this.life < 115) {
89 this.opacity = this.life / 230;
90 } else if (this.life > 450) {
91 this.delete = true;
92 } else if (this.life > 300) {
93 this.opacity = (150 + 300 - this.life) / 300;
94 }
95
96 this.ySpeed_3 += this.ySpeed_4;
97 this.ySpeed_2 += this.ySpeed_3;
98 this.ySpeed_1 += this.ySpeed_2;
99 this.y += this.ySpeed_1 * 0.5;
100
101 this.size -= 0.08;
102
103 if (this.delete) {
104 Object.assign(this, new Dot());
105 }
106 }
107
108 render(scene: VaultBackground) {
109 const ctx = scene.ctx;
110
111 if (this.fadeInOpacity < 1) {
112 this.fadeInOpacity += 0.0075;
113 }
114
115 const finalX = this.x +
116 Math.sin(this.seed * Math.PI * 2 + Date.now() / 15000) * 0.2;
117
118 const drawX = scene.shakeX +
119 finalX * Math.max(700, scene.width) -
120 (Math.max(700, scene.width) - scene.width) / 2;
121 const drawY = scene.shakeY + (this.y * 1.5 - 0.5) * scene.height;
122
123 const opacity = this.opacity * this.opacityRandom * this.fadeInOpacity;
124
125 const grad = ctx.createRadialGradient(
126 drawX,
127 drawY,
128 0,
129 drawX,
130 drawY,
131 this.size,
132 );
133 addGSHelper(grad, this.color, opacity, 0, 1);
134 addGSHelper(grad, this.color, opacity, 0.8, 0.7);
135 addGSHelper(grad, this.color, opacity, 0.87, 0.5);
136 addGSHelper(grad, this.color, opacity, 0.93, 0.3);
137 addGSHelper(grad, this.color, opacity, 1, 0);
138
139 ctx.fillStyle = grad;
140 ctx.fillRect(
141 drawX - this.size,
142 drawY - this.size,
143 this.size * 2,
144 this.size * 2,
145 );
146 }
147 }
148
149 class VaultBackground extends CanvasAPI {
150 private items = new Set<Dot>();
151
152 private shakeVar = 0;
153 private dom?: HTMLElement;
154 shakeX = 0;
155 shakeY = 0;
156
157 constructor(canvas: HTMLCanvasElement) {
158 super(canvas);
159 for (let i = 0; i < 450; i++) {
160 if (i % 7 === 0) {
161 this.items.add(new Dot());
162 }
163 this.items.forEach((x) => x.update(true));
164 }
165 this.items.forEach((x) => x.fadeInOpacity = 0);
166 }
167
168 render(): void {
169 this.ctx.clearRect(0, 0, this.width, this.height);
170
171 this.items.forEach((x) => (x.update(false), x.render(this)));
172
173 if (this.shakeVar >= 0.0001) {
174 this.shakeVar *= 0.97 - 0.22 * this.shakeVar;
175
176 if (this.shakeVar >= 0.0001) {
177 this.shakeX = (Math.random() * 2 - 1) * this.shakeVar * 65;
178 this.shakeY = (Math.random() * 2 - 1) * this.shakeVar * 65;
179 if (this.dom) {
180 this.dom.style.transform =
181 `translate(${this.shakeX}px,${this.shakeY}px)`;
182 }
183 } else {
184 this.shakeX = 0;
185 this.shakeY = 0;
186 if (this.dom) this.dom.style.removeProperty("transform");
187 this.dom = undefined;
188 }
189 }
190 }
191
192 shake(dom?: HTMLElement | null) {
193 this.dom = dom || document.body;
194 this.shakeVar = 1;
195 }
196 }
197
198 // Binding code
199 let bg = new VaultBackground(canvas);
200 bg.startRenderLoop();
201 canvas.style.opacity = "0.2";
202 function onResize() {
203 bg.width = canvas.width = canvas.clientWidth;
204 bg.height = canvas.height = canvas.clientHeight;
205 }
206 window.addEventListener("resize", onResize);
207 onResize();
208 (globalThis as any).vault = bg;
209 return () => {
210 bg.stopRenderLoop();
211 window.removeEventListener("resize", onResize);
212 };
213};
src/file-viewer/scripts/canvas_2020.client.ts created+197
...@@ -0,0 +1,197 @@
1// Vibe coded with AI. Heavily tuned.
2(globalThis as any).canvas_2020 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Rain effect with slanted lines
5 // Configuration interface for the rain effect
6 interface RainConfig {
7 fps: number; // frames per second
8 color: string; // color of rain particles
9 angle: number; // angle in degrees
10 particleDensity: number; // particles per 10000 pixels of canvas area
11 speed: number; // speed of particles (pixels per frame)
12 lineWidth: number; // thickness of rain lines
13 lineLength: number; // length of rain lines
14 }
15
16 // Rain particle interface
17 interface RainParticle {
18 x: number; // x position
19 y: number; // y position
20 }
21
22 // Default configuration
23 const config: RainConfig = {
24 fps: 16,
25 color: isStandalone ? "#00FEFB99" : "#081F24",
26 angle: -18,
27 particleDensity: 1,
28 speed: 400,
29 lineWidth: 8,
30 lineLength: 100,
31 };
32
33 // Get the canvas context
34 const ctx = canvas.getContext("2d");
35 if (!ctx) {
36 console.error("Could not get canvas context");
37 return () => {};
38 }
39
40 // Make canvas transparent
41 if (isStandalone) {
42 canvas.style.backgroundColor = "#0F252B";
43 } else {
44 canvas.style.backgroundColor = "transparent";
45 }
46
47 // Calculate canvas dimensions and update when resized
48 let width = canvas.width;
49 let height = canvas.height;
50 let particles: RainParticle[] = [];
51 let animationFrameId: number;
52 let lastFrameTime = 0;
53 const frameInterval = 1000 / config.fps;
54
55 // Calculate angle in radians
56 const angleRad = (config.angle * Math.PI) / 180;
57
58 // Update canvas dimensions and particle count when resized
59 const updateDimensions = () => {
60 width = canvas.width = canvas.offsetWidth;
61 height = canvas.height = canvas.offsetHeight;
62
63 // Calculate the canvas area in pixels
64 const canvasArea = width * height;
65
66 // Calculate target number of particles based on canvas area
67 const targetParticleCount = Math.floor(
68 (canvasArea / 10000) * config.particleDensity,
69 );
70
71 // Calculate buffer for horizontal offset due to slanted angle
72 const buffer = Math.abs(height * Math.tan(angleRad)) + config.lineLength;
73
74 // Adjust the particles array
75 if (particles.length < targetParticleCount) {
76 // Add more particles if needed
77 for (let i = particles.length; i < targetParticleCount; i++) {
78 particles.push(createParticle(true, buffer));
79 }
80 } else if (particles.length > targetParticleCount) {
81 // Remove excess particles
82 particles = particles.slice(0, targetParticleCount);
83 }
84 };
85
86 // Create a new particle
87 // Added initialDistribution parameter to distribute particles across the entire canvas at startup
88 const createParticle = (
89 initialDistribution = false,
90 buffer: number,
91 ): RainParticle => {
92 // For initial distribution, place particles throughout the canvas
93 // Otherwise start them above the canvas
94 let x = Math.random() * (width + buffer * 2) - buffer;
95 let y;
96
97 if (initialDistribution) {
98 // Distribute across the entire canvas height for initial setup
99 y = Math.random() * (height + config.lineLength * 2) - config.lineLength;
100 } else {
101 // Start new particles from above the canvas with some randomization
102 y = -config.lineLength - (Math.random() * config.lineLength * 20);
103 }
104
105 return {
106 x,
107 y,
108 };
109 };
110
111 // Update particle positions
112 const updateParticles = () => {
113 // Calculate buffer for horizontal offset due to slanted angle
114 const buffer = Math.abs(height * Math.tan(angleRad)) + config.lineLength;
115
116 for (let i = 0; i < particles.length; i++) {
117 const p = particles[i];
118
119 // Update position based on speed and angle
120 p.x += Math.sin(angleRad) * config.speed;
121 p.y += Math.cos(angleRad) * config.speed;
122
123 // Reset particles that go offscreen - only determined by position
124 // Add extra buffer to ensure particles fully exit the visible area before resetting
125 if (
126 p.y > height + config.lineLength ||
127 p.x < -buffer ||
128 p.x > width + buffer
129 ) {
130 particles[i] = createParticle(false, buffer);
131 }
132 }
133 };
134
135 // Draw particles
136 const drawParticles = () => {
137 // Clear canvas
138 ctx.clearRect(0, 0, width, height);
139
140 // Set drawing properties
141 ctx.strokeStyle = config.color;
142 ctx.lineWidth = config.lineWidth;
143 ctx.lineCap = "square";
144
145 // Draw each rain line
146 ctx.beginPath();
147 for (const p of particles) {
148 // Only draw particles that are either on screen or within a reasonable buffer
149 // This is for performance reasons - we don't need to draw particles far offscreen
150 if (p.y >= -config.lineLength * 2 && p.y <= height + config.lineLength) {
151 const endX = p.x + Math.sin(angleRad) * config.lineLength;
152 const endY = p.y + Math.cos(angleRad) * config.lineLength;
153
154 ctx.moveTo(p.x, p.y);
155 ctx.lineTo(endX, endY);
156 }
157 }
158 ctx.stroke();
159 };
160
161 // Animation loop
162 const animate = (currentTime: number) => {
163 animationFrameId = requestAnimationFrame(animate);
164
165 // Control frame rate
166 if (currentTime - lastFrameTime < frameInterval) {
167 return;
168 }
169
170 lastFrameTime = currentTime;
171
172 updateParticles();
173 drawParticles();
174 };
175
176 // Initialize the animation
177 const init = () => {
178 // Set up resize handler
179 window.addEventListener("resize", updateDimensions);
180
181 // Initial setup
182 updateDimensions();
183
184 // Start animation
185 lastFrameTime = performance.now();
186 animationFrameId = requestAnimationFrame(animate);
187 };
188
189 // Start the animation
190 init();
191
192 // Return cleanup function
193 return () => {
194 window.removeEventListener("resize", updateDimensions);
195 cancelAnimationFrame(animationFrameId);
196 };
197};
src/file-viewer/scripts/canvas_2020.ts deleted-197
...@@ -1,197 +0,0 @@
1// Vibe coded with AI
2(globalThis as any).canvas_2020 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Rain effect with slanted lines
5 // Configuration interface for the rain effect
6 interface RainConfig {
7 fps: number; // frames per second
8 color: string; // color of rain particles
9 angle: number; // angle in degrees
10 particleDensity: number; // particles per 10000 pixels of canvas area
11 speed: number; // speed of particles (pixels per frame)
12 lineWidth: number; // thickness of rain lines
13 lineLength: number; // length of rain lines
14 }
15
16 // Rain particle interface
17 interface RainParticle {
18 x: number; // x position
19 y: number; // y position
20 }
21
22 // Default configuration
23 const config: RainConfig = {
24 fps: 16,
25 color: isStandalone ? "#00FEFB99" : "#081F24",
26 angle: -18,
27 particleDensity: 1,
28 speed: 400,
29 lineWidth: 8,
30 lineLength: 100,
31 };
32
33 // Get the canvas context
34 const ctx = canvas.getContext("2d");
35 if (!ctx) {
36 console.error("Could not get canvas context");
37 return () => {};
38 }
39
40 // Make canvas transparent
41 if (isStandalone) {
42 canvas.style.backgroundColor = "#0F252B";
43 } else {
44 canvas.style.backgroundColor = "transparent";
45 }
46
47 // Calculate canvas dimensions and update when resized
48 let width = canvas.width;
49 let height = canvas.height;
50 let particles: RainParticle[] = [];
51 let animationFrameId: number;
52 let lastFrameTime = 0;
53 const frameInterval = 1000 / config.fps;
54
55 // Calculate angle in radians
56 const angleRad = (config.angle * Math.PI) / 180;
57
58 // Update canvas dimensions and particle count when resized
59 const updateDimensions = () => {
60 width = canvas.width = canvas.offsetWidth;
61 height = canvas.height = canvas.offsetHeight;
62
63 // Calculate the canvas area in pixels
64 const canvasArea = width * height;
65
66 // Calculate target number of particles based on canvas area
67 const targetParticleCount = Math.floor(
68 (canvasArea / 10000) * config.particleDensity,
69 );
70
71 // Calculate buffer for horizontal offset due to slanted angle
72 const buffer = Math.abs(height * Math.tan(angleRad)) + config.lineLength;
73
74 // Adjust the particles array
75 if (particles.length < targetParticleCount) {
76 // Add more particles if needed
77 for (let i = particles.length; i < targetParticleCount; i++) {
78 particles.push(createParticle(true, buffer));
79 }
80 } else if (particles.length > targetParticleCount) {
81 // Remove excess particles
82 particles = particles.slice(0, targetParticleCount);
83 }
84 };
85
86 // Create a new particle
87 // Added initialDistribution parameter to distribute particles across the entire canvas at startup
88 const createParticle = (
89 initialDistribution = false,
90 buffer: number,
91 ): RainParticle => {
92 // For initial distribution, place particles throughout the canvas
93 // Otherwise start them above the canvas
94 let x = Math.random() * (width + buffer * 2) - buffer;
95 let y;
96
97 if (initialDistribution) {
98 // Distribute across the entire canvas height for initial setup
99 y = Math.random() * (height + config.lineLength * 2) - config.lineLength;
100 } else {
101 // Start new particles from above the canvas with some randomization
102 y = -config.lineLength - (Math.random() * config.lineLength * 20);
103 }
104
105 return {
106 x,
107 y,
108 };
109 };
110
111 // Update particle positions
112 const updateParticles = () => {
113 // Calculate buffer for horizontal offset due to slanted angle
114 const buffer = Math.abs(height * Math.tan(angleRad)) + config.lineLength;
115
116 for (let i = 0; i < particles.length; i++) {
117 const p = particles[i];
118
119 // Update position based on speed and angle
120 p.x += Math.sin(angleRad) * config.speed;
121 p.y += Math.cos(angleRad) * config.speed;
122
123 // Reset particles that go offscreen - only determined by position
124 // Add extra buffer to ensure particles fully exit the visible area before resetting
125 if (
126 p.y > height + config.lineLength ||
127 p.x < -buffer ||
128 p.x > width + buffer
129 ) {
130 particles[i] = createParticle(false, buffer);
131 }
132 }
133 };
134
135 // Draw particles
136 const drawParticles = () => {
137 // Clear canvas
138 ctx.clearRect(0, 0, width, height);
139
140 // Set drawing properties
141 ctx.strokeStyle = config.color;
142 ctx.lineWidth = config.lineWidth;
143 ctx.lineCap = "square";
144
145 // Draw each rain line
146 ctx.beginPath();
147 for (const p of particles) {
148 // Only draw particles that are either on screen or within a reasonable buffer
149 // This is for performance reasons - we don't need to draw particles far offscreen
150 if (p.y >= -config.lineLength * 2 && p.y <= height + config.lineLength) {
151 const endX = p.x + Math.sin(angleRad) * config.lineLength;
152 const endY = p.y + Math.cos(angleRad) * config.lineLength;
153
154 ctx.moveTo(p.x, p.y);
155 ctx.lineTo(endX, endY);
156 }
157 }
158 ctx.stroke();
159 };
160
161 // Animation loop
162 const animate = (currentTime: number) => {
163 animationFrameId = requestAnimationFrame(animate);
164
165 // Control frame rate
166 if (currentTime - lastFrameTime < frameInterval) {
167 return;
168 }
169
170 lastFrameTime = currentTime;
171
172 updateParticles();
173 drawParticles();
174 };
175
176 // Initialize the animation
177 const init = () => {
178 // Set up resize handler
179 window.addEventListener("resize", updateDimensions);
180
181 // Initial setup
182 updateDimensions();
183
184 // Start animation
185 lastFrameTime = performance.now();
186 animationFrameId = requestAnimationFrame(animate);
187 };
188
189 // Start the animation
190 init();
191
192 // Return cleanup function
193 return () => {
194 window.removeEventListener("resize", updateDimensions);
195 cancelAnimationFrame(animationFrameId);
196 };
197};
src/file-viewer/scripts/canvas_2021.client.ts created+790
...@@ -0,0 +1,790 @@
1// Initially vibe coded with AI, but a lot of tuning was done manually
2// for it to feel natural. Some of the tuning was done through AI and some
3// was manual. This implementation is not very performant and might get
4// re-visited, but it runs mostly-fine, mostly in chromium.
5//
6// The parts that need improvement are how particles are computed. Those
7// nested loops take way too long. 2d Canvas is fine for rendering. A
8// good chance moving computation to WASM and rendering to JS would help.
9(globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) {
10 const isStandalone = canvas.getAttribute("data-standalone") === "true";
11 // Constants for simulation
12 const PARTICLE_RADIUS = 4.5;
13 const PARTICLE_DENSITY = 0.004; // Particles per pixel
14 const MIN_SPEED = 0.05;
15 const MAX_SPEED = 6.0;
16 const FRICTION = 0.96;
17 const REPULSION_STRENGTH = 0.1;
18 const REPULSION_RADIUS = 50;
19 const FORCE_RADIUS = 400; // Increased radius
20 const FORCE_STRENGTH = 0.25;
21 const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff
22 const FORCE_SPACING = 10; // Pixels between force points
23 const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements
24 const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements
25 const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force
26 const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force
27 const OVERSCAN_PIXELS = 250;
28 const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing
29
30 let globalOpacity = 0;
31
32 if (isStandalone) {
33 canvas.style.backgroundColor = "#301D02";
34 } else {
35 canvas.style.backgroundColor = "transparent";
36 }
37
38 // Interfaces
39 interface Particle {
40 x: number;
41 y: number;
42 vx: number;
43 vy: number;
44 charge: number; // 0 to 1, affecting color
45 }
46
47 interface Force {
48 x: number;
49 y: number;
50 dx: number;
51 dy: number;
52 strength: number;
53 radius: number;
54 createdAt: number;
55 }
56
57 interface SpatialHash {
58 [key: string]: Particle[];
59 }
60
61 // State
62 let first = true;
63 let particles: Particle[] = [];
64 let forces: Force[] = [];
65 let width = canvas.width;
66 let height = canvas.height;
67 let targetParticleCount = 0;
68 let spatialHash: SpatialHash = {};
69 let ctx: CanvasRenderingContext2D | null = null;
70 let animationId: number | null = null;
71 let isRunning = false;
72
73 // Mouse tracking
74 let lastMousePosition: { x: number; y: number } | null = null;
75 // Track position of the last created force
76 let lastForcePosition: { x: number; y: number } | null = null;
77
78 // Keep track of previous canvas dimensions for resize logic
79 let previousWidth = 0;
80 let previousHeight = 0;
81
82 // Initialize and cleanup
83 function init(): void {
84 ctx = canvas.getContext("2d");
85 if (!ctx) return;
86
87 // Set canvas to full size
88 resizeCanvas();
89
90 // Event listeners
91 window.addEventListener("resize", resizeCanvas);
92 document.addEventListener("mousemove", handleMouseMove);
93
94 // Start animation immediately
95 start();
96 }
97
98 function cleanup(): void {
99 // Stop the animation
100 stop();
101
102 // Remove event listeners
103 window.removeEventListener("resize", resizeCanvas);
104 document.removeEventListener("mousemove", handleMouseMove);
105
106 // Clear arrays
107 particles = [];
108 forces = [];
109 spatialHash = {};
110 lastMousePosition = null;
111 lastForcePosition = null;
112 }
113
114 // Resize canvas and adjust particle count
115 function resizeCanvas(): void {
116 // Store previous dimensions
117 previousWidth = width;
118 previousHeight = height;
119
120 // Update to new dimensions
121 width = window.innerWidth;
122 height = window.innerHeight;
123 canvas.width = width;
124 canvas.height = height;
125
126 const oldTargetCount = targetParticleCount;
127 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
128
129 // Adjust particle count
130 if (targetParticleCount > oldTargetCount) {
131 // Add more particles if needed, but only in newly available space
132 addParticles(targetParticleCount - oldTargetCount, !first);
133 first = false;
134 }
135 // Note: Removal of excess particles happens naturally during update
136 }
137
138 // Handle mouse movement
139 function handleMouseMove(e: MouseEvent): void {
140 const rect = canvas.getBoundingClientRect();
141 const currentX = e.clientX - rect.left;
142 const currentY = e.clientY - rect.top;
143
144 // Initialize positions if this is the first movement
145 if (!lastMousePosition || !lastForcePosition) {
146 lastMousePosition = { x: currentX, y: currentY };
147 lastForcePosition = { x: currentX, y: currentY };
148 return;
149 }
150
151 // Store current mouse position
152 const mouseX = currentX;
153 const mouseY = currentY;
154
155 // Calculate vector from last mouse position to current
156 const dx = mouseX - lastMousePosition.x;
157 const dy = mouseY - lastMousePosition.y;
158 const distMoved = Math.sqrt(dx * dx + dy * dy);
159
160 // Skip if essentially no movement (avoids numerical issues)
161 if (distMoved < 0.1) {
162 return;
163 }
164
165 // Get the vector from the last force to the current mouse position
166 const forceDx = mouseX - lastForcePosition.x;
167 const forceDy = mouseY - lastForcePosition.y;
168 const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy);
169
170 // Only create forces if we've moved far enough from the last force
171 if (forceDistance >= FORCE_SPACING) {
172 // Calculate the direction vector from last force to current mouse
173 let dirX = forceDx / forceDistance;
174 let dirY = forceDy / forceDistance;
175
176 // Calculate how many force points to create
177 const numPoints = Math.floor(forceDistance / FORCE_SPACING);
178
179 // Calculate movement speed based on the recent movement
180 const movementSpeed = distMoved; // Simple approximation of speed
181
182 // Scale force strength based on movement speed
183 let speedFactor;
184 if (movementSpeed <= MIN_SPEED_THRESHOLD) {
185 speedFactor = MIN_FORCE_STRENGTH;
186 } else if (movementSpeed >= MAX_SPEED_THRESHOLD) {
187 speedFactor = MAX_FORCE_STRENGTH;
188 } else {
189 // Linear interpolation between min and max
190 const t = (movementSpeed - MIN_SPEED_THRESHOLD) /
191 (MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD);
192 speedFactor = MIN_FORCE_STRENGTH +
193 t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH);
194 }
195
196 // Store current force position to update incrementally
197 let currentForceX = lastForcePosition.x;
198 let currentForceY = lastForcePosition.y;
199
200 // Create evenly spaced force points along the path from last force to current mouse
201 for (let i = 0; i < numPoints; i++) {
202 // Calculate position for this force point
203 const t = (i + 1) / numPoints;
204 const fx = lastForcePosition.x + forceDx * t;
205 const fy = lastForcePosition.y + forceDy * t;
206
207 // Create force at this position with the direction vector
208 createForce(fx, fy, dirX, dirY, speedFactor);
209
210 // Update the last force position to this new force
211 currentForceX = fx;
212 currentForceY = fy;
213 }
214
215 // Update the last force position
216 lastForcePosition = { x: currentForceX, y: currentForceY };
217 }
218
219 // Always update the last mouse position
220 lastMousePosition = { x: mouseX, y: mouseY };
221 }
222
223 // Create a new force
224 function createForce(
225 x: number,
226 y: number,
227 dx: number,
228 dy: number,
229 strength = FORCE_STRENGTH,
230 ): void {
231 forces.push({
232 x,
233 y,
234 dx,
235 dy,
236 strength,
237 radius: 1,
238 createdAt: Date.now(),
239 });
240 }
241
242 // Improved particle addition with fill strategy options
243 function addParticles(count: number, inNewAreaOnly: boolean = false): void {
244 // Determine available space
245 const minX = -OVERSCAN_PIXELS;
246 const maxX = width + OVERSCAN_PIXELS;
247 const minY = -OVERSCAN_PIXELS;
248 const maxY = height + OVERSCAN_PIXELS;
249
250 // Use a grid system that guarantees uniform spacing of particles
251 const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius
252 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
253 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
254
255 // Track which grid cells are already occupied
256 const occupiedCells: Set<string> = new Set();
257
258 // Mark cells occupied by existing particles
259 for (const particle of particles) {
260 const cellX = Math.floor((particle.x - minX) / gridSpacing);
261 const cellY = Math.floor((particle.y - minY) / gridSpacing);
262
263 // Ensure cell coordinates are within valid range
264 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
265 occupiedCells.add(`${cellX},${cellY}`);
266 }
267 }
268
269 // Create arrays of all cells and filter by placement strategy
270 const allGridCells: { x: number; y: number }[] = [];
271
272 for (let cellY = 0; cellY < gridHeight; cellY++) {
273 for (let cellX = 0; cellX < gridWidth; cellX++) {
274 const cellKey = `${cellX},${cellY}`;
275 if (!occupiedCells.has(cellKey)) {
276 const posX = minX + (cellX + 0.5) * gridSpacing;
277 const posY = minY + (cellY + 0.5) * gridSpacing;
278
279 // For new area only placement, filter to expanded areas
280 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
281 const expandedRight = width > previousWidth;
282 const expandedBottom = height > previousHeight;
283
284 const inNewRightArea = expandedRight && posX >= previousWidth &&
285 posX <= width;
286 const inNewBottomArea = expandedBottom && posY >= previousHeight &&
287 posY <= height;
288
289 if (inNewRightArea || inNewBottomArea) {
290 allGridCells.push({ x: cellX, y: cellY });
291 }
292 } else if (!inNewAreaOnly) {
293 // Standard placement - add all valid cells
294 allGridCells.push({ x: cellX, y: cellY });
295 }
296 }
297 }
298 }
299
300 if (allGridCells.length == 0) {
301 throw new Error("No cells available to place particles");
302 }
303
304 // We now have all grid cells that match our placement criteria
305
306 // If we need more particles than we have available cells, we need to adjust
307 // gridSpacing to fit more cells into the same space
308 if (count > allGridCells.length) {
309 // Retry with a smaller grid spacing
310 // Proportionally reduce the grid spacing to fit the required number of particles
311 const scaleFactor = Math.sqrt(allGridCells.length / count);
312 const newGridSpacing = gridSpacing * scaleFactor;
313
314 // Clear particles and try again with new spacing
315 // This is a recursive call, but with adjusted parameters that will fit
316 return addParticlesWithCustomSpacing(
317 count,
318 inNewAreaOnly,
319 newGridSpacing,
320 );
321 }
322
323 // Shuffle the available cells for random selection
324 shuffleArray(allGridCells);
325
326 // Take the number of cells we need
327 const cellsToUse = Math.min(count, allGridCells.length);
328 const selectedCells = allGridCells.slice(0, cellsToUse);
329
330 // Create particles in selected cells
331 for (const cell of selectedCells) {
332 // Add jitter within the cell for natural look
333 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
334 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
335
336 // Calculate final position
337 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
338 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
339
340 // Create a particle at this position
341 particles.push(createParticle(x, y));
342 }
343 }
344
345 // Helper function to add particles with custom grid spacing
346 function addParticlesWithCustomSpacing(
347 count: number,
348 inNewAreaOnly: boolean,
349 gridSpacing: number,
350 ): void {
351 if (gridSpacing == 0) throw new Error("Grid spacing is 0");
352 // Determine available space
353 const minX = -OVERSCAN_PIXELS;
354 const maxX = width + OVERSCAN_PIXELS;
355 const minY = -OVERSCAN_PIXELS;
356 const maxY = height + OVERSCAN_PIXELS;
357
358 // Create grid using the custom spacing
359 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
360 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
361
362 // Track which grid cells are already occupied
363 const occupiedCells: Set<string> = new Set();
364
365 // Mark cells occupied by existing particles
366 for (const particle of particles) {
367 const cellX = Math.floor((particle.x - minX) / gridSpacing);
368 const cellY = Math.floor((particle.y - minY) / gridSpacing);
369
370 // Ensure cell coordinates are within valid range
371 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
372 occupiedCells.add(`${cellX},${cellY}`);
373 }
374 }
375
376 // Create arrays of all cells and filter by placement strategy
377 const allGridCells: { x: number; y: number }[] = [];
378
379 for (let cellY = 0; cellY < gridHeight; cellY++) {
380 for (let cellX = 0; cellX < gridWidth; cellX++) {
381 const cellKey = `${cellX},${cellY}`;
382 if (!occupiedCells.has(cellKey)) {
383 const posX = minX + (cellX + 0.5) * gridSpacing;
384 const posY = minY + (cellY + 0.5) * gridSpacing;
385
386 // For new area only placement, filter to expanded areas
387 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
388 const expandedRight = width > previousWidth;
389 const expandedBottom = height > previousHeight;
390
391 const inNewRightArea = expandedRight && posX >= previousWidth &&
392 posX <= width;
393 const inNewBottomArea = expandedBottom && posY >= previousHeight &&
394 posY <= height;
395
396 if (inNewRightArea || inNewBottomArea) {
397 allGridCells.push({ x: cellX, y: cellY });
398 }
399 } else if (!inNewAreaOnly) {
400 // Standard placement - add all valid cells
401 allGridCells.push({ x: cellX, y: cellY });
402 }
403 }
404 }
405 }
406
407 // Shuffle the available cells for random distribution
408 shuffleArray(allGridCells);
409
410 // Take the number of cells we need (or all if we have fewer)
411 const cellsToUse = Math.min(count, allGridCells.length);
412
413 // Create particles in selected cells
414 for (let i = 0; i < cellsToUse; i++) {
415 const cell = allGridCells[i];
416
417 // Add jitter within the cell
418 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
419 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
420
421 // Calculate final position
422 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
423 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
424
425 // Create a particle at this position
426 particles.push(createParticle(x, y));
427 }
428 }
429
430 // Utility to shuffle an array (Fisher-Yates algorithm)
431 function shuffleArray<T>(array: T[]): void {
432 for (let i = array.length - 1; i > 0; i--) {
433 const j = Math.floor(Math.random() * (i + 1));
434 [array[i], array[j]] = [array[j], array[i]];
435 }
436 }
437
438 // Simplified createParticle function that just places at a specific position
439 function createParticle(x: number, y: number): Particle {
440 return {
441 x: x + (Math.random() * 4 - 2),
442 y: y + (Math.random() * 4 - 2),
443 vx: 0,
444 vy: 0,
445 charge: 0,
446 };
447 }
448
449 // Function to create a particle on one of the edges
450 function createParticleOnEdge(): Particle {
451 // Overscan bounds with fixed pixel size
452 const minX = -OVERSCAN_PIXELS;
453 const maxX = width + OVERSCAN_PIXELS;
454 const minY = -OVERSCAN_PIXELS;
455 const maxY = height + OVERSCAN_PIXELS;
456
457 let x: number, y: number;
458
459 // Place on one of the edges
460 const edge = Math.floor(Math.random() * 4);
461 switch (edge) {
462 case 0: // Top
463 x = minX + Math.random() * (maxX - minX);
464 y = minY;
465 break;
466 case 1: // Right
467 x = maxX;
468 y = minY + Math.random() * (maxY - minY);
469 break;
470 case 2: // Bottom
471 x = minX + Math.random() * (maxX - minX);
472 y = maxY;
473 break;
474 case 3: // Left
475 x = minX;
476 y = minY + Math.random() * (maxY - minY);
477 break;
478 default:
479 x = minX + Math.random() * (maxX - minX);
480 y = minY + Math.random() * (maxY - minY);
481 }
482
483 return createParticle(x, y);
484 }
485
486 // Spatial hashing functions
487 function getHashKey(x: number, y: number): string {
488 const cellX = Math.floor(x / CELL_SIZE);
489 const cellY = Math.floor(y / CELL_SIZE);
490 return `${cellX},${cellY}`;
491 }
492
493 function addToSpatialHash(particle: Particle): void {
494 const key = getHashKey(particle.x, particle.y);
495 if (!spatialHash[key]) {
496 spatialHash[key] = [];
497 }
498 spatialHash[key].push(particle);
499 }
500
501 function updateSpatialHash(): void {
502 // Clear previous hash
503 spatialHash = {};
504
505 // Add all particles to hash
506 for (const particle of particles) {
507 addToSpatialHash(particle);
508 }
509 }
510
511 function getNearbyParticles(
512 x: number,
513 y: number,
514 radius: number,
515 ): Particle[] {
516 const result: Particle[] = [];
517 const cellRadius = Math.ceil(radius / CELL_SIZE);
518
519 const centerCellX = Math.floor(x / CELL_SIZE);
520 const centerCellY = Math.floor(y / CELL_SIZE);
521
522 for (
523 let cellX = centerCellX - cellRadius;
524 cellX <= centerCellX + cellRadius;
525 cellX++
526 ) {
527 for (
528 let cellY = centerCellY - cellRadius;
529 cellY <= centerCellY + cellRadius;
530 cellY++
531 ) {
532 const key = `${cellX},${cellY}`;
533 const cell = spatialHash[key];
534
535 if (cell) {
536 result.push(...cell);
537 }
538 }
539 }
540
541 return result;
542 }
543
544 // Main update function
545 function update(): void {
546 const now = Date.now();
547 // Fixed pixel overscan
548 const minX = -OVERSCAN_PIXELS;
549 const maxX = width + OVERSCAN_PIXELS;
550 const minY = -OVERSCAN_PIXELS;
551 const maxY = height + OVERSCAN_PIXELS;
552
553 // Update spatial hash
554 updateSpatialHash();
555
556 // Update forces and remove expired ones
557 if (forces.length > 40) {
558 forces = forces.slice(-40);
559 }
560 forces = forces.filter((force) => {
561 force.strength *= 0.95;
562 force.radius *= 0.95;
563 return force.strength > 0.001;
564 });
565
566 // Update particles
567 const newParticles: Particle[] = [];
568
569 for (const particle of particles) {
570 // Apply forces
571 for (const force of forces) {
572 const dx = particle.x - force.x;
573 const dy = particle.y - force.y;
574 const distSq = dx * dx + dy * dy;
575
576 const radius = force.radius * FORCE_RADIUS;
577
578 if (distSq < radius * radius) {
579 const dist = Math.sqrt(distSq);
580
581 // Exponential falloff - much more concentrated at center
582 // (1 - x/R)^n where n controls how sharp the falloff is
583 const normalizedDist = dist / radius;
584 const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
585
586 // Calculate force line projection for directional effect
587 // This makes particles along the force's path experience stronger effect
588 const dotProduct = (dx * -force.dx) + (dy * -force.dy);
589 const projectionFactor = Math.max(0, dotProduct / dist);
590
591 // Apply the combined factors - stronger directional bias
592 const finalFactor = factor * force.strength *
593 (0.1 + 0.9 * projectionFactor);
594
595 particle.vx += force.dx * finalFactor;
596 particle.vy += force.dy * finalFactor;
597 // charge for the first 100ms
598 if ((now - force.createdAt) < 100) {
599 particle.charge = Math.min(
600 1,
601 particle.charge + (finalFactor * finalFactor) * 0.2,
602 );
603 }
604 }
605 }
606
607 // Apply repulsion from nearby particles
608 const nearby = getNearbyParticles(
609 particle.x,
610 particle.y,
611 REPULSION_RADIUS,
612 );
613
614 for (const other of nearby) {
615 if (other === particle) continue;
616
617 const dx = particle.x - other.x;
618 const dy = particle.y - other.y;
619 const distSq = dx * dx + dy * dy;
620
621 if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) {
622 const dist = Math.sqrt(distSq);
623 const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS);
624
625 const fx = dx / dist * factor;
626 const fy = dy / dist * factor;
627
628 particle.vx += fx;
629 particle.vy += fy;
630 }
631 }
632
633 // Apply friction
634 particle.vx *= FRICTION;
635 particle.vy *= FRICTION;
636
637 // Ensure minimum speed
638 const speed = Math.sqrt(
639 particle.vx * particle.vx + particle.vy * particle.vy,
640 );
641 if (speed < MIN_SPEED && speed > 0) {
642 const scale = MIN_SPEED / speed;
643 particle.vx *= scale;
644 particle.vy *= scale;
645 }
646
647 // Cap at maximum speed
648 if (speed > MAX_SPEED) {
649 const scale = MAX_SPEED / speed;
650 particle.vx *= scale;
651 particle.vy *= scale;
652 }
653
654 // Update position
655 particle.x += particle.vx;
656 particle.y += particle.vy;
657
658 // Decrease charge
659 particle.charge *= 0.99;
660
661 // Check if particle is within extended bounds
662 if (
663 particle.x >= minX && particle.x <= maxX &&
664 particle.y >= minY && particle.y <= maxY
665 ) {
666 // If outside screen but within overscan, keep it if we need more particles
667 if (
668 (particle.x < 0 || particle.x > width ||
669 particle.y < 0 || particle.y > height) &&
670 newParticles.length >= targetParticleCount
671 ) {
672 continue;
673 }
674
675 newParticles.push(particle);
676 } else {
677 // Out of bounds, respawn if needed
678 if (newParticles.length < targetParticleCount) {
679 newParticles.push(createParticleOnEdge());
680 }
681 }
682 }
683
684 // Add more particles if needed
685 while (newParticles.length < targetParticleCount) {
686 newParticles.push(createParticleOnEdge());
687 }
688
689 particles = newParticles;
690 }
691
692 // Render function
693 const mul = isStandalone ? 0.9 : 0.5;
694 const add = isStandalone ? 0.1 : 0.03;
695 function render(): void {
696 if (!ctx) return;
697
698 // Clear canvas
699 ctx.clearRect(0, 0, width, height);
700
701 // Draw particles
702 for (const particle of particles) {
703 // Only draw if within canvas bounds (plus a small margin)
704 if (
705 particle.x >= -PARTICLE_RADIUS &&
706 particle.x <= width + PARTICLE_RADIUS &&
707 particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS
708 ) {
709 ctx.beginPath();
710 ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2);
711
712 // Color based on charge
713 ctx.fillStyle = "#FFCB1F";
714 ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity;
715 ctx.fill();
716 }
717 }
718
719 // // Debug: Draw forces and falloff visualization
720 // if (ctx) {
721 // for (const force of forces) {
722 // const R = force.radius * FORCE_RADIUS;
723
724 // // Draw force point
725 // ctx.beginPath();
726 // ctx.arc(force.x, force.y, 5, 0, Math.PI * 2);
727 // ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
728 // ctx.fill();
729
730 // // Draw force direction
731 // ctx.beginPath();
732 // ctx.moveTo(force.x, force.y);
733 // ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20);
734 // ctx.strokeStyle = 'red';
735 // ctx.stroke();
736
737 // // Visualize the falloff curve with rings
738 // for (let i = 0; i <= 10; i++) {
739 // const radius = (R * i) / 10;
740 // const normalizedDist = radius / R;
741 // const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
742
743 // ctx.beginPath();
744 // ctx.arc(force.x, force.y, radius, 0, Math.PI * 2);
745 // ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`;
746 // ctx.stroke();
747 // }
748 // }
749 // }
750 }
751
752 // Animation loop
753 let r = Math.random();
754 function animate(): void {
755 globalOpacity = Math.min(1, globalOpacity + 0.03);
756 update();
757 render();
758
759 if (isRunning) {
760 animationId = requestAnimationFrame(animate);
761 }
762 }
763
764 // Start/stop functions
765 function start(): void {
766 if (isRunning) return;
767
768 // Calculate target particle count based on canvas size
769 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
770
771 // Clear any existing particles and create new ones with proper spacing
772 particles = [];
773 addParticles(targetParticleCount);
774
775 isRunning = true;
776 animate();
777 }
778
779 function stop(): void {
780 isRunning = false;
781
782 if (animationId !== null) {
783 cancelAnimationFrame(animationId);
784 animationId = null;
785 }
786 }
787
788 init();
789 return cleanup;
790};
src/file-viewer/scripts/canvas_2021.ts deleted-783
...@@ -1,783 +0,0 @@
1// Vibe coded.
2(globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Constants for simulation
5 const PARTICLE_RADIUS = 4.5;
6 const PARTICLE_DENSITY = 0.004; // Particles per pixel
7 const MIN_SPEED = 0.05;
8 const MAX_SPEED = 6.0;
9 const FRICTION = 0.96;
10 const REPULSION_STRENGTH = 0.1;
11 const REPULSION_RADIUS = 50;
12 const FORCE_RADIUS = 400; // Increased radius
13 const FORCE_STRENGTH = 0.25;
14 const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff
15 const FORCE_SPACING = 10; // Pixels between force points
16 const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements
17 const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements
18 const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force
19 const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force
20 const OVERSCAN_PIXELS = 250;
21 const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing
22
23 let globalOpacity = 0;
24
25 if (isStandalone) {
26 canvas.style.backgroundColor = "#301D02";
27 } else {
28 canvas.style.backgroundColor = "transparent";
29 }
30
31 // Interfaces
32 interface Particle {
33 x: number;
34 y: number;
35 vx: number;
36 vy: number;
37 charge: number; // 0 to 1, affecting color
38 }
39
40 interface Force {
41 x: number;
42 y: number;
43 dx: number;
44 dy: number;
45 strength: number;
46 radius: number;
47 createdAt: number;
48 }
49
50 interface SpatialHash {
51 [key: string]: Particle[];
52 }
53
54 // State
55 let first = true;
56 let particles: Particle[] = [];
57 let forces: Force[] = [];
58 let width = canvas.width;
59 let height = canvas.height;
60 let targetParticleCount = 0;
61 let spatialHash: SpatialHash = {};
62 let ctx: CanvasRenderingContext2D | null = null;
63 let animationId: number | null = null;
64 let isRunning = false;
65
66 // Mouse tracking
67 let lastMousePosition: { x: number; y: number } | null = null;
68 // Track position of the last created force
69 let lastForcePosition: { x: number; y: number } | null = null;
70
71 // Keep track of previous canvas dimensions for resize logic
72 let previousWidth = 0;
73 let previousHeight = 0;
74
75 // Initialize and cleanup
76 function init(): void {
77 ctx = canvas.getContext("2d");
78 if (!ctx) return;
79
80 // Set canvas to full size
81 resizeCanvas();
82
83 // Event listeners
84 window.addEventListener("resize", resizeCanvas);
85 document.addEventListener("mousemove", handleMouseMove);
86
87 // Start animation immediately
88 start();
89 }
90
91 function cleanup(): void {
92 // Stop the animation
93 stop();
94
95 // Remove event listeners
96 window.removeEventListener("resize", resizeCanvas);
97 document.removeEventListener("mousemove", handleMouseMove);
98
99 // Clear arrays
100 particles = [];
101 forces = [];
102 spatialHash = {};
103 lastMousePosition = null;
104 lastForcePosition = null;
105 }
106
107 // Resize canvas and adjust particle count
108 function resizeCanvas(): void {
109 // Store previous dimensions
110 previousWidth = width;
111 previousHeight = height;
112
113 // Update to new dimensions
114 width = window.innerWidth;
115 height = window.innerHeight;
116 canvas.width = width;
117 canvas.height = height;
118
119 const oldTargetCount = targetParticleCount;
120 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
121
122 // Adjust particle count
123 if (targetParticleCount > oldTargetCount) {
124 // Add more particles if needed, but only in newly available space
125 addParticles(targetParticleCount - oldTargetCount, !first);
126 first = false;
127 }
128 // Note: Removal of excess particles happens naturally during update
129 }
130
131 // Handle mouse movement
132 function handleMouseMove(e: MouseEvent): void {
133 const rect = canvas.getBoundingClientRect();
134 const currentX = e.clientX - rect.left;
135 const currentY = e.clientY - rect.top;
136
137 // Initialize positions if this is the first movement
138 if (!lastMousePosition || !lastForcePosition) {
139 lastMousePosition = { x: currentX, y: currentY };
140 lastForcePosition = { x: currentX, y: currentY };
141 return;
142 }
143
144 // Store current mouse position
145 const mouseX = currentX;
146 const mouseY = currentY;
147
148 // Calculate vector from last mouse position to current
149 const dx = mouseX - lastMousePosition.x;
150 const dy = mouseY - lastMousePosition.y;
151 const distMoved = Math.sqrt(dx * dx + dy * dy);
152
153 // Skip if essentially no movement (avoids numerical issues)
154 if (distMoved < 0.1) {
155 return;
156 }
157
158 // Get the vector from the last force to the current mouse position
159 const forceDx = mouseX - lastForcePosition.x;
160 const forceDy = mouseY - lastForcePosition.y;
161 const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy);
162
163 // Only create forces if we've moved far enough from the last force
164 if (forceDistance >= FORCE_SPACING) {
165 // Calculate the direction vector from last force to current mouse
166 let dirX = forceDx / forceDistance;
167 let dirY = forceDy / forceDistance;
168
169 // Calculate how many force points to create
170 const numPoints = Math.floor(forceDistance / FORCE_SPACING);
171
172 // Calculate movement speed based on the recent movement
173 const movementSpeed = distMoved; // Simple approximation of speed
174
175 // Scale force strength based on movement speed
176 let speedFactor;
177 if (movementSpeed <= MIN_SPEED_THRESHOLD) {
178 speedFactor = MIN_FORCE_STRENGTH;
179 } else if (movementSpeed >= MAX_SPEED_THRESHOLD) {
180 speedFactor = MAX_FORCE_STRENGTH;
181 } else {
182 // Linear interpolation between min and max
183 const t = (movementSpeed - MIN_SPEED_THRESHOLD) /
184 (MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD);
185 speedFactor = MIN_FORCE_STRENGTH +
186 t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH);
187 }
188
189 // Store current force position to update incrementally
190 let currentForceX = lastForcePosition.x;
191 let currentForceY = lastForcePosition.y;
192
193 // Create evenly spaced force points along the path from last force to current mouse
194 for (let i = 0; i < numPoints; i++) {
195 // Calculate position for this force point
196 const t = (i + 1) / numPoints;
197 const fx = lastForcePosition.x + forceDx * t;
198 const fy = lastForcePosition.y + forceDy * t;
199
200 // Create force at this position with the direction vector
201 createForce(fx, fy, dirX, dirY, speedFactor);
202
203 // Update the last force position to this new force
204 currentForceX = fx;
205 currentForceY = fy;
206 }
207
208 // Update the last force position
209 lastForcePosition = { x: currentForceX, y: currentForceY };
210 }
211
212 // Always update the last mouse position
213 lastMousePosition = { x: mouseX, y: mouseY };
214 }
215
216 // Create a new force
217 function createForce(
218 x: number,
219 y: number,
220 dx: number,
221 dy: number,
222 strength = FORCE_STRENGTH,
223 ): void {
224 forces.push({
225 x,
226 y,
227 dx,
228 dy,
229 strength,
230 radius: 1,
231 createdAt: Date.now(),
232 });
233 }
234
235 // Improved particle addition with fill strategy options
236 function addParticles(count: number, inNewAreaOnly: boolean = false): void {
237 // Determine available space
238 const minX = -OVERSCAN_PIXELS;
239 const maxX = width + OVERSCAN_PIXELS;
240 const minY = -OVERSCAN_PIXELS;
241 const maxY = height + OVERSCAN_PIXELS;
242
243 // Use a grid system that guarantees uniform spacing of particles
244 const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius
245 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
246 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
247
248 // Track which grid cells are already occupied
249 const occupiedCells: Set<string> = new Set();
250
251 // Mark cells occupied by existing particles
252 for (const particle of particles) {
253 const cellX = Math.floor((particle.x - minX) / gridSpacing);
254 const cellY = Math.floor((particle.y - minY) / gridSpacing);
255
256 // Ensure cell coordinates are within valid range
257 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
258 occupiedCells.add(`${cellX},${cellY}`);
259 }
260 }
261
262 // Create arrays of all cells and filter by placement strategy
263 const allGridCells: { x: number; y: number }[] = [];
264
265 for (let cellY = 0; cellY < gridHeight; cellY++) {
266 for (let cellX = 0; cellX < gridWidth; cellX++) {
267 const cellKey = `${cellX},${cellY}`;
268 if (!occupiedCells.has(cellKey)) {
269 const posX = minX + (cellX + 0.5) * gridSpacing;
270 const posY = minY + (cellY + 0.5) * gridSpacing;
271
272 // For new area only placement, filter to expanded areas
273 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
274 const expandedRight = width > previousWidth;
275 const expandedBottom = height > previousHeight;
276
277 const inNewRightArea = expandedRight && posX >= previousWidth &&
278 posX <= width;
279 const inNewBottomArea = expandedBottom && posY >= previousHeight &&
280 posY <= height;
281
282 if (inNewRightArea || inNewBottomArea) {
283 allGridCells.push({ x: cellX, y: cellY });
284 }
285 } else if (!inNewAreaOnly) {
286 // Standard placement - add all valid cells
287 allGridCells.push({ x: cellX, y: cellY });
288 }
289 }
290 }
291 }
292
293 if (allGridCells.length == 0) {
294 throw new Error("No cells available to place particles");
295 }
296
297 // We now have all grid cells that match our placement criteria
298
299 // If we need more particles than we have available cells, we need to adjust
300 // gridSpacing to fit more cells into the same space
301 if (count > allGridCells.length) {
302 // Retry with a smaller grid spacing
303 // Proportionally reduce the grid spacing to fit the required number of particles
304 const scaleFactor = Math.sqrt(allGridCells.length / count);
305 const newGridSpacing = gridSpacing * scaleFactor;
306
307 // Clear particles and try again with new spacing
308 // This is a recursive call, but with adjusted parameters that will fit
309 return addParticlesWithCustomSpacing(
310 count,
311 inNewAreaOnly,
312 newGridSpacing,
313 );
314 }
315
316 // Shuffle the available cells for random selection
317 shuffleArray(allGridCells);
318
319 // Take the number of cells we need
320 const cellsToUse = Math.min(count, allGridCells.length);
321 const selectedCells = allGridCells.slice(0, cellsToUse);
322
323 // Create particles in selected cells
324 for (const cell of selectedCells) {
325 // Add jitter within the cell for natural look
326 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
327 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
328
329 // Calculate final position
330 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
331 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
332
333 // Create a particle at this position
334 particles.push(createParticle(x, y));
335 }
336 }
337
338 // Helper function to add particles with custom grid spacing
339 function addParticlesWithCustomSpacing(
340 count: number,
341 inNewAreaOnly: boolean,
342 gridSpacing: number,
343 ): void {
344 if (gridSpacing == 0) throw new Error("Grid spacing is 0");
345 // Determine available space
346 const minX = -OVERSCAN_PIXELS;
347 const maxX = width + OVERSCAN_PIXELS;
348 const minY = -OVERSCAN_PIXELS;
349 const maxY = height + OVERSCAN_PIXELS;
350
351 // Create grid using the custom spacing
352 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
353 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
354
355 // Track which grid cells are already occupied
356 const occupiedCells: Set<string> = new Set();
357
358 // Mark cells occupied by existing particles
359 for (const particle of particles) {
360 const cellX = Math.floor((particle.x - minX) / gridSpacing);
361 const cellY = Math.floor((particle.y - minY) / gridSpacing);
362
363 // Ensure cell coordinates are within valid range
364 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
365 occupiedCells.add(`${cellX},${cellY}`);
366 }
367 }
368
369 // Create arrays of all cells and filter by placement strategy
370 const allGridCells: { x: number; y: number }[] = [];
371
372 for (let cellY = 0; cellY < gridHeight; cellY++) {
373 for (let cellX = 0; cellX < gridWidth; cellX++) {
374 const cellKey = `${cellX},${cellY}`;
375 if (!occupiedCells.has(cellKey)) {
376 const posX = minX + (cellX + 0.5) * gridSpacing;
377 const posY = minY + (cellY + 0.5) * gridSpacing;
378
379 // For new area only placement, filter to expanded areas
380 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
381 const expandedRight = width > previousWidth;
382 const expandedBottom = height > previousHeight;
383
384 const inNewRightArea = expandedRight && posX >= previousWidth &&
385 posX <= width;
386 const inNewBottomArea = expandedBottom && posY >= previousHeight &&
387 posY <= height;
388
389 if (inNewRightArea || inNewBottomArea) {
390 allGridCells.push({ x: cellX, y: cellY });
391 }
392 } else if (!inNewAreaOnly) {
393 // Standard placement - add all valid cells
394 allGridCells.push({ x: cellX, y: cellY });
395 }
396 }
397 }
398 }
399
400 // Shuffle the available cells for random distribution
401 shuffleArray(allGridCells);
402
403 // Take the number of cells we need (or all if we have fewer)
404 const cellsToUse = Math.min(count, allGridCells.length);
405
406 // Create particles in selected cells
407 for (let i = 0; i < cellsToUse; i++) {
408 const cell = allGridCells[i];
409
410 // Add jitter within the cell
411 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
412 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
413
414 // Calculate final position
415 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
416 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
417
418 // Create a particle at this position
419 particles.push(createParticle(x, y));
420 }
421 }
422
423 // Utility to shuffle an array (Fisher-Yates algorithm)
424 function shuffleArray<T>(array: T[]): void {
425 for (let i = array.length - 1; i > 0; i--) {
426 const j = Math.floor(Math.random() * (i + 1));
427 [array[i], array[j]] = [array[j], array[i]];
428 }
429 }
430
431 // Simplified createParticle function that just places at a specific position
432 function createParticle(x: number, y: number): Particle {
433 return {
434 x: x + (Math.random() * 4 - 2),
435 y: y + (Math.random() * 4 - 2),
436 vx: 0,
437 vy: 0,
438 charge: 0,
439 };
440 }
441
442 // Function to create a particle on one of the edges
443 function createParticleOnEdge(): Particle {
444 // Overscan bounds with fixed pixel size
445 const minX = -OVERSCAN_PIXELS;
446 const maxX = width + OVERSCAN_PIXELS;
447 const minY = -OVERSCAN_PIXELS;
448 const maxY = height + OVERSCAN_PIXELS;
449
450 let x: number, y: number;
451
452 // Place on one of the edges
453 const edge = Math.floor(Math.random() * 4);
454 switch (edge) {
455 case 0: // Top
456 x = minX + Math.random() * (maxX - minX);
457 y = minY;
458 break;
459 case 1: // Right
460 x = maxX;
461 y = minY + Math.random() * (maxY - minY);
462 break;
463 case 2: // Bottom
464 x = minX + Math.random() * (maxX - minX);
465 y = maxY;
466 break;
467 case 3: // Left
468 x = minX;
469 y = minY + Math.random() * (maxY - minY);
470 break;
471 default:
472 x = minX + Math.random() * (maxX - minX);
473 y = minY + Math.random() * (maxY - minY);
474 }
475
476 return createParticle(x, y);
477 }
478
479 // Spatial hashing functions
480 function getHashKey(x: number, y: number): string {
481 const cellX = Math.floor(x / CELL_SIZE);
482 const cellY = Math.floor(y / CELL_SIZE);
483 return `${cellX},${cellY}`;
484 }
485
486 function addToSpatialHash(particle: Particle): void {
487 const key = getHashKey(particle.x, particle.y);
488 if (!spatialHash[key]) {
489 spatialHash[key] = [];
490 }
491 spatialHash[key].push(particle);
492 }
493
494 function updateSpatialHash(): void {
495 // Clear previous hash
496 spatialHash = {};
497
498 // Add all particles to hash
499 for (const particle of particles) {
500 addToSpatialHash(particle);
501 }
502 }
503
504 function getNearbyParticles(
505 x: number,
506 y: number,
507 radius: number,
508 ): Particle[] {
509 const result: Particle[] = [];
510 const cellRadius = Math.ceil(radius / CELL_SIZE);
511
512 const centerCellX = Math.floor(x / CELL_SIZE);
513 const centerCellY = Math.floor(y / CELL_SIZE);
514
515 for (
516 let cellX = centerCellX - cellRadius;
517 cellX <= centerCellX + cellRadius;
518 cellX++
519 ) {
520 for (
521 let cellY = centerCellY - cellRadius;
522 cellY <= centerCellY + cellRadius;
523 cellY++
524 ) {
525 const key = `${cellX},${cellY}`;
526 const cell = spatialHash[key];
527
528 if (cell) {
529 result.push(...cell);
530 }
531 }
532 }
533
534 return result;
535 }
536
537 // Main update function
538 function update(): void {
539 const now = Date.now();
540 // Fixed pixel overscan
541 const minX = -OVERSCAN_PIXELS;
542 const maxX = width + OVERSCAN_PIXELS;
543 const minY = -OVERSCAN_PIXELS;
544 const maxY = height + OVERSCAN_PIXELS;
545
546 // Update spatial hash
547 updateSpatialHash();
548
549 // Update forces and remove expired ones
550 if (forces.length > 40) {
551 forces = forces.slice(-40);
552 }
553 forces = forces.filter((force) => {
554 force.strength *= 0.95;
555 force.radius *= 0.95;
556 return force.strength > 0.001;
557 });
558
559 // Update particles
560 const newParticles: Particle[] = [];
561
562 for (const particle of particles) {
563 // Apply forces
564 for (const force of forces) {
565 const dx = particle.x - force.x;
566 const dy = particle.y - force.y;
567 const distSq = dx * dx + dy * dy;
568
569 const radius = force.radius * FORCE_RADIUS;
570
571 if (distSq < radius * radius) {
572 const dist = Math.sqrt(distSq);
573
574 // Exponential falloff - much more concentrated at center
575 // (1 - x/R)^n where n controls how sharp the falloff is
576 const normalizedDist = dist / radius;
577 const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
578
579 // Calculate force line projection for directional effect
580 // This makes particles along the force's path experience stronger effect
581 const dotProduct = (dx * -force.dx) + (dy * -force.dy);
582 const projectionFactor = Math.max(0, dotProduct / dist);
583
584 // Apply the combined factors - stronger directional bias
585 const finalFactor = factor * force.strength *
586 (0.1 + 0.9 * projectionFactor);
587
588 particle.vx += force.dx * finalFactor;
589 particle.vy += force.dy * finalFactor;
590 // charge for the first 100ms
591 if ((now - force.createdAt) < 100) {
592 particle.charge = Math.min(
593 1,
594 particle.charge + (finalFactor * finalFactor) * 0.2,
595 );
596 }
597 }
598 }
599
600 // Apply repulsion from nearby particles
601 const nearby = getNearbyParticles(
602 particle.x,
603 particle.y,
604 REPULSION_RADIUS,
605 );
606
607 for (const other of nearby) {
608 if (other === particle) continue;
609
610 const dx = particle.x - other.x;
611 const dy = particle.y - other.y;
612 const distSq = dx * dx + dy * dy;
613
614 if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) {
615 const dist = Math.sqrt(distSq);
616 const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS);
617
618 const fx = dx / dist * factor;
619 const fy = dy / dist * factor;
620
621 particle.vx += fx;
622 particle.vy += fy;
623 }
624 }
625
626 // Apply friction
627 particle.vx *= FRICTION;
628 particle.vy *= FRICTION;
629
630 // Ensure minimum speed
631 const speed = Math.sqrt(
632 particle.vx * particle.vx + particle.vy * particle.vy,
633 );
634 if (speed < MIN_SPEED && speed > 0) {
635 const scale = MIN_SPEED / speed;
636 particle.vx *= scale;
637 particle.vy *= scale;
638 }
639
640 // Cap at maximum speed
641 if (speed > MAX_SPEED) {
642 const scale = MAX_SPEED / speed;
643 particle.vx *= scale;
644 particle.vy *= scale;
645 }
646
647 // Update position
648 particle.x += particle.vx;
649 particle.y += particle.vy;
650
651 // Decrease charge
652 particle.charge *= 0.99;
653
654 // Check if particle is within extended bounds
655 if (
656 particle.x >= minX && particle.x <= maxX &&
657 particle.y >= minY && particle.y <= maxY
658 ) {
659 // If outside screen but within overscan, keep it if we need more particles
660 if (
661 (particle.x < 0 || particle.x > width ||
662 particle.y < 0 || particle.y > height) &&
663 newParticles.length >= targetParticleCount
664 ) {
665 continue;
666 }
667
668 newParticles.push(particle);
669 } else {
670 // Out of bounds, respawn if needed
671 if (newParticles.length < targetParticleCount) {
672 newParticles.push(createParticleOnEdge());
673 }
674 }
675 }
676
677 // Add more particles if needed
678 while (newParticles.length < targetParticleCount) {
679 newParticles.push(createParticleOnEdge());
680 }
681
682 particles = newParticles;
683 }
684
685 // Render function
686 const mul = isStandalone ? 0.9 : 0.5;
687 const add = isStandalone ? 0.1 : 0.03;
688 function render(): void {
689 if (!ctx) return;
690
691 // Clear canvas
692 ctx.clearRect(0, 0, width, height);
693
694 // Draw particles
695 for (const particle of particles) {
696 // Only draw if within canvas bounds (plus a small margin)
697 if (
698 particle.x >= -PARTICLE_RADIUS &&
699 particle.x <= width + PARTICLE_RADIUS &&
700 particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS
701 ) {
702 ctx.beginPath();
703 ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2);
704
705 // Color based on charge
706 ctx.fillStyle = "#FFCB1F";
707 ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity;
708 ctx.fill();
709 }
710 }
711
712 // // Debug: Draw forces and falloff visualization
713 // if (ctx) {
714 // for (const force of forces) {
715 // const R = force.radius * FORCE_RADIUS;
716
717 // // Draw force point
718 // ctx.beginPath();
719 // ctx.arc(force.x, force.y, 5, 0, Math.PI * 2);
720 // ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
721 // ctx.fill();
722
723 // // Draw force direction
724 // ctx.beginPath();
725 // ctx.moveTo(force.x, force.y);
726 // ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20);
727 // ctx.strokeStyle = 'red';
728 // ctx.stroke();
729
730 // // Visualize the falloff curve with rings
731 // for (let i = 0; i <= 10; i++) {
732 // const radius = (R * i) / 10;
733 // const normalizedDist = radius / R;
734 // const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
735
736 // ctx.beginPath();
737 // ctx.arc(force.x, force.y, radius, 0, Math.PI * 2);
738 // ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`;
739 // ctx.stroke();
740 // }
741 // }
742 // }
743 }
744
745 // Animation loop
746 let r = Math.random();
747 function animate(): void {
748 globalOpacity = Math.min(1, globalOpacity + 0.03);
749 update();
750 render();
751
752 if (isRunning) {
753 animationId = requestAnimationFrame(animate);
754 }
755 }
756
757 // Start/stop functions
758 function start(): void {
759 if (isRunning) return;
760
761 // Calculate target particle count based on canvas size
762 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
763
764 // Clear any existing particles and create new ones with proper spacing
765 particles = [];
766 addParticles(targetParticleCount);
767
768 isRunning = true;
769 animate();
770 }
771
772 function stop(): void {
773 isRunning = false;
774
775 if (animationId !== null) {
776 cancelAnimationFrame(animationId);
777 animationId = null;
778 }
779 }
780
781 init();
782 return cleanup;
783};
src/file-viewer/scripts/canvas_2022.client.ts created+161
...@@ -0,0 +1,161 @@
1// Written by AI. Options tuned with AI.
2(globalThis as any).canvas_2022 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 // Configuration for the grid of rotating squares
5 const config = {
6 gridRotation: 20, // Overall grid rotation in degrees
7 squareSize: 20, // Size of each square
8 spacing: 100, // Distance between square centers
9 moveSpeedX: 0.01, // Horizontal movement speed (pixels per second)
10 moveSpeedY: 0.01, // Vertical movement speed (pixels per second)
11 squareColor: "#00220A", // Color of the squares
12 squareOpacity: 1, // Opacity of the squares
13
14 // Function to determine square rotation based on its coordinates and time
15 // Can be adjusted for different patterns
16 rotationFunction: (x: number, y: number, time: number): number => {
17 // Combination of spatial wave and time-based rotation
18 return Math.sin(x * 0.05) * Math.cos(y * 0.05) * 180;
19 },
20 };
21
22 // Convert grid rotation to radians
23 const gridRotationRad = (config.gridRotation * Math.PI) / 180;
24
25 // Get the canvas context
26 const ctx = canvas.getContext("2d");
27 if (!ctx) {
28 console.error("Could not get canvas context");
29 return () => {};
30 }
31
32 // Make canvas transparent
33 if (isStandalone) {
34 canvas.style.backgroundColor = "#154226";
35 } else {
36 canvas.style.backgroundColor = "transparent";
37 }
38
39 // Animation variables
40 let width = canvas.width;
41 let height = canvas.height;
42 let offsetX = 0;
43 let offsetY = 0;
44 let time = 0;
45 let animationFrameId: number;
46 let lastTime = 0;
47
48 // Update canvas dimensions when resized
49 const updateDimensions = () => {
50 width = canvas.width = canvas.clientWidth;
51 height = canvas.height = canvas.clientHeight;
52 };
53
54 // Calculate the diagonal length of the canvas (to ensure rotation covers corners)
55 const calculateDiagonal = () => {
56 return Math.sqrt(width * width + height * height);
57 };
58
59 // Draw a single square with rotation
60 const drawSquare = (x: number, y: number, size: number, rotation: number) => {
61 ctx.save();
62
63 // Move to the center of the square position, rotate, then draw
64 ctx.translate(x, y);
65 ctx.rotate((rotation * Math.PI) / 180); // Convert rotation degrees to radians
66
67 // Draw square centered at position
68 ctx.fillRect(-size / 2, -size / 2, size, size);
69
70 ctx.restore();
71 };
72
73 // Draw the entire grid of squares
74 const drawGrid = () => {
75 ctx.clearRect(0, 0, width, height);
76
77 // Set drawing properties
78 ctx.fillStyle = config.squareColor;
79 ctx.globalAlpha = config.squareOpacity;
80
81 // Save the current transformation state
82 ctx.save();
83
84 // Move to the center of the canvas, rotate the grid, then move back
85 const centerX = width / 2;
86 const centerY = height / 2;
87 ctx.translate(centerX, centerY);
88 ctx.rotate(gridRotationRad);
89
90 // Calculate how much of the grid to draw based on canvas size
91 const diagonal = calculateDiagonal();
92 const gridSize = Math.ceil(diagonal / config.spacing) + 2;
93
94 // Adjust for offset to create movement
95 const adjustedOffsetX = offsetX % config.spacing;
96 const adjustedOffsetY = offsetY % config.spacing;
97
98 // Draw grid with enough squares to cover the rotated canvas
99 const halfGrid = Math.ceil(gridSize / 2);
100
101 for (let y = -halfGrid; y <= halfGrid; y++) {
102 for (let x = -halfGrid; x <= halfGrid; x++) {
103 // Calculate actual position with offset
104 const posX = x * config.spacing + adjustedOffsetX;
105 const posY = y * config.spacing + adjustedOffsetY;
106
107 // Calculate square rotation based on its position and time
108 const squareRotation = config.rotationFunction(posX, posY, time);
109
110 // Draw the square
111 drawSquare(posX, posY, config.squareSize, squareRotation);
112 }
113 }
114
115 // Restore the transformation state
116 ctx.restore();
117
118 // Reset global alpha
119 ctx.globalAlpha = 1.0;
120 };
121
122 // Animation loop
123 const animate = (currentTime: number) => {
124 animationFrameId = requestAnimationFrame(animate);
125
126 // Calculate time elapsed since last frame
127 const elapsed = currentTime - lastTime;
128 lastTime = currentTime;
129
130 // Update time variable for rotation function
131 time += elapsed;
132
133 // Update position offsets for movement
134 offsetX += config.moveSpeedX * elapsed;
135 offsetY += config.moveSpeedY * elapsed;
136
137 // Draw the grid
138 drawGrid();
139 };
140
141 // Initialize the animation
142 const init = () => {
143 // Set up resize handler
144 window.addEventListener("resize", updateDimensions);
145
146 // Initial setup
147 updateDimensions();
148
149 // Start animation
150 animationFrameId = requestAnimationFrame(animate);
151 };
152
153 // Start the animation
154 init();
155
156 // Return cleanup function
157 return () => {
158 window.removeEventListener("resize", updateDimensions);
159 cancelAnimationFrame(animationFrameId);
160 };
161};
src/file-viewer/scripts/canvas_2022.ts deleted-160
...@@ -1,160 +0,0 @@
1(globalThis as any).canvas_2022 = function (canvas: HTMLCanvasElement) {
2 const isStandalone = canvas.getAttribute("data-standalone") === "true";
3 // Configuration for the grid of rotating squares
4 const config = {
5 gridRotation: 20, // Overall grid rotation in degrees
6 squareSize: 20, // Size of each square
7 spacing: 100, // Distance between square centers
8 moveSpeedX: 0.01, // Horizontal movement speed (pixels per second)
9 moveSpeedY: 0.01, // Vertical movement speed (pixels per second)
10 squareColor: "#00220A", // Color of the squares
11 squareOpacity: 1, // Opacity of the squares
12
13 // Function to determine square rotation based on its coordinates and time
14 // Can be adjusted for different patterns
15 rotationFunction: (x: number, y: number, time: number): number => {
16 // Combination of spatial wave and time-based rotation
17 return Math.sin(x * 0.05) * Math.cos(y * 0.05) * 180;
18 },
19 };
20
21 // Convert grid rotation to radians
22 const gridRotationRad = (config.gridRotation * Math.PI) / 180;
23
24 // Get the canvas context
25 const ctx = canvas.getContext("2d");
26 if (!ctx) {
27 console.error("Could not get canvas context");
28 return () => {};
29 }
30
31 // Make canvas transparent
32 if (isStandalone) {
33 canvas.style.backgroundColor = "#154226";
34 } else {
35 canvas.style.backgroundColor = "transparent";
36 }
37
38 // Animation variables
39 let width = canvas.width;
40 let height = canvas.height;
41 let offsetX = 0;
42 let offsetY = 0;
43 let time = 0;
44 let animationFrameId: number;
45 let lastTime = 0;
46
47 // Update canvas dimensions when resized
48 const updateDimensions = () => {
49 width = canvas.width = canvas.clientWidth;
50 height = canvas.height = canvas.clientHeight;
51 };
52
53 // Calculate the diagonal length of the canvas (to ensure rotation covers corners)
54 const calculateDiagonal = () => {
55 return Math.sqrt(width * width + height * height);
56 };
57
58 // Draw a single square with rotation
59 const drawSquare = (x: number, y: number, size: number, rotation: number) => {
60 ctx.save();
61
62 // Move to the center of the square position, rotate, then draw
63 ctx.translate(x, y);
64 ctx.rotate((rotation * Math.PI) / 180); // Convert rotation degrees to radians
65
66 // Draw square centered at position
67 ctx.fillRect(-size / 2, -size / 2, size, size);
68
69 ctx.restore();
70 };
71
72 // Draw the entire grid of squares
73 const drawGrid = () => {
74 ctx.clearRect(0, 0, width, height);
75
76 // Set drawing properties
77 ctx.fillStyle = config.squareColor;
78 ctx.globalAlpha = config.squareOpacity;
79
80 // Save the current transformation state
81 ctx.save();
82
83 // Move to the center of the canvas, rotate the grid, then move back
84 const centerX = width / 2;
85 const centerY = height / 2;
86 ctx.translate(centerX, centerY);
87 ctx.rotate(gridRotationRad);
88
89 // Calculate how much of the grid to draw based on canvas size
90 const diagonal = calculateDiagonal();
91 const gridSize = Math.ceil(diagonal / config.spacing) + 2;
92
93 // Adjust for offset to create movement
94 const adjustedOffsetX = offsetX % config.spacing;
95 const adjustedOffsetY = offsetY % config.spacing;
96
97 // Draw grid with enough squares to cover the rotated canvas
98 const halfGrid = Math.ceil(gridSize / 2);
99
100 for (let y = -halfGrid; y <= halfGrid; y++) {
101 for (let x = -halfGrid; x <= halfGrid; x++) {
102 // Calculate actual position with offset
103 const posX = x * config.spacing + adjustedOffsetX;
104 const posY = y * config.spacing + adjustedOffsetY;
105
106 // Calculate square rotation based on its position and time
107 const squareRotation = config.rotationFunction(posX, posY, time);
108
109 // Draw the square
110 drawSquare(posX, posY, config.squareSize, squareRotation);
111 }
112 }
113
114 // Restore the transformation state
115 ctx.restore();
116
117 // Reset global alpha
118 ctx.globalAlpha = 1.0;
119 };
120
121 // Animation loop
122 const animate = (currentTime: number) => {
123 animationFrameId = requestAnimationFrame(animate);
124
125 // Calculate time elapsed since last frame
126 const elapsed = currentTime - lastTime;
127 lastTime = currentTime;
128
129 // Update time variable for rotation function
130 time += elapsed;
131
132 // Update position offsets for movement
133 offsetX += config.moveSpeedX * elapsed;
134 offsetY += config.moveSpeedY * elapsed;
135
136 // Draw the grid
137 drawGrid();
138 };
139
140 // Initialize the animation
141 const init = () => {
142 // Set up resize handler
143 window.addEventListener("resize", updateDimensions);
144
145 // Initial setup
146 updateDimensions();
147
148 // Start animation
149 animationFrameId = requestAnimationFrame(animate);
150 };
151
152 // Start the animation
153 init();
154
155 // Return cleanup function
156 return () => {
157 window.removeEventListener("resize", updateDimensions);
158 cancelAnimationFrame(animationFrameId);
159 };
160};
src/file-viewer/scripts/canvas_2023.client.ts created+199
...@@ -0,0 +1,199 @@
1// Partially vibe coded. A lot of manually tuning with the heart scale function
2// and how the mouse interacts with the hearts.
3(globalThis as any).canvas_2023 = function (canvas: HTMLCanvasElement) {
4 const isStandalone = canvas.getAttribute("data-standalone") === "true";
5 const config = {
6 heartBaseSize: 50,
7 heartMaxSize: 100,
8 spacing: 150,
9 rowSpeed: 0.1,
10 heartColor: "#FF90D9",
11 heartOpacity: isStandalone ? 0.5 : 0.04,
12 mouseInfluenceRadius: 1000,
13 heartScaleFunction: (distance: number, radius: number): number => {
14 if (distance > radius) return 1;
15
16 const normalizedDistance = distance / radius;
17 const scaleFactor = 1 +
18 (1 - normalizedDistance) *
19 (config.heartMaxSize / config.heartBaseSize - 1);
20
21 return 1 + (scaleFactor - 1) * Math.pow(1 - normalizedDistance, 2);
22 },
23 };
24
25 const heart = new Path2D(
26 "M23.9451 45.3973L20.8672 42.6493C16.9551 39.0174 13.7054 35.8927 11.1181 33.275C8.53056 30.6574 6.46731 28.286 4.92839 26.1608C3.38946 24.0356 2.31772 22.1028 1.71314 20.3624C1.10856 18.6219 0.806274 16.8705 0.806274 15.1081C0.806274 11.4718 2.03118 8.42016 4.481 5.95312C6.93118 3.48608 9.93831 2.25256 13.5024 2.25256C15.5649 2.25256 17.482 2.70142 19.2536 3.59912C21.0255 4.49682 22.5893 5.80674 23.9451 7.52887C25.484 5.73346 27.1059 4.40522 28.8108 3.54416C30.5161 2.6831 32.3751 2.25256 34.3877 2.25256C38.0141 2.25256 41.0551 3.48663 43.5108 5.95477C45.9661 8.42291 47.1938 11.4758 47.1938 15.1136C47.1938 16.8712 46.8823 18.6115 46.2594 20.3343C45.6365 22.0568 44.5648 23.9807 43.0442 26.1059C41.5236 28.231 39.4721 30.6136 36.8896 33.2536C34.3068 35.8936 31.0362 39.0255 27.0779 42.6493L23.9451 45.3973ZM23.9176 38.802C27.6088 35.431 30.6339 32.5547 32.9928 30.173C35.3518 27.7913 37.2091 25.7211 38.5648 23.9624C39.9205 22.2036 40.864 20.6137 41.3953 19.1928C41.9266 17.7715 42.1923 16.4101 42.1923 15.1086C42.1923 12.8768 41.4529 11.0098 39.974 9.50748C38.4952 8.0052 36.6461 7.25406 34.4268 7.25406C32.631 7.25406 30.9572 7.6811 29.4055 8.87193C27.8537 10.0628 25.5389 13.0434 25.5389 13.0434L23.9451 15.3299L22.3512 13.0434C22.3512 13.0434 20.0643 10.2311 18.4638 9.04031C16.8634 7.84948 15.2194 7.25406 13.4991 7.25406C11.2929 7.25406 9.46857 7.98816 8.02602 9.45637C6.58383 10.9246 5.86273 12.8162 5.86273 15.1311C5.86273 16.4784 6.13644 17.8679 6.68386 19.2994C7.23127 20.731 8.18394 22.3333 9.54185 24.1064C10.8998 25.879 12.7329 27.9562 15.0413 30.3379C17.3497 32.7196 20.3084 35.5409 23.9176 38.802Z",
27 );
28
29 const ctx = canvas.getContext("2d");
30 if (!ctx) {
31 console.error("Could not get canvas context");
32 return () => {};
33 }
34
35 if (isStandalone) {
36 canvas.style.backgroundColor = "#2F1C21";
37 } else {
38 canvas.style.backgroundColor = "transparent";
39 }
40
41 let width = canvas.width;
42 let height = canvas.height;
43 let animationFrameId: number;
44 let lastFrameTime = 0;
45
46 let mouseX = width / 2;
47 let mouseY = height / 2;
48
49 let offset = config.spacing / 2;
50
51 const updateDimensions = () => {
52 width = canvas.width = canvas.clientWidth;
53 height = canvas.height = canvas.clientHeight;
54
55 mouseX = width / 2;
56 mouseY = height / 2;
57 };
58
59 const drawHeart = (x: number, y: number, size: number) => {
60 const scale = size / 30;
61
62 ctx.save();
63 ctx.translate(x, y);
64 ctx.scale(scale, scale);
65
66 ctx.fillStyle = config.heartColor;
67 ctx.fill(heart);
68
69 ctx.restore();
70 };
71
72 const c = 400;
73 const h = 40;
74 const k = solveForK(c, h);
75
76 const drawHeartGrid = () => {
77 ctx.clearRect(0, 0, width, height);
78
79 ctx.globalAlpha = config.heartOpacity;
80
81 const numRows = Math.ceil(height / config.spacing) + 1;
82
83 for (let row = 0; row < numRows; row++) {
84 const direction = row % 2 === 0 ? 1 : -1;
85 const rowOffset = (offset * direction) % config.spacing;
86
87 const posYInit = row * config.spacing + config.spacing / 2;
88
89 for (
90 let posXInit = -config.spacing + rowOffset;
91 posXInit < width + config.spacing;
92 posXInit += config.spacing
93 ) {
94 const dx = (posXInit + config.heartBaseSize / 2) - mouseX;
95 const dy = (posYInit + config.heartBaseSize / 2) - mouseY;
96 const distance = Math.sqrt(dx * dx + dy * dy);
97
98 const pushIntensity = asymmetricBump(distance, h, c, k, 0.00002);
99
100 const pushAngle = Math.atan2(dy, dx);
101
102 const pushDistanceX = pushIntensity * Math.cos(pushAngle);
103 const pushDistanceY = pushIntensity * Math.sin(pushAngle);
104 const posX = posXInit + pushDistanceX * 1;
105 const posY = posYInit + pushDistanceY * 2;
106
107 const scaleFactor = config.heartScaleFunction(
108 distance,
109 config.mouseInfluenceRadius,
110 );
111 const heartSize = config.heartBaseSize * scaleFactor;
112
113 if (
114 posX > -config.heartMaxSize &&
115 posX < width + config.heartMaxSize &&
116 posY > -config.heartMaxSize &&
117 posY < height + config.heartMaxSize
118 ) {
119 drawHeart(posX - heartSize / 2, posY - heartSize / 2, heartSize);
120 }
121 }
122 }
123
124 ctx.globalAlpha = 1.0;
125 };
126
127 function solveForK(c: number, k: number) {
128 // input -> f(x)=h*e^{(-k*(x-c)^{2})}
129 // desired result is (0, 0.45). (0, 0) is unsolvable but 0.45px will round down to 0.
130 //
131 // solution: -\frac{\ln\left(\frac{0.45}{h}\right)}{c^{2}}
132 return -Math.log(0.45 / h) / (c * c);
133 }
134
135 function asymmetricBump(
136 x: number,
137 h: number,
138 c: number,
139 leftK: number,
140 rightK: number,
141 ) {
142 const k = (x <= c) ? leftK : rightK;
143 return h * Math.exp(-k * Math.pow(x - c, 2));
144 }
145
146 const updateOffset = (elapsed: number) => {
147 offset += config.rowSpeed * elapsed;
148
149 if (offset > 1000000) {
150 offset -= 1000000;
151 }
152 };
153
154 const animate = (currentTime: number) => {
155 animationFrameId = requestAnimationFrame(animate);
156
157 const elapsed = currentTime - lastFrameTime;
158 lastFrameTime = currentTime;
159
160 updateOffset(elapsed * 0.05);
161
162 drawHeartGrid();
163 };
164
165 const handleMouseMove = (event: MouseEvent) => {
166 const rect = canvas.getBoundingClientRect();
167 mouseX = event.clientX - rect.left;
168 mouseY = event.clientY - rect.top;
169 };
170
171 const handleTouchMove = (event: TouchEvent) => {
172 if (event.touches.length > 0) {
173 event.preventDefault();
174 const rect = canvas.getBoundingClientRect();
175 mouseX = event.touches[0].clientX - rect.left;
176 mouseY = event.touches[0].clientY - rect.top;
177 }
178 };
179
180 const init = () => {
181 window.addEventListener("resize", updateDimensions);
182 document.addEventListener("mousemove", handleMouseMove);
183 document.addEventListener("touchmove", handleTouchMove, { passive: false });
184
185 updateDimensions();
186
187 lastFrameTime = performance.now();
188 animationFrameId = requestAnimationFrame(animate);
189 };
190
191 init();
192
193 return () => {
194 window.removeEventListener("resize", updateDimensions);
195 document.removeEventListener("mousemove", handleMouseMove);
196 document.removeEventListener("touchmove", handleTouchMove);
197 cancelAnimationFrame(animationFrameId);
198 };
199};
src/file-viewer/scripts/canvas_2023.ts deleted-197
...@@ -1,197 +0,0 @@
1(globalThis as any).canvas_2023 = function (canvas: HTMLCanvasElement) {
2 const isStandalone = canvas.getAttribute("data-standalone") === "true";
3 const config = {
4 heartBaseSize: 50,
5 heartMaxSize: 100,
6 spacing: 150,
7 rowSpeed: 0.1,
8 heartColor: "#FF90D9",
9 heartOpacity: isStandalone ? 0.5 : 0.04,
10 mouseInfluenceRadius: 1000,
11 heartScaleFunction: (distance: number, radius: number): number => {
12 if (distance > radius) return 1;
13
14 const normalizedDistance = distance / radius;
15 const scaleFactor = 1 +
16 (1 - normalizedDistance) *
17 (config.heartMaxSize / config.heartBaseSize - 1);
18
19 return 1 + (scaleFactor - 1) * Math.pow(1 - normalizedDistance, 2);
20 },
21 };
22
23 const heart = new Path2D(
24 "M23.9451 45.3973L20.8672 42.6493C16.9551 39.0174 13.7054 35.8927 11.1181 33.275C8.53056 30.6574 6.46731 28.286 4.92839 26.1608C3.38946 24.0356 2.31772 22.1028 1.71314 20.3624C1.10856 18.6219 0.806274 16.8705 0.806274 15.1081C0.806274 11.4718 2.03118 8.42016 4.481 5.95312C6.93118 3.48608 9.93831 2.25256 13.5024 2.25256C15.5649 2.25256 17.482 2.70142 19.2536 3.59912C21.0255 4.49682 22.5893 5.80674 23.9451 7.52887C25.484 5.73346 27.1059 4.40522 28.8108 3.54416C30.5161 2.6831 32.3751 2.25256 34.3877 2.25256C38.0141 2.25256 41.0551 3.48663 43.5108 5.95477C45.9661 8.42291 47.1938 11.4758 47.1938 15.1136C47.1938 16.8712 46.8823 18.6115 46.2594 20.3343C45.6365 22.0568 44.5648 23.9807 43.0442 26.1059C41.5236 28.231 39.4721 30.6136 36.8896 33.2536C34.3068 35.8936 31.0362 39.0255 27.0779 42.6493L23.9451 45.3973ZM23.9176 38.802C27.6088 35.431 30.6339 32.5547 32.9928 30.173C35.3518 27.7913 37.2091 25.7211 38.5648 23.9624C39.9205 22.2036 40.864 20.6137 41.3953 19.1928C41.9266 17.7715 42.1923 16.4101 42.1923 15.1086C42.1923 12.8768 41.4529 11.0098 39.974 9.50748C38.4952 8.0052 36.6461 7.25406 34.4268 7.25406C32.631 7.25406 30.9572 7.6811 29.4055 8.87193C27.8537 10.0628 25.5389 13.0434 25.5389 13.0434L23.9451 15.3299L22.3512 13.0434C22.3512 13.0434 20.0643 10.2311 18.4638 9.04031C16.8634 7.84948 15.2194 7.25406 13.4991 7.25406C11.2929 7.25406 9.46857 7.98816 8.02602 9.45637C6.58383 10.9246 5.86273 12.8162 5.86273 15.1311C5.86273 16.4784 6.13644 17.8679 6.68386 19.2994C7.23127 20.731 8.18394 22.3333 9.54185 24.1064C10.8998 25.879 12.7329 27.9562 15.0413 30.3379C17.3497 32.7196 20.3084 35.5409 23.9176 38.802Z",
25 );
26
27 const ctx = canvas.getContext("2d");
28 if (!ctx) {
29 console.error("Could not get canvas context");
30 return () => {};
31 }
32
33 if (isStandalone) {
34 canvas.style.backgroundColor = "#2F1C21";
35 } else {
36 canvas.style.backgroundColor = "transparent";
37 }
38
39 let width = canvas.width;
40 let height = canvas.height;
41 let animationFrameId: number;
42 let lastFrameTime = 0;
43
44 let mouseX = width / 2;
45 let mouseY = height / 2;
46
47 let offset = config.spacing / 2;
48
49 const updateDimensions = () => {
50 width = canvas.width = canvas.clientWidth;
51 height = canvas.height = canvas.clientHeight;
52
53 mouseX = width / 2;
54 mouseY = height / 2;
55 };
56
57 const drawHeart = (x: number, y: number, size: number) => {
58 const scale = size / 30;
59
60 ctx.save();
61 ctx.translate(x, y);
62 ctx.scale(scale, scale);
63
64 ctx.fillStyle = config.heartColor;
65 ctx.fill(heart);
66
67 ctx.restore();
68 };
69
70 const c = 400;
71 const h = 40;
72 const k = solveForK(c, h);
73
74 const drawHeartGrid = () => {
75 ctx.clearRect(0, 0, width, height);
76
77 ctx.globalAlpha = config.heartOpacity;
78
79 const numRows = Math.ceil(height / config.spacing) + 1;
80
81 for (let row = 0; row < numRows; row++) {
82 const direction = row % 2 === 0 ? 1 : -1;
83 const rowOffset = (offset * direction) % config.spacing;
84
85 const posYInit = row * config.spacing + config.spacing / 2;
86
87 for (
88 let posXInit = -config.spacing + rowOffset;
89 posXInit < width + config.spacing;
90 posXInit += config.spacing
91 ) {
92 const dx = (posXInit + config.heartBaseSize / 2) - mouseX;
93 const dy = (posYInit + config.heartBaseSize / 2) - mouseY;
94 const distance = Math.sqrt(dx * dx + dy * dy);
95
96 const pushIntensity = asymmetricBump(distance, h, c, k, 0.00002);
97
98 const pushAngle = Math.atan2(dy, dx);
99
100 const pushDistanceX = pushIntensity * Math.cos(pushAngle);
101 const pushDistanceY = pushIntensity * Math.sin(pushAngle);
102 const posX = posXInit + pushDistanceX * 1;
103 const posY = posYInit + pushDistanceY * 2;
104
105 const scaleFactor = config.heartScaleFunction(
106 distance,
107 config.mouseInfluenceRadius,
108 );
109 const heartSize = config.heartBaseSize * scaleFactor;
110
111 if (
112 posX > -config.heartMaxSize &&
113 posX < width + config.heartMaxSize &&
114 posY > -config.heartMaxSize &&
115 posY < height + config.heartMaxSize
116 ) {
117 drawHeart(posX - heartSize / 2, posY - heartSize / 2, heartSize);
118 }
119 }
120 }
121
122 ctx.globalAlpha = 1.0;
123 };
124
125 function solveForK(c: number, k: number) {
126 // input -> f(x)=h*e^{(-k*(x-c)^{2})}
127 // desired result is (0, 0.45). (0, 0) is unsolvable but 0.45px will round down to 0.
128 //
129 // solution: -\frac{\ln\left(\frac{0.45}{h}\right)}{c^{2}}
130 return -Math.log(0.45 / h) / (c * c);
131 }
132
133 function asymmetricBump(
134 x: number,
135 h: number,
136 c: number,
137 leftK: number,
138 rightK: number,
139 ) {
140 const k = (x <= c) ? leftK : rightK;
141 return h * Math.exp(-k * Math.pow(x - c, 2));
142 }
143
144 const updateOffset = (elapsed: number) => {
145 offset += config.rowSpeed * elapsed;
146
147 if (offset > 1000000) {
148 offset -= 1000000;
149 }
150 };
151
152 const animate = (currentTime: number) => {
153 animationFrameId = requestAnimationFrame(animate);
154
155 const elapsed = currentTime - lastFrameTime;
156 lastFrameTime = currentTime;
157
158 updateOffset(elapsed * 0.05);
159
160 drawHeartGrid();
161 };
162
163 const handleMouseMove = (event: MouseEvent) => {
164 const rect = canvas.getBoundingClientRect();
165 mouseX = event.clientX - rect.left;
166 mouseY = event.clientY - rect.top;
167 };
168
169 const handleTouchMove = (event: TouchEvent) => {
170 if (event.touches.length > 0) {
171 event.preventDefault();
172 const rect = canvas.getBoundingClientRect();
173 mouseX = event.touches[0].clientX - rect.left;
174 mouseY = event.touches[0].clientY - rect.top;
175 }
176 };
177
178 const init = () => {
179 window.addEventListener("resize", updateDimensions);
180 document.addEventListener("mousemove", handleMouseMove);
181 document.addEventListener("touchmove", handleTouchMove, { passive: false });
182
183 updateDimensions();
184
185 lastFrameTime = performance.now();
186 animationFrameId = requestAnimationFrame(animate);
187 };
188
189 init();
190
191 return () => {
192 window.removeEventListener("resize", updateDimensions);
193 document.removeEventListener("mousemove", handleMouseMove);
194 document.removeEventListener("touchmove", handleTouchMove);
195 cancelAnimationFrame(animationFrameId);
196 };
197};
src/file-viewer/scripts/canvas_2024.client.ts created+251
...@@ -0,0 +1,251 @@
1// Vibe coded with AI, manually tuned randomness shader + opacity.
2(globalThis as any).canvas_2024 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 if (isStandalone) {
5 canvas.parentElement!.style.backgroundColor = "black";
6 }
7
8 const gl = canvas.getContext("webgl", {
9 alpha: true,
10 premultipliedAlpha: false,
11 });
12 if (!gl) {
13 console.error("WebGL not supported");
14 return () => {};
15 }
16
17 canvas.style.imageRendering = "pixelated";
18 canvas.style.opacity = isStandalone ? "0.3" : "0.15";
19
20 // Resize canvas to match display size
21 const resize = () => {
22 const displayWidth = Math.floor(
23 (canvas.clientWidth || window.innerWidth) / 3,
24 );
25 const displayHeight = Math.floor(
26 (canvas.clientHeight || window.innerHeight) / 3,
27 );
28
29 if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
30 canvas.width = displayWidth;
31 canvas.height = displayHeight;
32 gl.viewport(0, 0, canvas.width, canvas.height);
33 }
34 };
35 resize();
36
37 // Vertex shader (just passes coordinates)
38 const vertexShaderSource = `
39 attribute vec2 a_position;
40 void main() {
41 gl_Position = vec4(a_position, 0.0, 1.0);
42 }
43 `;
44
45 // Fragment shader creates random noise with higher opacity to ensure visibility
46 const fragmentShaderSource = `
47 precision mediump float;
48 uniform float u_time;
49
50 float noise1(float seed1,float seed2){
51 return(
52 fract(seed1+12.34567*
53 fract(100.*(abs(seed1*0.91)+seed2+94.68)*
54 fract((abs(seed2*0.41)+45.46)*
55 fract((abs(seed2)+757.21)*
56 fract(seed1*0.0171))))))
57 * 1.0038 - 0.00185;
58 }
59
60 float n(float seed1, float seed2, float seed3){
61 float buff1 = abs(seed1+100.81) + 1000.3;
62 float buff2 = abs(seed2+100.45) + 1000.2;
63 float buff3 = abs(noise1(seed1, seed2)+seed3) + 1000.1;
64 buff1 = (buff3*fract(buff2*fract(buff1*fract(buff2*0.146))));
65 buff2 = (buff2*fract(buff2*fract(buff1+buff2*fract(buff3*0.52))));
66 buff1 = noise1(buff1, buff2);
67 return(buff1);
68 }
69
70 void main() {
71 float noise = n(gl_FragCoord.x, gl_FragCoord.y, u_time);
72
73 gl_FragColor = vec4(1.0, 0.7, 0.7, 0.8*noise);
74 }
75 `;
76
77 // Create and compile shaders
78 const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
79 const fragmentShader = createShader(
80 gl,
81 gl.FRAGMENT_SHADER,
82 fragmentShaderSource,
83 );
84
85 // Check if shader creation failed
86 if (!vertexShader || !fragmentShader) {
87 console.error("Failed to create shaders");
88 return () => {};
89 }
90
91 // Create program and link shaders
92 const program = createProgram(gl, vertexShader, fragmentShader);
93
94 // Check if program creation failed
95 if (!program) {
96 console.error("Failed to create program");
97 return () => {};
98 }
99
100 // Get attribute and uniform locations
101 const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
102 const timeUniformLocation = gl.getUniformLocation(program, "u_time");
103
104 // Create a position buffer for a rectangle covering the entire canvas
105 const positionBuffer = gl.createBuffer();
106 if (!positionBuffer) {
107 console.error("Failed to create position buffer");
108 return () => {};
109 }
110
111 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
112 // Rectangle that covers the entire clip space
113 const positions = [
114 -1.0,
115 -1.0, // bottom left
116 1.0,
117 -1.0, // bottom right
118 -1.0,
119 1.0, // top left
120 1.0,
121 1.0, // top right
122 ];
123 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
124
125 // Set up blending
126 gl.enable(gl.BLEND);
127 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
128
129 // Fixed 24 FPS timing
130 const FPS = 24;
131 const FRAME_TIME = 1000 / FPS; // ms per frame
132
133 // Handle animation
134 let animationTimerId: number;
135 let startTime = Date.now();
136 let lastFrameTime = 0;
137
138 const render = () => {
139 // Get current time
140 const currentTime = Date.now();
141 const deltaTime = currentTime - lastFrameTime;
142
143 // Skip frame if it's too early (maintain 24 FPS)
144 if (deltaTime < FRAME_TIME) {
145 animationTimerId = window.setTimeout(render, 0); // Check again ASAP but yield to browser
146 return;
147 }
148
149 // Update last frame time, accounting for any drift
150 lastFrameTime = currentTime - (deltaTime % FRAME_TIME);
151
152 // Resize canvas if needed
153 resize();
154
155 // Calculate elapsed time in seconds for animation
156 const elapsedTime = (currentTime - startTime) / 1000;
157
158 // Clear the canvas with transparent black
159 gl.clearColor(0, 0, 0, 0);
160 gl.clear(gl.COLOR_BUFFER_BIT);
161
162 // Use our shader program
163 gl.useProgram(program);
164
165 // Set up the position attribute
166 gl.enableVertexAttribArray(positionAttributeLocation);
167 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
168 gl.vertexAttribPointer(
169 positionAttributeLocation,
170 2, // 2 components per vertex
171 gl.FLOAT, // data type
172 false, // normalize
173 0, // stride (0 = compute from size and type)
174 0, // offset
175 );
176
177 // Update time uniform for animation
178 gl.uniform1f(timeUniformLocation, elapsedTime);
179
180 // Draw the rectangle (2 triangles)
181 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
182
183 // Schedule next frame (aiming for 24 FPS)
184 const timeToNextFrame = Math.max(
185 0,
186 FRAME_TIME - (Date.now() - currentTime),
187 );
188 animationTimerId = window.setTimeout(render, timeToNextFrame);
189 };
190
191 // Helper function to create shaders
192 function createShader(
193 gl: WebGLRenderingContext,
194 type: number,
195 source: string,
196 ): WebGLShader | null {
197 const shader = gl.createShader(type);
198 if (!shader) {
199 console.error("Failed to create shader object");
200 return null;
201 }
202
203 gl.shaderSource(shader, source);
204 gl.compileShader(shader);
205
206 if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
207 console.error("Shader compilation error:", gl.getShaderInfoLog(shader));
208 gl.deleteShader(shader);
209 return null;
210 }
211
212 return shader;
213 }
214
215 // Helper function to create program and link shaders
216 function createProgram(
217 gl: WebGLRenderingContext,
218 vertexShader: WebGLShader,
219 fragmentShader: WebGLShader,
220 ): WebGLProgram | null {
221 const program = gl.createProgram();
222 if (!program) {
223 console.error("Failed to create program object");
224 return null;
225 }
226
227 gl.attachShader(program, vertexShader);
228 gl.attachShader(program, fragmentShader);
229 gl.linkProgram(program);
230
231 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
232 console.error("Program linking error:", gl.getProgramInfoLog(program));
233 return null;
234 }
235
236 return program;
237 }
238
239 // Start the rendering with initial timestamp
240 lastFrameTime = Date.now();
241 render();
242
243 // Return cleanup function
244 return () => {
245 clearTimeout(animationTimerId);
246 if (program) gl.deleteProgram(program);
247 if (vertexShader) gl.deleteShader(vertexShader);
248 if (fragmentShader) gl.deleteShader(fragmentShader);
249 if (positionBuffer) gl.deleteBuffer(positionBuffer);
250 };
251};
src/file-viewer/scripts/canvas_2024.ts deleted-251
...@@ -1,251 +0,0 @@
1// Vibe coded with AI
2(globalThis as any).canvas_2024 = function (canvas: HTMLCanvasElement) {
3 const isStandalone = canvas.getAttribute("data-standalone") === "true";
4 if (isStandalone) {
5 canvas.parentElement!.style.backgroundColor = "black";
6 }
7
8 const gl = canvas.getContext("webgl", {
9 alpha: true,
10 premultipliedAlpha: false,
11 });
12 if (!gl) {
13 console.error("WebGL not supported");
14 return () => {};
15 }
16
17 canvas.style.imageRendering = "pixelated";
18 canvas.style.opacity = isStandalone ? "0.3" : "0.15";
19
20 // Resize canvas to match display size
21 const resize = () => {
22 const displayWidth = Math.floor(
23 (canvas.clientWidth || window.innerWidth) / 3,
24 );
25 const displayHeight = Math.floor(
26 (canvas.clientHeight || window.innerHeight) / 3,
27 );
28
29 if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
30 canvas.width = displayWidth;
31 canvas.height = displayHeight;
32 gl.viewport(0, 0, canvas.width, canvas.height);
33 }
34 };
35 resize();
36
37 // Vertex shader (just passes coordinates)
38 const vertexShaderSource = `
39 attribute vec2 a_position;
40 void main() {
41 gl_Position = vec4(a_position, 0.0, 1.0);
42 }
43 `;
44
45 // Fragment shader creates random noise with higher opacity to ensure visibility
46 const fragmentShaderSource = `
47 precision mediump float;
48 uniform float u_time;
49
50 float noise1(float seed1,float seed2){
51 return(
52 fract(seed1+12.34567*
53 fract(100.*(abs(seed1*0.91)+seed2+94.68)*
54 fract((abs(seed2*0.41)+45.46)*
55 fract((abs(seed2)+757.21)*
56 fract(seed1*0.0171))))))
57 * 1.0038 - 0.00185;
58 }
59
60 float n(float seed1, float seed2, float seed3){
61 float buff1 = abs(seed1+100.81) + 1000.3;
62 float buff2 = abs(seed2+100.45) + 1000.2;
63 float buff3 = abs(noise1(seed1, seed2)+seed3) + 1000.1;
64 buff1 = (buff3*fract(buff2*fract(buff1*fract(buff2*0.146))));
65 buff2 = (buff2*fract(buff2*fract(buff1+buff2*fract(buff3*0.52))));
66 buff1 = noise1(buff1, buff2);
67 return(buff1);
68 }
69
70 void main() {
71 float noise = n(gl_FragCoord.x, gl_FragCoord.y, u_time);
72
73 gl_FragColor = vec4(1.0, 0.7, 0.7, 0.8*noise);
74 }
75 `;
76
77 // Create and compile shaders
78 const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
79 const fragmentShader = createShader(
80 gl,
81 gl.FRAGMENT_SHADER,
82 fragmentShaderSource,
83 );
84
85 // Check if shader creation failed
86 if (!vertexShader || !fragmentShader) {
87 console.error("Failed to create shaders");
88 return () => {};
89 }
90
91 // Create program and link shaders
92 const program = createProgram(gl, vertexShader, fragmentShader);
93
94 // Check if program creation failed
95 if (!program) {
96 console.error("Failed to create program");
97 return () => {};
98 }
99
100 // Get attribute and uniform locations
101 const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
102 const timeUniformLocation = gl.getUniformLocation(program, "u_time");
103
104 // Create a position buffer for a rectangle covering the entire canvas
105 const positionBuffer = gl.createBuffer();
106 if (!positionBuffer) {
107 console.error("Failed to create position buffer");
108 return () => {};
109 }
110
111 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
112 // Rectangle that covers the entire clip space
113 const positions = [
114 -1.0,
115 -1.0, // bottom left
116 1.0,
117 -1.0, // bottom right
118 -1.0,
119 1.0, // top left
120 1.0,
121 1.0, // top right
122 ];
123 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
124
125 // Set up blending
126 gl.enable(gl.BLEND);
127 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
128
129 // Fixed 24 FPS timing
130 const FPS = 24;
131 const FRAME_TIME = 1000 / FPS; // ms per frame
132
133 // Handle animation
134 let animationTimerId: number;
135 let startTime = Date.now();
136 let lastFrameTime = 0;
137
138 const render = () => {
139 // Get current time
140 const currentTime = Date.now();
141 const deltaTime = currentTime - lastFrameTime;
142
143 // Skip frame if it's too early (maintain 24 FPS)
144 if (deltaTime < FRAME_TIME) {
145 animationTimerId = window.setTimeout(render, 0); // Check again ASAP but yield to browser
146 return;
147 }
148
149 // Update last frame time, accounting for any drift
150 lastFrameTime = currentTime - (deltaTime % FRAME_TIME);
151
152 // Resize canvas if needed
153 resize();
154
155 // Calculate elapsed time in seconds for animation
156 const elapsedTime = (currentTime - startTime) / 1000;
157
158 // Clear the canvas with transparent black
159 gl.clearColor(0, 0, 0, 0);
160 gl.clear(gl.COLOR_BUFFER_BIT);
161
162 // Use our shader program
163 gl.useProgram(program);
164
165 // Set up the position attribute
166 gl.enableVertexAttribArray(positionAttributeLocation);
167 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
168 gl.vertexAttribPointer(
169 positionAttributeLocation,
170 2, // 2 components per vertex
171 gl.FLOAT, // data type
172 false, // normalize
173 0, // stride (0 = compute from size and type)
174 0, // offset
175 );
176
177 // Update time uniform for animation
178 gl.uniform1f(timeUniformLocation, elapsedTime);
179
180 // Draw the rectangle (2 triangles)
181 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
182
183 // Schedule next frame (aiming for 24 FPS)
184 const timeToNextFrame = Math.max(
185 0,
186 FRAME_TIME - (Date.now() - currentTime),
187 );
188 animationTimerId = window.setTimeout(render, timeToNextFrame);
189 };
190
191 // Helper function to create shaders
192 function createShader(
193 gl: WebGLRenderingContext,
194 type: number,
195 source: string,
196 ): WebGLShader | null {
197 const shader = gl.createShader(type);
198 if (!shader) {
199 console.error("Failed to create shader object");
200 return null;
201 }
202
203 gl.shaderSource(shader, source);
204 gl.compileShader(shader);
205
206 if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
207 console.error("Shader compilation error:", gl.getShaderInfoLog(shader));
208 gl.deleteShader(shader);
209 return null;
210 }
211
212 return shader;
213 }
214
215 // Helper function to create program and link shaders
216 function createProgram(
217 gl: WebGLRenderingContext,
218 vertexShader: WebGLShader,
219 fragmentShader: WebGLShader,
220 ): WebGLProgram | null {
221 const program = gl.createProgram();
222 if (!program) {
223 console.error("Failed to create program object");
224 return null;
225 }
226
227 gl.attachShader(program, vertexShader);
228 gl.attachShader(program, fragmentShader);
229 gl.linkProgram(program);
230
231 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
232 console.error("Program linking error:", gl.getProgramInfoLog(program));
233 return null;
234 }
235
236 return program;
237 }
238
239 // Start the rendering with initial timestamp
240 lastFrameTime = Date.now();
241 render();
242
243 // Return cleanup function
244 return () => {
245 clearTimeout(animationTimerId);
246 if (program) gl.deleteProgram(program);
247 if (vertexShader) gl.deleteShader(vertexShader);
248 if (fragmentShader) gl.deleteShader(fragmentShader);
249 if (positionBuffer) gl.deleteBuffer(positionBuffer);
250 };
251};
src/file-viewer/scripts/canvas_cotyledon.client.ts created+363
...@@ -0,0 +1,363 @@
1// This canvas was written partially by AI
2// @ts-ignore
3globalThis.canvas_cotyledon = function (
4 canvas: HTMLCanvasElement,
5 panel: HTMLElement,
6) {
7 let running = true;
8 const ctx = canvas.getContext("2d");
9
10 function resizeCanvas() {
11 canvas.width = canvas.clientWidth;
12 canvas.height = canvas.clientHeight;
13 }
14
15 resizeCanvas();
16 window.addEventListener("resize", resizeCanvas);
17
18 const clover = new Path2D(
19 "M18.9845 34.4839C20.4004 34.5218 21.8336 34.6883 23.2644 34.9578C20.1378 31.095 18.4268 27.1546 18.0555 23.2959C17.321 15.6622 21.9022 9.36595 28.8908 5.78535C34.6355 2.84212 40.258 2.98454 44.2809 5.96879C45.6605 6.99221 46.7683 8.2886 47.5877 9.78593C48.3054 8.50307 49.134 7.26623 50.0858 6.17951C51.8368 4.18037 54.1947 2.47127 57.2294 2.15019C60.2768 1.82766 63.467 2.9608 66.7548 5.52299C70.9834 8.81811 73.084 12.8864 73.5996 17.2135C74.1044 21.4504 73.0711 25.7433 71.4155 29.6117C70.6566 31.3849 69.7488 33.1106 68.7557 34.7506C70.3664 33.9983 72.0168 33.3376 73.6816 32.8312C77.2262 31.7528 81.0258 31.3024 84.8151 32.2149C88.6451 33.1371 92.2246 35.3946 95.3823 39.3157C98.4534 43.1293 99.9219 46.6818 99.997 49.9677C100.073 53.3033 98.7051 55.9829 96.8652 57.9789C95.0586 59.9387 92.7653 61.2872 90.7505 62.1315C90.692 62.1561 90.6334 62.1802 90.5746 62.2042L90.4465 62.256C91.4852 63.7304 92.4724 65.5955 93.0127 67.6979C93.5916 69.9509 93.6669 72.5285 92.674 75.1356C91.679 77.7482 89.7006 80.1559 86.5767 82.2161C86.5556 82.23 86.5342 82.2438 86.5126 82.2571C84.1333 83.7267 81.5504 84.7197 78.6932 84.9352C75.832 85.151 72.8634 84.5742 69.7337 83.1522C64.7667 80.8953 59.274 76.4525 52.8745 69.3645C52.8789 70.1568 52.8844 70.9254 52.9004 71.6677C52.9643 74.6226 53.1868 77.4534 54.0666 80.6265C55.2259 84.503 57.2821 88.4684 60.9561 92.3161C61.644 93.0366 61.8512 94.0908 61.4872 95.018L60.9919 96.2799C60.6464 97.16 59.8435 97.778 58.9041 97.8865C57.9647 97.9952 57.042 97.5769 56.5047 96.7985C52.5406 91.0574 50.3441 86.3289 49.1491 82.0434C48.0155 78.2319 47.6244 74.4579 47.5085 71.0024C45.418 73.6873 42.8696 76.4687 40.0618 78.9101C34.3517 83.8756 26.6803 88.1931 19.142 85.9955C15.5301 84.9425 12.8635 83.2751 11.0848 81.1179C9.2952 78.9474 8.5557 76.4627 8.4981 74.0631C8.43961 71.6256 9.07998 69.225 10.075 67.1703C7.76333 66.828 5.38011 65.9682 3.47071 64.2327C-0.339092 60.7699 -1.2199 54.8876 1.86982 46.4552C3.47011 42.0878 5.90372 38.9798 8.98328 37.0179C12.0444 35.0677 15.5215 34.3912 18.9845 34.4839Z",
20 );
21
22 // Background
23 const base = [0x14, 0x1a, 0x19];
24
25 let blobTextureCanvas: OffscreenCanvas;
26 let blobTextureCtx: OffscreenCanvasRenderingContext2D;
27 const blobSize = 1000; // Size of the noise texture
28 {
29 const blobTexture = new ImageData(blobSize, blobSize);
30 const data = blobTexture.data;
31
32 let x = 0;
33 let y = 0;
34 for (let i = 0; i < data.length; i += 4) {
35 if (x >= blobSize) {
36 x = 0;
37 y++;
38 }
39
40 const noiseX = Math.sin(x * .2 + y * .1) * 0.03;
41 const noiseY = Math.cos(y * .6 + x * .2) * 0.05;
42
43 const centerX = blobSize / 2;
44 const centerY = blobSize / 2;
45 const dx = (x + noiseX) - centerX;
46 const dy = (y + noiseY) - centerY;
47 const distanceFromCenterRaw = (dx * dx + dy * dy) ** 0.5;
48
49 const maxDistance = blobSize / 2;
50 const distanceFromCenter = Math.min(
51 1,
52 distanceFromCenterRaw / maxDistance,
53 );
54
55 const noiseValue = (1 - 0.5 * Math.sin(x * 0.02 - y * 0.04)) *
56 (1 - 0.5 * Math.cos(x * 0.03 + y * 0.04)) * 0.3;
57
58 const gradient = (1 - distanceFromCenter) *
59 (0.95 - distanceFromCenter * 0.4);
60 const finalValue = Math.max(
61 0,
62 Math.min(1, gradient * (Math.random() * 0.3 + 0.85 + noiseValue)),
63 );
64
65 data[i] = 121;
66 data[i + 1] = 219;
67 data[i + 2] = 160;
68 data[i + 3] = Math.floor(finalValue * 255.99); // Alpha
69 x++;
70 }
71 blobTextureCanvas = new OffscreenCanvas(blobSize, blobSize);
72 blobTextureCtx = blobTextureCanvas.getContext("2d")!;
73 blobTextureCtx.putImageData(blobTexture, 0, 0);
74 }
75
76 class CotyledonParticle {
77 x: number;
78 y: number;
79 size: number;
80 velocityX: number;
81 velocityY: number;
82 rotation: number;
83 rotationSpeed: number;
84 color: string;
85
86 constructor(positioning: "random" | "edge") {
87 this.size = Math.random() * 0.1 + 0.6;
88
89 if (positioning === "edge") {
90 const edge = Math.floor(Math.random() * 5);
91 if (edge === 0 || edge === 1) {
92 // Right edge
93 this.x = 1.05;
94 this.y = Math.random();
95 this.velocityX = -Math.random() * 0.1 - 0.2;
96 this.velocityY = 0;
97 } else if (edge === 2 || edge === 3) {
98 // Top edge
99 this.x = Math.random();
100 this.y = -0.05;
101 this.velocityX = -Math.random() * 0.1 - 0.1;
102 this.velocityY = -Math.random() * 0.2 - 0.05;
103 } else {
104 // Bottom edge
105 this.x = Math.random() * 0.5 + 0.5;
106 this.y = 1.05;
107 this.velocityX = Math.random() * 0.1 + 0.1;
108 this.velocityY = -Math.random() * 0.3 - 0.1;
109 }
110 } else {
111 let tries = 0;
112 do {
113 this.x = Math.random();
114 this.y = Math.random();
115 this.velocityX = -Math.random() * 0.05 - 0.1;
116 this.velocityY = -Math.random() * 0.2 + 0.1;
117 } while (this.tooCloseToAnyOtherParticle() && (tries++ < 10));
118 }
119
120 this.rotation = Math.random() * Math.PI * 2;
121 this.rotationSpeed = (Math.random() * 0.003 - 0.0015) *
122 (Math.random() > 0.5 ? 1 : -1);
123
124 const opacity = Math.random() * 0.4 + 0.2;
125 this.color = `rgba(${
126 base.map((x) => x + Math.floor(x * opacity)).join(",")
127 }, 1)`;
128 }
129
130 tooCloseToAnyOtherParticle() {
131 for (let i = 0; i < cotyledonParticles.length; i++) {
132 const otherParticle = cotyledonParticles[i];
133 const distance = Math.sqrt(
134 (this.x - otherParticle.x) ** 2 + (this.y - otherParticle.y) ** 2,
135 );
136 if (distance < 0.1) {
137 return true;
138 }
139 }
140 }
141
142 update() {
143 if (this.velocityY < 0.01) {
144 this.velocityY += 0.00025;
145 }
146 this.velocityX -= 0.0001;
147 this.x += this.velocityX / 1300;
148 this.y += this.velocityY / 1000;
149 this.rotation += this.rotationSpeed;
150 return this.x < -0.05 || (this.y > 1 && this.velocityY < 0);
151 }
152
153 draw() {
154 if (!ctx) return;
155
156 ctx.save();
157 ctx.translate(this.x * canvas.width, this.y * canvas.height);
158 ctx.rotate(this.rotation);
159 ctx.scale(this.size, this.size);
160 ctx.translate(-50, -50);
161 ctx.fillStyle = this.color;
162 ctx.fill(clover);
163 ctx.restore();
164 }
165 }
166
167 class BlobParticle {
168 x: number;
169 y: number;
170 opacity: number;
171 state: 0 | 1 | 2;
172 stateTime: number;
173 stayDuration: number;
174 innerColor: string;
175 rot: number = Math.random() * Math.PI * 2;
176
177 constructor() {
178 this.x = Math.random() * 0.6 + 0.2;
179 this.y = Math.random() * 0.6 + 0.2;
180
181 this.opacity = 0; // Start fully transparent
182
183 this.state = 0;
184 this.stateTime = 0;
185 this.stayDuration = Math.random() * 10000 + 5000; // Random stay duration between 5-15 seconds
186
187 const colorMultiplier = Math.random() * 0.5 + 1.5; // 0.5-1.0 multiplier
188 const colorValues = base.map((x) => Math.floor(x * colorMultiplier));
189 this.innerColor = `rgba(${colorValues.join(",")}, 1)`;
190 }
191
192 update(deltaTime: number) {
193 this.stateTime += deltaTime;
194
195 if (this.state === 0) {
196 this.opacity = Math.min(1, this.stateTime / 15000);
197 if (this.stateTime >= 15000) {
198 this.state = 1;
199 this.stateTime = 0;
200 }
201 } else if (this.state === 1) {
202 if (this.stateTime >= this.stayDuration) {
203 this.state = 2;
204 this.stateTime = 0;
205 }
206 } else if (this.state === 2) {
207 this.opacity = Math.max(0, 1 - (this.stateTime / 15000));
208 if (this.stateTime >= 15000) {
209 return true;
210 }
211 }
212 return false;
213 }
214
215 draw() {
216 if (!ctx) return;
217
218 const screenX = this.x * canvas.width;
219 const screenY = this.y * canvas.height;
220 const screenSize = (Math.min(canvas.width, canvas.height) * 2) / blobSize;
221
222 ctx.save();
223 ctx.translate(screenX - screenSize * 0.5, screenY - screenSize * 0.5);
224 ctx.scale(screenSize, screenSize);
225 ctx.rotate(this.rot);
226 ctx.globalAlpha = this.opacity * 0.2;
227 ctx.globalCompositeOperation = "overlay";
228 ctx.drawImage(blobTextureCanvas, 0, 0);
229 ctx.restore();
230 }
231 }
232
233 const cotyledonParticles: CotyledonParticle[] = [];
234 const blobParticles: BlobParticle[] = [];
235 let blobParticleTop: BlobParticle = new BlobParticle();
236 for (let i = 0; i < 80; i++) {
237 cotyledonParticles.push(new CotyledonParticle("random"));
238 }
239 for (let i = 0; i < 9; i++) {
240 const blobParticle = new BlobParticle();
241 if (i < 4) {
242 blobParticle.state = 1;
243 blobParticle.opacity = 1;
244 blobParticle.stayDuration = Math.random() * 10000;
245 } else {
246 blobParticle.state = i < 7 ? 2 : 0;
247 blobParticle.stateTime = Math.random() * 15000;
248 }
249 if (i > 0) {
250 do {
251 blobParticle.x = Math.random();
252 blobParticle.y = Math.random();
253 } while (
254 blobParticles.some((p) =>
255 Math.sqrt((p.x - blobParticle.x) ** 2 + (p.y - blobParticle.y) ** 2) <
256 0.1
257 )
258 );
259 }
260 blobParticles.push(blobParticle);
261 }
262
263 let lastTime = performance.now();
264
265 function animate(currentTime: number) {
266 if (!running) return;
267 if (!ctx) return;
268
269 const deltaTime = currentTime - lastTime;
270 lastTime = currentTime;
271
272 ctx.clearRect(0, 0, canvas.width, canvas.height);
273
274 for (let i = blobParticles.length - 1; i >= 0; i--) {
275 const shouldRemove = blobParticles[i].update(deltaTime);
276 if (shouldRemove) {
277 blobParticles[i] = new BlobParticle();
278 } else {
279 blobParticles[i].draw();
280 }
281 }
282
283 for (let i = cotyledonParticles.length - 1; i >= 0; i--) {
284 const shouldRemove = cotyledonParticles[i].update();
285 if (shouldRemove) {
286 cotyledonParticles[i] = new CotyledonParticle("edge");
287 } else {
288 cotyledonParticles[i].draw();
289 }
290 }
291
292 if (blobParticleTop.update(deltaTime)) {
293 blobParticleTop = new BlobParticle();
294 }
295 blobParticleTop.draw();
296
297 requestAnimationFrame(animate);
298 }
299
300 let clickedButton = false;
301 const enterButton = panel.querySelector("button#enter")!;
302 enterButton.addEventListener("click", () => {
303 if (clickedButton) return;
304 clickedButton = true;
305 const first = panel.querySelector("#first")! as HTMLElement;
306 const second = panel.querySelector("#captcha")! as HTMLElement;
307 first.style.transition = second.style.transition = "opacity 1s ease-in-out";
308 first.style.opacity = "0";
309 second.style.opacity = "0";
310 setTimeout(() => {
311 first.style.display = "none";
312 second.style.display = "block";
313 setTimeout(() => {
314 second.style.opacity = "1";
315
316 document.getElementById("enter2")?.addEventListener("click", () => {
317 second.style.opacity = "0";
318 let p = fetch("/file/cotyledon", {
319 method: "POST",
320 body: "I AGREE",
321 });
322 setTimeout(() => {
323 p.then(() => {
324 location.reload();
325 });
326 }, 1000);
327 });
328 }, 10);
329 }, 1000);
330 });
331 const imageButtons = panel.querySelectorAll(".image-grid button")!;
332 imageButtons.forEach((button) => {
333 let canClick = true;
334 button.addEventListener("click", () => {
335 if (!canClick) return;
336 canClick = false;
337 const image = button.querySelector("img")!;
338 image.style.transition = "opacity 0.05s linear";
339 image.style.opacity = "0";
340 setTimeout(() => {
341 image.style.transition = "opacity 2s linear";
342 let newNum;
343 do {
344 newNum = Math.floor(Math.random() * 18); // 0-17 inclusive
345 } while (
346 document.querySelector(`img[src="/captcha/image/${newNum}.jpeg"]`)
347 );
348 image.setAttribute("src", `/captcha/image/${newNum}.jpeg`);
349 setTimeout(() => {
350 image.style.opacity = "0.75";
351 canClick = true;
352 }, 50);
353 }, 300);
354 });
355 });
356
357 // Start animation
358 animate(performance.now());
359 return () => {
360 window.removeEventListener("resize", resizeCanvas);
361 running = false;
362 };
363};
src/file-viewer/scripts/canvas_cotyledon.ts deleted-362
...@@ -1,362 +0,0 @@
1// @ts-ignore
2globalThis.canvas_cotyledon = function (
3 canvas: HTMLCanvasElement,
4 panel: HTMLElement,
5) {
6 let running = true;
7 const ctx = canvas.getContext("2d");
8
9 function resizeCanvas() {
10 canvas.width = canvas.clientWidth;
11 canvas.height = canvas.clientHeight;
12 }
13
14 resizeCanvas();
15 window.addEventListener("resize", resizeCanvas);
16
17 const clover = new Path2D(
18 "M18.9845 34.4839C20.4004 34.5218 21.8336 34.6883 23.2644 34.9578C20.1378 31.095 18.4268 27.1546 18.0555 23.2959C17.321 15.6622 21.9022 9.36595 28.8908 5.78535C34.6355 2.84212 40.258 2.98454 44.2809 5.96879C45.6605 6.99221 46.7683 8.2886 47.5877 9.78593C48.3054 8.50307 49.134 7.26623 50.0858 6.17951C51.8368 4.18037 54.1947 2.47127 57.2294 2.15019C60.2768 1.82766 63.467 2.9608 66.7548 5.52299C70.9834 8.81811 73.084 12.8864 73.5996 17.2135C74.1044 21.4504 73.0711 25.7433 71.4155 29.6117C70.6566 31.3849 69.7488 33.1106 68.7557 34.7506C70.3664 33.9983 72.0168 33.3376 73.6816 32.8312C77.2262 31.7528 81.0258 31.3024 84.8151 32.2149C88.6451 33.1371 92.2246 35.3946 95.3823 39.3157C98.4534 43.1293 99.9219 46.6818 99.997 49.9677C100.073 53.3033 98.7051 55.9829 96.8652 57.9789C95.0586 59.9387 92.7653 61.2872 90.7505 62.1315C90.692 62.1561 90.6334 62.1802 90.5746 62.2042L90.4465 62.256C91.4852 63.7304 92.4724 65.5955 93.0127 67.6979C93.5916 69.9509 93.6669 72.5285 92.674 75.1356C91.679 77.7482 89.7006 80.1559 86.5767 82.2161C86.5556 82.23 86.5342 82.2438 86.5126 82.2571C84.1333 83.7267 81.5504 84.7197 78.6932 84.9352C75.832 85.151 72.8634 84.5742 69.7337 83.1522C64.7667 80.8953 59.274 76.4525 52.8745 69.3645C52.8789 70.1568 52.8844 70.9254 52.9004 71.6677C52.9643 74.6226 53.1868 77.4534 54.0666 80.6265C55.2259 84.503 57.2821 88.4684 60.9561 92.3161C61.644 93.0366 61.8512 94.0908 61.4872 95.018L60.9919 96.2799C60.6464 97.16 59.8435 97.778 58.9041 97.8865C57.9647 97.9952 57.042 97.5769 56.5047 96.7985C52.5406 91.0574 50.3441 86.3289 49.1491 82.0434C48.0155 78.2319 47.6244 74.4579 47.5085 71.0024C45.418 73.6873 42.8696 76.4687 40.0618 78.9101C34.3517 83.8756 26.6803 88.1931 19.142 85.9955C15.5301 84.9425 12.8635 83.2751 11.0848 81.1179C9.2952 78.9474 8.5557 76.4627 8.4981 74.0631C8.43961 71.6256 9.07998 69.225 10.075 67.1703C7.76333 66.828 5.38011 65.9682 3.47071 64.2327C-0.339092 60.7699 -1.2199 54.8876 1.86982 46.4552C3.47011 42.0878 5.90372 38.9798 8.98328 37.0179C12.0444 35.0677 15.5215 34.3912 18.9845 34.4839Z",
19 );
20
21 // Background
22 const base = [0x14, 0x1a, 0x19];
23
24 let blobTextureCanvas: OffscreenCanvas;
25 let blobTextureCtx: OffscreenCanvasRenderingContext2D;
26 const blobSize = 1000; // Size of the noise texture
27 {
28 const blobTexture = new ImageData(blobSize, blobSize);
29 const data = blobTexture.data;
30
31 let x = 0;
32 let y = 0;
33 for (let i = 0; i < data.length; i += 4) {
34 if (x >= blobSize) {
35 x = 0;
36 y++;
37 }
38
39 const noiseX = Math.sin(x * .2 + y * .1) * 0.03;
40 const noiseY = Math.cos(y * .6 + x * .2) * 0.05;
41
42 const centerX = blobSize / 2;
43 const centerY = blobSize / 2;
44 const dx = (x + noiseX) - centerX;
45 const dy = (y + noiseY) - centerY;
46 const distanceFromCenterRaw = (dx * dx + dy * dy) ** 0.5;
47
48 const maxDistance = blobSize / 2;
49 const distanceFromCenter = Math.min(
50 1,
51 distanceFromCenterRaw / maxDistance,
52 );
53
54 const noiseValue = (1 - 0.5 * Math.sin(x * 0.02 - y * 0.04)) *
55 (1 - 0.5 * Math.cos(x * 0.03 + y * 0.04)) * 0.3;
56
57 const gradient = (1 - distanceFromCenter) *
58 (0.95 - distanceFromCenter * 0.4);
59 const finalValue = Math.max(
60 0,
61 Math.min(1, gradient * (Math.random() * 0.3 + 0.85 + noiseValue)),
62 );
63
64 data[i] = 121;
65 data[i + 1] = 219;
66 data[i + 2] = 160;
67 data[i + 3] = Math.floor(finalValue * 255.99); // Alpha
68 x++;
69 }
70 blobTextureCanvas = new OffscreenCanvas(blobSize, blobSize);
71 blobTextureCtx = blobTextureCanvas.getContext("2d")!;
72 blobTextureCtx.putImageData(blobTexture, 0, 0);
73 }
74
75 class CotyledonParticle {
76 x: number;
77 y: number;
78 size: number;
79 velocityX: number;
80 velocityY: number;
81 rotation: number;
82 rotationSpeed: number;
83 color: string;
84
85 constructor(positioning: "random" | "edge") {
86 this.size = Math.random() * 0.1 + 0.6;
87
88 if (positioning === "edge") {
89 const edge = Math.floor(Math.random() * 5);
90 if (edge === 0 || edge === 1) {
91 // Right edge
92 this.x = 1.05;
93 this.y = Math.random();
94 this.velocityX = -Math.random() * 0.1 - 0.2;
95 this.velocityY = 0;
96 } else if (edge === 2 || edge === 3) {
97 // Top edge
98 this.x = Math.random();
99 this.y = -0.05;
100 this.velocityX = -Math.random() * 0.1 - 0.1;
101 this.velocityY = -Math.random() * 0.2 - 0.05;
102 } else {
103 // Bottom edge
104 this.x = Math.random() * 0.5 + 0.5;
105 this.y = 1.05;
106 this.velocityX = Math.random() * 0.1 + 0.1;
107 this.velocityY = -Math.random() * 0.3 - 0.1;
108 }
109 } else {
110 let tries = 0;
111 do {
112 this.x = Math.random();
113 this.y = Math.random();
114 this.velocityX = -Math.random() * 0.05 - 0.1;
115 this.velocityY = -Math.random() * 0.2 + 0.1;
116 } while (this.tooCloseToAnyOtherParticle() && (tries++ < 10));
117 }
118
119 this.rotation = Math.random() * Math.PI * 2;
120 this.rotationSpeed = (Math.random() * 0.003 - 0.0015) *
121 (Math.random() > 0.5 ? 1 : -1);
122
123 const opacity = Math.random() * 0.4 + 0.2;
124 this.color = `rgba(${
125 base.map((x) => x + Math.floor(x * opacity)).join(",")
126 }, 1)`;
127 }
128
129 tooCloseToAnyOtherParticle() {
130 for (let i = 0; i < cotyledonParticles.length; i++) {
131 const otherParticle = cotyledonParticles[i];
132 const distance = Math.sqrt(
133 (this.x - otherParticle.x) ** 2 + (this.y - otherParticle.y) ** 2,
134 );
135 if (distance < 0.1) {
136 return true;
137 }
138 }
139 }
140
141 update() {
142 if (this.velocityY < 0.01) {
143 this.velocityY += 0.00025;
144 }
145 this.velocityX -= 0.0001;
146 this.x += this.velocityX / 1300;
147 this.y += this.velocityY / 1000;
148 this.rotation += this.rotationSpeed;
149 return this.x < -0.05 || (this.y > 1 && this.velocityY < 0);
150 }
151
152 draw() {
153 if (!ctx) return;
154
155 ctx.save();
156 ctx.translate(this.x * canvas.width, this.y * canvas.height);
157 ctx.rotate(this.rotation);
158 ctx.scale(this.size, this.size);
159 ctx.translate(-50, -50);
160 ctx.fillStyle = this.color;
161 ctx.fill(clover);
162 ctx.restore();
163 }
164 }
165
166 class BlobParticle {
167 x: number;
168 y: number;
169 opacity: number;
170 state: 0 | 1 | 2;
171 stateTime: number;
172 stayDuration: number;
173 innerColor: string;
174 rot: number = Math.random() * Math.PI * 2;
175
176 constructor() {
177 this.x = Math.random() * 0.6 + 0.2;
178 this.y = Math.random() * 0.6 + 0.2;
179
180 this.opacity = 0; // Start fully transparent
181
182 this.state = 0;
183 this.stateTime = 0;
184 this.stayDuration = Math.random() * 10000 + 5000; // Random stay duration between 5-15 seconds
185
186 const colorMultiplier = Math.random() * 0.5 + 1.5; // 0.5-1.0 multiplier
187 const colorValues = base.map((x) => Math.floor(x * colorMultiplier));
188 this.innerColor = `rgba(${colorValues.join(",")}, 1)`;
189 }
190
191 update(deltaTime: number) {
192 this.stateTime += deltaTime;
193
194 if (this.state === 0) {
195 this.opacity = Math.min(1, this.stateTime / 15000);
196 if (this.stateTime >= 15000) {
197 this.state = 1;
198 this.stateTime = 0;
199 }
200 } else if (this.state === 1) {
201 if (this.stateTime >= this.stayDuration) {
202 this.state = 2;
203 this.stateTime = 0;
204 }
205 } else if (this.state === 2) {
206 this.opacity = Math.max(0, 1 - (this.stateTime / 15000));
207 if (this.stateTime >= 15000) {
208 return true;
209 }
210 }
211 return false;
212 }
213
214 draw() {
215 if (!ctx) return;
216
217 const screenX = this.x * canvas.width;
218 const screenY = this.y * canvas.height;
219 const screenSize = (Math.min(canvas.width, canvas.height) * 2) / blobSize;
220
221 ctx.save();
222 ctx.translate(screenX - screenSize * 0.5, screenY - screenSize * 0.5);
223 ctx.scale(screenSize, screenSize);
224 ctx.rotate(this.rot);
225 ctx.globalAlpha = this.opacity * 0.2;
226 ctx.globalCompositeOperation = "overlay";
227 ctx.drawImage(blobTextureCanvas, 0, 0);
228 ctx.restore();
229 }
230 }
231
232 const cotyledonParticles: CotyledonParticle[] = [];
233 const blobParticles: BlobParticle[] = [];
234 let blobParticleTop: BlobParticle = new BlobParticle();
235 for (let i = 0; i < 80; i++) {
236 cotyledonParticles.push(new CotyledonParticle("random"));
237 }
238 for (let i = 0; i < 9; i++) {
239 const blobParticle = new BlobParticle();
240 if (i < 4) {
241 blobParticle.state = 1;
242 blobParticle.opacity = 1;
243 blobParticle.stayDuration = Math.random() * 10000;
244 } else {
245 blobParticle.state = i < 7 ? 2 : 0;
246 blobParticle.stateTime = Math.random() * 15000;
247 }
248 if (i > 0) {
249 do {
250 blobParticle.x = Math.random();
251 blobParticle.y = Math.random();
252 } while (
253 blobParticles.some((p) =>
254 Math.sqrt((p.x - blobParticle.x) ** 2 + (p.y - blobParticle.y) ** 2) <
255 0.1
256 )
257 );
258 }
259 blobParticles.push(blobParticle);
260 }
261
262 let lastTime = performance.now();
263
264 function animate(currentTime: number) {
265 if (!running) return;
266 if (!ctx) return;
267
268 const deltaTime = currentTime - lastTime;
269 lastTime = currentTime;
270
271 ctx.clearRect(0, 0, canvas.width, canvas.height);
272
273 for (let i = blobParticles.length - 1; i >= 0; i--) {
274 const shouldRemove = blobParticles[i].update(deltaTime);
275 if (shouldRemove) {
276 blobParticles[i] = new BlobParticle();
277 } else {
278 blobParticles[i].draw();
279 }
280 }
281
282 for (let i = cotyledonParticles.length - 1; i >= 0; i--) {
283 const shouldRemove = cotyledonParticles[i].update();
284 if (shouldRemove) {
285 cotyledonParticles[i] = new CotyledonParticle("edge");
286 } else {
287 cotyledonParticles[i].draw();
288 }
289 }
290
291 if (blobParticleTop.update(deltaTime)) {
292 blobParticleTop = new BlobParticle();
293 }
294 blobParticleTop.draw();
295
296 requestAnimationFrame(animate);
297 }
298
299 let clickedButton = false;
300 const enterButton = panel.querySelector("button#enter")!;
301 enterButton.addEventListener("click", () => {
302 if (clickedButton) return;
303 clickedButton = true;
304 const first = panel.querySelector("#first")! as HTMLElement;
305 const second = panel.querySelector("#captcha")! as HTMLElement;
306 first.style.transition = second.style.transition = "opacity 1s ease-in-out";
307 first.style.opacity = "0";
308 second.style.opacity = "0";
309 setTimeout(() => {
310 first.style.display = "none";
311 second.style.display = "block";
312 setTimeout(() => {
313 second.style.opacity = "1";
314
315 document.getElementById("enter2")?.addEventListener("click", () => {
316 second.style.opacity = "0";
317 let p = fetch("/file/cotyledon", {
318 method: "POST",
319 body: "I AGREE",
320 });
321 setTimeout(() => {
322 p.then(() => {
323 location.reload();
324 });
325 }, 1000);
326 });
327 }, 10);
328 }, 1000);
329 });
330 const imageButtons = panel.querySelectorAll(".image-grid button")!;
331 imageButtons.forEach((button) => {
332 let canClick = true;
333 button.addEventListener("click", () => {
334 if (!canClick) return;
335 canClick = false;
336 const image = button.querySelector("img")!;
337 image.style.transition = "opacity 0.05s linear";
338 image.style.opacity = "0";
339 setTimeout(() => {
340 image.style.transition = "opacity 2s linear";
341 let newNum;
342 do {
343 newNum = Math.floor(Math.random() * 18); // 0-17 inclusive
344 } while (
345 document.querySelector(`img[src="/captcha/image/${newNum}.jpeg"]`)
346 );
347 image.setAttribute("src", `/captcha/image/${newNum}.jpeg`);
348 setTimeout(() => {
349 image.style.opacity = "0.75";
350 canClick = true;
351 }, 50);
352 }, 300);
353 });
354 });
355
356 // Start animation
357 animate(performance.now());
358 return () => {
359 window.removeEventListener("resize", resizeCanvas);
360 running = false;
361 };
362};
src/file-viewer/views/clofi.tsx+37-41
...@@ -1,23 +1,15 @@...@@ -1,23 +1,15 @@
1import "./clofi.css";
2import assert from "node:assert";
3import path, { dirname } from "node:path";
4import { MediaFile } from "../db.ts";
5import { useInlineScript } from "../framework/page-resources.ts";
6import { escapeUri, formatDuration, formatSize } from "../media/share.ts";
7import {
8 highlightConvo,
9 highlightHashComments,
10 highlightLinksInTextView,
11} from "../media/text-formatting.ts";
12import { TestID } from "../test/id.ts";
13import { ForEveryone } from "../media/cotyledon.tsx";
14
15export const theme = {1export const theme = {
16 bg: "#312652",2 bg: "#312652",
17 fg: "#f0f0ff",3 fg: "#f0f0ff",
18 primary: "#fabe32",4 primary: "#fabe32",
19};5};
206
7export function meta({ file }: { file: MediaFile }) {
8 if (file.path === "/") return { title: "clo's files" };
9 return { title: file.basename + " - clo's files" };
10}
11
12// TODO: use rules.ts
21export const extensionToViewer: {13export const extensionToViewer: {
22 [key: string]: (props: {14 [key: string]: (props: {
23 file: MediaFile;15 file: MediaFile;
...@@ -78,7 +70,7 @@ export default function MediaList({...@@ -78,7 +70,7 @@ export default function MediaList({
78 file: MediaFile;70 file: MediaFile;
79 hasCotyledonCookie: boolean;71 hasCotyledonCookie: boolean;
80}) {72}) {
81 useInlineScript("file_viewer");73 addScript("./clofi.client.ts");
8274
83 const dirs: MediaFile[] = [];75 const dirs: MediaFile[] = [];
84 let dir: MediaFile | null = file;76 let dir: MediaFile | null = file;
...@@ -130,7 +122,7 @@ export function MediaPanel({...@@ -130,7 +122,7 @@ export function MediaPanel({
130 )122 )
131 : (123 : (
132 <>124 <>
133 {file.kind === MediaFile.Kind.directory125 {file.kind === MediaFileKind.directory
134 ? (126 ? (
135 <span>127 <span>
136 {file.basename}128 {file.basename}
...@@ -184,7 +176,7 @@ export function MediaPanel({...@@ -184,7 +176,7 @@ export function MediaPanel({
184 "/2024": "2024",176 "/2024": "2024",
185 };177 };
186 if (canvases[file.path]) {178 if (canvases[file.path]) {
187 useInlineScript(`canvas_${canvases[file.path]}` as any);179 addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
188 }180 }
189 return (181 return (
190 <div182 <div
...@@ -198,7 +190,7 @@ export function MediaPanel({...@@ -198,7 +190,7 @@ export function MediaPanel({
198 >190 >
199 </canvas>191 </canvas>
200 )}192 )}
201 {file.kind === MediaFile.Kind.directory && (193 {file.kind === MediaFileKind.directory && (
202 <a class="custom header" href={`/file${escapeUri(file.path)}`}>194 <a class="custom header" href={`/file${escapeUri(file.path)}`}>
203 <div class="ico ico-dir-open"></div>195 <div class="ico ico-dir-open"></div>
204 {label}196 {label}
...@@ -210,7 +202,7 @@ export function MediaPanel({...@@ -210,7 +202,7 @@ export function MediaPanel({
210 {label}202 {label}
211 </div>203 </div>
212204
213 {file.kind === MediaFile.Kind.directory205 {file.kind === MediaFileKind.directory
214 ? (206 ? (
215 <DirView207 <DirView
216 dir={file}208 dir={file}
...@@ -276,7 +268,7 @@ function sortDefault(a: MediaFile, b: MediaFile) {...@@ -276,7 +268,7 @@ function sortDefault(a: MediaFile, b: MediaFile) {
276268
277 // Then sort directories before files269 // Then sort directories before files
278 if (a.kind !== b.kind) {270 if (a.kind !== b.kind) {
279 return a.kind === MediaFile.Kind.directory ? -1 : 1;271 return a.kind === MediaFileKind.directory ? -1 : 1;
280 }272 }
281273
282 // Finally sort by date (newest first), then by name (a-z) if dates are the same274 // Finally sort by date (newest first), then by name (a-z) if dates are the same
...@@ -433,7 +425,7 @@ function ListItem({...@@ -433,7 +425,7 @@ function ListItem({
433425
434 let basenameWithoutExt = file.basenameWithoutExt;426 let basenameWithoutExt = file.basenameWithoutExt;
435427
436 let meta = file.kind === MediaFile.Kind.directory428 let meta = file.kind === MediaFileKind.directory
437 ? formatSize(file.size)429 ? formatSize(file.size)
438 : (file.duration ?? 0) > 0430 : (file.duration ?? 0) > 0
439 ? formatDuration(file.duration!)431 ? formatDuration(file.duration!)
...@@ -468,7 +460,7 @@ function ListItem({...@@ -468,7 +460,7 @@ function ListItem({
468 )}460 )}
469 {basenameWithoutExt}461 {basenameWithoutExt}
470 <span class="ext">{file.extension}</span>462 <span class="ext">{file.extension}</span>
471 {file.kind === MediaFile.Kind.directory463 {file.kind === MediaFileKind.directory
472 ? <span class="bold-slash">/</span>464 ? <span class="bold-slash">/</span>
473 : (465 : (
474 ""466 ""
...@@ -482,7 +474,7 @@ function ListItem({...@@ -482,7 +474,7 @@ function ListItem({
482}474}
483475
484function fileIcon(file: MediaFile, dirOpen?: boolean) {476function fileIcon(file: MediaFile, dirOpen?: boolean) {
485 if (file.kind === MediaFile.Kind.directory) {477 if (file.kind === MediaFileKind.directory) {
486 return dirOpen ? "dir-open" : "dir";478 return dirOpen ? "dir-open" : "dir";
487 }479 }
488 if (file.basename === "readme.txt") {480 if (file.basename === "readme.txt") {
...@@ -712,7 +704,7 @@ function TextView({...@@ -712,7 +704,7 @@ function TextView({
712 dangerouslySetInnerHTML={{704 dangerouslySetInnerHTML={{
713 __html: highlightLinksInTextView(705 __html: highlightLinksInTextView(
714 contents,706 contents,
715 siblingFiles.filter((f) => f.kind === MediaFile.Kind.file),707 siblingFiles.filter((f) => f.kind === MediaFileKind.file),
716 ),708 ),
717 }}709 }}
718 ></pre>710 ></pre>
...@@ -752,7 +744,7 @@ function CodeView({ file }: { file: MediaFile }) {...@@ -752,7 +744,7 @@ function CodeView({ file }: { file: MediaFile }) {
752}744}
753745
754function VideoView({ file }: { file: MediaFile }) {746function VideoView({ file }: { file: MediaFile }) {
755 useInlineScript("video_player");747 addScript("@/tags/hls-polyfill.client.ts");
756 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };748 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
757 return (749 return (
758 <>750 <>
...@@ -768,7 +760,7 @@ function VideoView({ file }: { file: MediaFile }) {...@@ -768,7 +760,7 @@ function VideoView({ file }: { file: MediaFile }) {
768 >760 >
769 <source761 <source
770 src={"/file" + file.path}762 src={"/file" + file.path}
771 type={contentTypeFromExt(file.path)}763 type={mime.contentTypeFor(file.path)}
772 />764 />
773 </video>765 </video>
774 </>766 </>
...@@ -791,7 +783,7 @@ function AudioView({...@@ -791,7 +783,7 @@ function AudioView({
791 // - there is lyrics.txt in the same directory783 // - there is lyrics.txt in the same directory
792 const audioFiles = siblings.filter(784 const audioFiles = siblings.filter(
793 (f) =>785 (f) =>
794 f.kind === MediaFile.Kind.file &&786 f.kind === MediaFileKind.file &&
795 extensionToViewer[path.extname(f.basename)] === AudioView,787 extensionToViewer[path.extname(f.basename)] === AudioView,
796 );788 );
797 if (789 if (
...@@ -967,12 +959,12 @@ function ReadmeView({...@@ -967,12 +959,12 @@ function ReadmeView({
967 extra?: any;959 extra?: any;
968}) {960}) {
969 // let showFileCandidates = siblingFiles.filter(f =>961 // let showFileCandidates = siblingFiles.filter(f =>
970 // f.kind === MediaFile.Kind.file962 // f.kind === MediaFileKind.file
971 // && (extensionToViewer[path.extname(f.basename)] === AudioView963 // && (extensionToViewer[path.extname(f.basename)] === AudioView
972 // || extensionToViewer[path.extname(f.basename)] === VideoView)964 // || extensionToViewer[path.extname(f.basename)] === VideoView)
973 // );965 // );
974 return (966 return (
975 <div class="content readme" data-testid={TestID.Readme}>967 <div class="content readme">
976 {/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */}968 {/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */}
977 <TextView file={file} siblingFiles={siblingFiles} />969 <TextView file={file} siblingFiles={siblingFiles} />
978 {extra}970 {extra}
...@@ -986,18 +978,6 @@ function simplifyFraction(n: number, d: number) {...@@ -986,18 +978,6 @@ function simplifyFraction(n: number, d: number) {
986 return `${n / divisor}/${d / divisor}`;978 return `${n / divisor}/${d / divisor}`;
987}979}
988980
989export function contentTypeFromExt(src: string) {
990 src = src.toLowerCase();
991 if (src.endsWith(".m3u8")) return "application/x-mpegURL";
992 if (src.endsWith(".webm")) return "video/webm";
993 if (src.endsWith(".mp4")) return "video/mp4";
994 if (src.endsWith(".mkv")) return "video/x-matroska";
995 if (src.endsWith(".avi")) return "video/x-msvideo";
996 if (src.endsWith(".mov")) return "video/quicktime";
997 if (src.endsWith(".ogg")) return "video/ogg";
998 throw new Error("Unknown video extension: " + path.extname(src));
999}
1000
1001// Add class properties to the components981// Add class properties to the components
1002AudioView.class = "audio";982AudioView.class = "audio";
1003ImageView.class = "image";983ImageView.class = "image";
...@@ -1005,3 +985,19 @@ VideoView.class = "video";...@@ -1005,3 +985,19 @@ VideoView.class = "video";
1005TextView.class = "text";985TextView.class = "text";
1006DownloadView.class = "download";986DownloadView.class = "download";
1007CodeView.class = "code";987CodeView.class = "code";
988
989import "./clofi.css";
990import assert from "node:assert";
991import path, { dirname } from "node:path";
992import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
993import { addScript } from "#sitegen";
994import {
995 escapeUri,
996 formatDuration,
997 formatSize,
998 highlightConvo,
999 highlightHashComments,
1000 highlightLinksInTextView,
1001} from "@/file-viewer/format.ts";
1002import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
1003import * as mime from "#sitegen/mime";
src/friend-auth.ts+1-1
...@@ -72,4 +72,4 @@ app.post("/friends", async (c) => {...@@ -72,4 +72,4 @@ app.post("/friends", async (c) => {
72import { type Context, Hono } from "hono"; 72import { type Context, Hono } from "hono";
73import { serveAsset } from "#sitegen/assets"; 73import { serveAsset } from "#sitegen/assets";
74import { setTimeout } from "node:timers/promises"; 74import { setTimeout } from "node:timers/promises";
75import { getConnInfo } from "hono/bun"; 75import { getConnInfo } from "#hono/conninfo";
src/pages/friends/auth.fail.marko+2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1export const meta = { title: "password required" };
2
1<main>3<main>
2 <p>incorrect or outdated password</p>4 <p>incorrect or outdated password</p>
3 <p>please contact clover</p>5 <p>please contact clover</p>
src/pages/friends/auth.marko+2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1export const meta = { title: "password required" };
2
1<main>3<main>
2 <form method="post" action="/friends">4 <form method="post" action="/friends">
3 <h1>what's the password</h1>5 <h1>what's the password</h1>
src/pages/waterfalls.tsx+3
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { Video } from "@/tags/Video.tsx";1import { Video } from "@/tags/Video.tsx";
2
3export const meta = { title: "waterfalls by paper clover" };
4
2export default () => (5export default () => (
3 <main class="waterfalls">6 <main class="waterfalls">
4 <div class="waterfalls_bg1" />7 <div class="waterfalls_bg1" />
src/q+a/scripts/editor.client.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { EditorState } from "@codemirror/state"; 1import { EditorState } from "@codemirror/state";
2import { basicSetup, EditorView } from "codemirror"; 2import { basicSetup, EditorView } from "codemirror";
3import { ssrSync } from "#ssr"; 3import { ssrSync } from "#ssr";
4import { ScriptPayload } from "@/q+a/view/editor.marko"; 4import type { ScriptPayload } from "@/q+a/views/editor.marko";
5import QuestionRender from "@/q+a/tags/question.marko"; 5import QuestionRender from "@/q+a/tags/question.marko";
6 6
7declare const payload: ScriptPayload; 7declare const payload: ScriptPayload;
src/q+a/views/editor.marko+1-1
...@@ -18,7 +18,7 @@ export interface ScriptPayload {...@@ -18,7 +18,7 @@ export interface ScriptPayload {
1818
19<const/{ question } = input />19<const/{ question } = input />
2020
21<const/payload= {21<const/payload = {
22 id: question.id,22 id: question.id,
23 qmid: question.qmid,23 qmid: question.qmid,
24 text: question.text,24 text: question.text,
src/q+a/views/image-embed.marko+1-1
...@@ -13,4 +13,4 @@ export interface Input {...@@ -13,4 +13,4 @@ export interface Input {
13 <question question=input.question /> 13 <question question=input.question />
14</main> 14</main>
15 15
16import { Question } from '@/q+a/models/Question'; 16import { Question } from '@/q+a/models/Question.ts';
src/tags/PhotoGrid.tsx+1
...@@ -40,6 +40,7 @@ export function PhotoGrid({...@@ -40,6 +40,7 @@ export function PhotoGrid({
40 heights,40 heights,
41 items,41 items,
42}: PhotoGridProps) {42}: PhotoGridProps) {
43 return;
43 if (!base.endsWith("/")) base += "/";44 if (!base.endsWith("/")) base += "/";
44 let rows: boolean[][] = [];45 let rows: boolean[][] = [];
45 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));46 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
src/tags/Video.tsx+4-2
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import "./video.css";
1import * as path from "node:path"; 2import * as path from "node:path";
2import { addScript } from "#sitegen"; 3import { addScript } from "#sitegen";
3import { PrecomputedBlurhash } from "./blurhash.tsx"; 4import { PrecomputedBlurhash } from "./blurhash.tsx";
4import "./Video.css"; 5
5export namespace Video { 6export namespace Video {
6 export interface Props { 7 export interface Props {
7 title: string; 8 title: string;
...@@ -14,11 +15,12 @@ export namespace Video {...@@ -14,11 +15,12 @@ export namespace Video {
14 borderless?: boolean; 15 borderless?: boolean;
15 } 16 }
16} 17}
18
17export function Video( 19export function Video(
18 { title, sources, height, poster, posterHash, width, borderless }: 20 { title, sources, height, poster, posterHash, width, borderless }:
19 Video.Props, 21 Video.Props,
20) { 22) {
21 addScript("./video.client.ts"); 23 addScript("./hls-polyfill.client.ts");
22 return ( 24 return (
23 <figure class={`video ${borderless ? "borderless" : ""}`}> 25 <figure class={`video ${borderless ? "borderless" : ""}`}>
24 <figcaption>{title}</figcaption> 26 <figcaption>{title}</figcaption>
src/tags/hls-polyfill.client.ts created+29
...@@ -0,0 +1,29 @@
1import type Hls from "hls.js";
2
3let cachedHls: null | Promise<typeof Hls | false> | typeof Hls | false = null;
4
5const loadHls = () =>
6 cachedHls ?? (cachedHls = (
7 // @ts-ignore
8 +"HLS POLYFILL", import("hls.js")
9 ).then((m, y = m.default) => cachedHls = y.isSupported() && y));
10
11const createVideoPlayer = async (videoElement: HTMLVideoElement) => {
12 const sources = videoElement.querySelectorAll("source");
13 for (let i = 0; i < sources.length; i++) {
14 const { src, type } = sources[i];
15 if (videoElement.canPlayType(type)) {
16 videoElement.src = src;
17 return;
18 }
19
20 if (type === "application/x-mpegURL" && await loadHls()) {
21 const hls = new (cachedHls as typeof Hls)();
22 hls.loadSource(src);
23 hls.attachMedia(videoElement);
24 return;
25 }
26 }
27};
28
29document.querySelectorAll("video").forEach(createVideoPlayer);
src/tags/video.client.ts deleted-29
...@@ -1,29 +0,0 @@
1import type Hls from "hls.js";
2
3let cachedHls: null | Promise<typeof Hls | false> | typeof Hls | false = null;
4
5const loadHls = () =>
6 cachedHls ?? (cachedHls = (
7 // @ts-ignore
8 +"HLS POLYFILL", import("hls.js")
9 ).then((m, y = m.default) => cachedHls = y.isSupported() && y));
10
11const createVideoPlayer = async (videoElement: HTMLVideoElement) => {
12 const sources = videoElement.querySelectorAll("source");
13 for (let i = 0; i < sources.length; i++) {
14 const { src, type } = sources[i];
15 if (videoElement.canPlayType(type)) {
16 videoElement.src = src;
17 return;
18 }
19
20 if (type === "application/x-mpegURL" && await loadHls()) {
21 const hls = new (cachedHls as typeof Hls)();
22 hls.loadSource(src);
23 hls.attachMedia(videoElement);
24 return;
25 }
26 }
27};
28
29document.querySelectorAll("video").forEach(createVideoPlayer);
src/tags/video.css created+25
...@@ -0,0 +1,25 @@
1.video {
2 border: 4px solid var(--fg);
3 display: flex;
4 flex-direction: column;
5 position: relative;
6}
7
8.video > img,
9.video > span {
10 position: absolute;
11 left: 0;
12 top: 0;
13 width: 100%;
14 height: 100%;
15 z-index: -1;
16}
17
18.video figcaption {
19 background-color: var(--fg);
20 color: var(--bg);
21 width: 100%;
22 margin-top: -1px;
23 padding-bottom: 2px;
24}
25