| author | |
| committer | |
| log | 579f44c68f4fd91813fbb5adcac82a4736b6c12d |
| tree | 69de6288f87dc5ea9528563f49c59391fe0c535d |
| parent | 00306a3d8c31b6a24d62794b40df738acd543577 |
| signature | Commit is signed but in an unrecognized format. |
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 | ]; |
| 29 | 29 | ||
| 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:")); | ||
| 70 | 75 | ||
| 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 | |||
| 298 | import * as esbuild from "esbuild"; | 312 | import * as esbuild from "esbuild"; |
| 299 | import * as path from "node:path"; | 313 | import * as path from "node:path"; |
| 300 | import process from "node:process"; | 314 | import process from "node:process"; |
| 301 | import * as hot from "./hot.ts"; | 315 | import * as hot from "./hot.ts"; |
| 302 | import { | 316 | import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts"; |
| 303 | banFiles, | ||
| 304 | projectRelativeResolution, | ||
| 305 | virtualFiles, | ||
| 306 | } from "./esbuild-support.ts"; | ||
| 307 | import { Incremental } from "./incremental.ts"; | 317 | import { Incremental } from "./incremental.ts"; |
| 308 | import * as css from "./css.ts"; | 318 | import * as css from "./css.ts"; |
| 309 | import * as fs from "#sitegen/fs"; | 319 | import * as fs from "#sitegen/fs"; |
| 320 | import * 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 | */ |
| 88 | export type Component = ( | 88 | export 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 @@ |
| 1 | const entries = fs.readFileSync( | 1 | declare const MIME_INLINE_DATA: never; |
| 2 | path.join(import.meta.dirname, "mime.txt"), | 2 | const 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]); |
| 9 | export const rawEntriesText = entries; | ||
| 9 | const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); | 10 | const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); |
| 10 | const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); | 11 | const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); |
| 11 | 12 |
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 | } |
| 54 | 61 |
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..."); |
| 11 | const { hot } = await import("./run.js"); // get plugins ready | 11 | const { hot } = await import("./run.js"); // get plugins ready |
| 12 | const { errorAllWidgets } = hot.load("@paperclover/console/Widget"); | 12 | const { errorAllWidgets } = hot.load("@paperclover/console/Widget"); |
| 13 | process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r"); | 13 | process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r"); |
| 14 | hot.load("node:repl").start({ | 14 | const 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 | }); |
| 28 | repl.setupHistory(".clover/repl-history.txt", () => {}); | ||
| 28 | 29 | ||
| 29 | setTimeout(() => { | 30 | setTimeout(() => { |
| 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)))); |
| 10 | app.use(admin.middleware); | 10 | app.use(admin.middleware); |
| 11 | 11 | ||
| 12 | app.route("", require("./q+a/backend.ts").app); | 12 | app.route("", require("./q+a/backend.ts").app); |
| 13 | // app.route("", require("./file-viewer/backend.tsx").app); | 13 | app.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 | ||
| 16 | app.use(assets.middleware); | 16 | app.use(assets.middleware); |
src/blog/helpers.ts createdsrc/blog/layout.tsx createdsrc/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 | }; |
| 7 | export const meta = formatBlogMeta(blob); | 7 | export const meta = formatBlogMeta(blob); |
| 8 | export * as layout from "@/blog/layout.tsx"; | ||
| 8 | 9 | ||
| 9 | I've been recently playing around [Marko][1], and after adding limited support | 10 | I've been recently playing around [Marko][1], and after adding limited support |
| 10 | for it in my website generator, [sitegen][2], I instantly fell in love with how | 11 | for 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 @@ |
| 1 | import { type Context, Hono } from "hono"; | 1 | export const app = new Hono(); |
| 2 | import * as path from "node:path"; | ||
| 3 | import { etagMatches, serveAsset } from "../assets.ts"; | ||
| 4 | import { FilePermissions, MediaFile } from "../db.ts"; | ||
| 5 | import { renderDynamicPage } from "../framework/dynamic-pages.ts"; | ||
| 6 | import { renderToStringSync } from "../framework/render-to-string.ts"; | ||
| 7 | import { MediaPanel } from "../pages-dynamic/file_viewer.tsx"; | ||
| 8 | import mimeTypeDb from "./mime.json" with { type: "json" }; | ||
| 9 | import { Speedbump } from "./cotyledon.tsx"; | ||
| 10 | import { hasAsset } from "../assets.ts"; | ||
| 11 | import { CompressionFormat, fetchFile, prefetchFile } from "./cache.ts"; | ||
| 12 | import { requireFriendAuth } from "../journal/backend.ts"; | ||
| 13 | |||
| 14 | const app = new Hono(); | ||
| 15 | 2 | ||
| 16 | interface APIDirectoryList { | 3 | interface 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 | } |
| 87 | 74 | ||
| 88 | // File listings | 75 | // 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 | } |
| 381 | 368 | ||
| 382 | function mimeType(file: string) { | ||
| 383 | return (mimeTypeDb as any)[path.extname(file)] ?? "application/octet-stream"; | ||
| 384 | } | ||
| 385 | |||
| 386 | function getPartialPage(c: Context, rawFilePath: string) { | 369 | function 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; |
| 392 | 375 | ||
| 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; |
| 420 | 403 | ||
| 421 | const html = renderToStringSync(root); | 404 | const html = ssr.ssrSync(root).text; |
| 422 | return c.html(html); | 405 | return c.html(html); |
| 423 | } | 406 | } |
| 424 | 407 | ||
| 425 | export { app as mediaApp }; | 408 | import { type Context, Hono } from "hono"; |
| 409 | |||
| 410 | import * as ssr from "#ssr"; | ||
| 411 | import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets"; | ||
| 412 | import { renderView } from "#sitegen/view"; | ||
| 413 | import { contentTypeFor } from "#sitegen/mime"; | ||
| 414 | |||
| 415 | import { requireFriendAuth } from "@/friend-auth.ts"; | ||
| 416 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; | ||
| 417 | import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts"; | ||
| 418 | import { MediaPanel } from "@/file-viewer/views/clofi.tsx"; | ||
| 419 | import { Speedbump } from "@/file-viewer/cotyledon.tsx"; | ||
| 420 | import { | ||
| 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 @@ | ||
| 1 | import "@/file-viewer/models/MediaFile.ts"; | ||
| 2 | import "@/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"; |
| 3 | import * as path from "node:path"; | 3 | import * as path from "node:path"; |
| 4 | import { Buffer } from "node:buffer"; | 4 | import { Buffer } from "node:buffer"; |
| 5 | import type { ClientRequest } from "node:http"; | 5 | import type { ClientRequest } from "node:http"; |
| 6 | import { LRUCache } from "lru-cache"; | 6 | import LRUCache from "lru-cache"; |
| 7 | import { open } from "node:fs/promises"; | 7 | import { open } from "node:fs/promises"; |
| 8 | import { createHash } from "node:crypto"; | 8 | import { createHash } from "node:crypto"; |
| 9 | import { scoped } from "@paperclover/console"; | 9 | import { scoped } from "@paperclover/console"; |
| 10 | import { escapeUri } from "./share.ts"; | 10 | import { escapeUri } from "./format.ts"; |
| 11 | 11 | ||
| 12 | declare const Deno: any; | 12 | declare const Deno: any; |
| 13 | 13 | ||
| 14 | const sourceOfTruth = "https://nas.paperclover.net:43250"; | 14 | const sourceOfTruth = "https://nas.paperclover.net:43250"; |
| 15 | const caCert = fs.readFileSync(path.join(import.meta.dirname, "cert.pem")); | 15 | const caCert = fs.readFileSync("src/file-viewer/cert.pem"); |
| 16 | 16 | ||
| 17 | const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/"); | 17 | const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/"); |
| 18 | const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB | 18 | const 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 | } |
| 138 | 138 | ||
| 139 | export function ForEveryone() { | 139 | export function ForEveryone() { |
| 140 | // deno-fmt-ignore | 140 | 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 |
| 264 | okay.. oof... its still clouding my mind<br/> | 264 | one of the first people to ever see this. |
| 265 | i 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 /> |
| 277 | i'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> | ||
| 279 | 330 | ||
| 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 | } |
| 291 | ForEveryone.class = "text"; | 351 | ForEveryone.class = "text"; |
src/file-viewer/format.ts+14-19| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | const findDomain = "paperclover.net"; | ||
| 2 | |||
| 1 | export function formatSize(bytes: number) { | 3 | export 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 | |||
| 9 | export function formatDate(date: Date) { | 12 | export function formatDate(date: Date) { |
| 10 | // YYYY-MM-DD, format in PST timezone | 13 | // 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 | |||
| 13 | export function formatShortDate(date: Date) { | 17 | export function formatShortDate(date: Date) { |
| 14 | // YY-MM-DD, format in PST timezone | 18 | // YY-MM-DD, format in PST timezone |
| 15 | return formatDate(date).slice(2); | 19 | return formatDate(date).slice(2); |
| 16 | } | 20 | } |
| 21 | |||
| 17 | export function formatDuration(seconds: number) { | 22 | export 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 | } |
| 22 | export const escapeUri = (uri: string) => | 27 | |
| 23 | encodeURIComponent(uri) | 28 | export 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, ","); |
| 32 | 38 | } | |
| 33 | import type { MediaFile } from "../db.ts"; | ||
| 34 | import { escapeHTML } from "../framework/bun-polyfill.ts"; | ||
| 35 | const findDomain = "paperclover.net"; | ||
| 36 | 39 | ||
| 37 | // Returns escaped HTML | 40 | // 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 basename | 46 | // - 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. |
| 47 | export function highlightLinksInTextView( | 50 | export 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 | ); |
| 56 | 59 | ||
| 57 | // First escape the HTML to prevent XSS | 60 | // First escape the HTML to prevent XSS |
| 58 | let processedText = escapeHTML(text); | 61 | let processedText = escapeHtml(text); |
| 59 | 62 | ||
| 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 paths | 119 | // 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 | } |
| 125 | 126 | ||
| 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].path | 128 | const currentDir = siblingFiles[0].path |
| 129 | .split("/") | 129 | .split("/") |
| ... | @@ -138,21 +138,13 @@ export function highlightLinksInTextView( | ... | @@ -138,21 +138,13 @@ export function highlightLinksInTextView( |
| 138 | 138 | ||
| 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(/(<[^>]*>)/); |
| 153 | 146 | ||
| 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 | |||
| 258 | import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | ||
| 259 | import { escapeHtml } from "#ssr"; |
src/file-viewer/models/BlobAsset.ts+57| ... | @@ -0,0 +1,57 @@ | ... | @@ -0,0 +1,57 @@ |
| 1 | const db = getDb("cache.sqlite"); | ||
| 2 | db.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 | */ | ||
| 19 | export 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 | |||
| 43 | const getQuery = db.prepare<[hash: string]>(/* SQL */ ` | ||
| 44 | SELECT * FROM blob_assets WHERE hash = ?; | ||
| 45 | `).as(BlobAsset); | ||
| 46 | const 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 | `); | ||
| 50 | const decrementQuery = db.prepare<[hash: string]>(/* SQL */ ` | ||
| 51 | UPDATE blob_assets SET refs = refs - 1 WHERE hash = ? AND refs > 0; | ||
| 52 | `); | ||
| 53 | const deleteQuery = db.prepare<[hash: string]>(/* SQL */ ` | ||
| 54 | DELETE FROM blob_assets WHERE hash = ? AND refs <= 0; | ||
| 55 | `); | ||
| 56 | |||
| 57 | import { getDb } from "#sitegen/sqlite"; |
src/file-viewer/models/FilePermission.ts deletedsrc/file-viewer/models/FilePermissions.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | const db = getDb("cache.sqlite"); | ||
| 2 | |||
| 3 | db.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 | ); | ||
| 12 | export 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 | |||
| 35 | const 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 | `); | ||
| 45 | const getExactQuery = db.prepare< | ||
| 46 | [file: string], | ||
| 47 | Pick<FilePermissions, "allow"> | ||
| 48 | >(/* SQL */ ` | ||
| 49 | SELECT allow FROM permissions WHERE ? == prefix | ||
| 50 | `); | ||
| 51 | |||
| 52 | const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ ` | ||
| 53 | REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow); | ||
| 54 | `); | ||
| 55 | const deleteQuery = db.prepare<[file: string]>(/* SQL */ ` | ||
| 56 | DELETE FROM permissions WHERE prefix = ?; | ||
| 57 | `); | ||
| 58 | |||
| 59 | import { getDb } from "#sitegen/sqlite"; | ||
src/file-viewer/models/MediaFile.ts+336| ... | @@ -0,0 +1,336 @@ | ... | @@ -0,0 +1,336 @@ |
| 1 | const db = getDb("cache.sqlite"); | ||
| 2 | db.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 | |||
| 28 | export enum MediaFileKind { | ||
| 29 | directory = 0, | ||
| 30 | file = 1, | ||
| 31 | } | ||
| 32 | export 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. | ||
| 231 | interface 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 | ||
| 242 | export interface MarkDirectoryProcessed { | ||
| 243 | id: number; | ||
| 244 | timestamp: Date; | ||
| 245 | contents: string; | ||
| 246 | size: number; | ||
| 247 | hash: string; | ||
| 248 | dirsort: null | string[]; | ||
| 249 | } | ||
| 250 | |||
| 251 | export 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 | ||
| 259 | const 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 | ); | ||
| 269 | const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ ` | ||
| 270 | SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory}; | ||
| 271 | `); | ||
| 272 | const 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 | `); | ||
| 300 | const markCompressedQuery = db.prepare<[{ | ||
| 301 | id: number; | ||
| 302 | processed: number; | ||
| 303 | }]>(/* SQL */ ` | ||
| 304 | update media_files set processed = $processed where id = $id; | ||
| 305 | `); | ||
| 306 | const getByPathQuery = db.prepare<[string]>(/* SQL */ ` | ||
| 307 | select * from media_files where path = ?; | ||
| 308 | `).as(MediaFile); | ||
| 309 | const 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 | `); | ||
| 326 | const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ ` | ||
| 327 | update media_files set processed = 0 where id = ?; | ||
| 328 | `); | ||
| 329 | |||
| 330 | const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ ` | ||
| 331 | select * from media_files where parent_id = ?; | ||
| 332 | `).as(MediaFile); | ||
| 333 | |||
| 334 | import { getDb } from "#sitegen/sqlite"; | ||
| 335 | import * as path from "node:path"; | ||
| 336 | import { FilePermissions } from "./FilePermissions.ts"; |
src/file-viewer/pages/file.cotyledon_enterance.tsx+7-7| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { MediaFile } from "../db"; | 1 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; |
| 2 | import { useInlineScript } from "../framework/page-resources"; | 2 | import { addScript } from "#sitegen"; |
| 3 | import { Readme } from "../media/cotyledon"; | 3 | import { Readme } from "@/file-viewer/cotyledon.tsx"; |
| 4 | import { MediaPanel } from "../pages-dynamic/file_viewer"; | 4 | import { MediaPanel } from "../views/clofi.tsx"; |
| 5 | import "../media/files.css"; | ||
| 6 | 5 | ||
| 7 | export const theme = { | 6 | export 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 | }; |
| 12 | 11 | ||
| 12 | export const meta = { title: "living room" }; | ||
| 13 | |||
| 13 | export default function CotyledonPage() { | 14 | export 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 | <MediaPanel | 18 | <MediaPanel |
src/file-viewer/pages/file.cotyledon_speedbump.tsx+7-7| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { MediaFile } from "../db"; | 1 | import { MediaFile } from "../models/MediaFile.ts"; |
| 2 | import { useInlineScript } from "../framework/page-resources"; | 2 | import { Speedbump } from "../cotyledon.tsx"; |
| 3 | import { Speedbump } from "../media/cotyledon"; | 3 | import { MediaPanel } from "../views/clofi.tsx"; |
| 4 | import { MediaPanel } from "../pages-dynamic/file_viewer"; | 4 | import { addScript } from "#sitegen"; |
| 5 | import "../media/files.css"; | ||
| 6 | 5 | ||
| 7 | export const theme = { | 6 | export 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 | }; |
| 12 | 11 | ||
| 12 | export const meta = { title: "the front door" }; | ||
| 13 | |||
| 13 | export default function CotyledonPage() { | 14 | export 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 | <MediaPanel | 18 | <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 */ | ||
| 4 | const extScrubExif = new Set([ | ||
| 5 | ".jpg", | ||
| 6 | ".jpeg", | ||
| 7 | ".png", | ||
| 8 | ".mov", | ||
| 9 | ".mp4", | ||
| 10 | ".m4a", | ||
| 11 | ]); | ||
| 12 | /** Extensions that rendered syntax-highlighted code */ | ||
| 13 | const 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. */ | ||
| 39 | const extsAudio = new Set([ | ||
| 40 | ".mp3", | ||
| 41 | ".flac", | ||
| 42 | ".wav", | ||
| 43 | ".ogg", | ||
| 44 | ".m4a", | ||
| 45 | ]); | ||
| 46 | /** These files show a video embed. */ | ||
| 47 | const extsVideo = new Set([ | ||
| 48 | ".mp4", | ||
| 49 | ".mkv", | ||
| 50 | ".webm", | ||
| 51 | ".avi", | ||
| 52 | ".mov", | ||
| 53 | ]); | ||
| 54 | /** These files show an image embed */ | ||
| 55 | const 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` */ | ||
| 67 | export const extsDuration = new Set([...extsAudio, ...extsVideo]); | ||
| 68 | /** These files populate `dimensions` using `ffprobe` */ | ||
| 69 | export const extsDimensions = new Set([...extsImage, ...extsVideo]); | ||
| 70 | |||
| 71 | /** These files read file contents into `contents`, as-is */ | ||
| 72 | export const extsReadContents = new Set([".txt", ".chat"]); | ||
| 73 | |||
| 74 | export 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 | */ | ||
| 88 | export const extsHaveCompression = new Set([ | ||
| 89 | ...extsAudio, | ||
| 90 | ...extsVideo, | ||
| 91 | ...extsImage, | ||
| 92 | ...extsArchive, | ||
| 93 | // TODO: are any of these NOT good for compression | ||
| 94 | ]); | ||
| 95 | |||
| 96 | export 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 -- | ||
| 130 | const 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 | |||
| 141 | import type * as highlight from "./highlight.ts"; | ||
| 142 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; | ||
| 143 | import * 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 | ||
| 3 | globalThis.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 | ||
| 2 | globalThis.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 @@ |
| 1 | import "./clofi.css"; | ||
| 2 | import assert from "node:assert"; | ||
| 3 | import path, { dirname } from "node:path"; | ||
| 4 | import { MediaFile } from "../db.ts"; | ||
| 5 | import { useInlineScript } from "../framework/page-resources.ts"; | ||
| 6 | import { escapeUri, formatDuration, formatSize } from "../media/share.ts"; | ||
| 7 | import { | ||
| 8 | highlightConvo, | ||
| 9 | highlightHashComments, | ||
| 10 | highlightLinksInTextView, | ||
| 11 | } from "../media/text-formatting.ts"; | ||
| 12 | import { TestID } from "../test/id.ts"; | ||
| 13 | import { ForEveryone } from "../media/cotyledon.tsx"; | ||
| 14 | |||
| 15 | export const theme = { | 1 | export const theme = { |
| 16 | bg: "#312652", | 2 | bg: "#312652", |
| 17 | fg: "#f0f0ff", | 3 | fg: "#f0f0ff", |
| 18 | primary: "#fabe32", | 4 | primary: "#fabe32", |
| 19 | }; | 5 | }; |
| 20 | 6 | ||
| 7 | export 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 | ||
| 21 | export const extensionToViewer: { | 13 | export 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"); |
| 82 | 74 | ||
| 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.directory | 125 | {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 | <div | 182 | <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> |
| 212 | 204 | ||
| 213 | {file.kind === MediaFile.Kind.directory | 205 | {file.kind === MediaFileKind.directory |
| 214 | ? ( | 206 | ? ( |
| 215 | <DirView | 207 | <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) { |
| 276 | 268 | ||
| 277 | // Then sort directories before files | 269 | // 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 | } |
| 281 | 273 | ||
| 282 | // Finally sort by date (newest first), then by name (a-z) if dates are the same | 274 | // 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({ |
| 433 | 425 | ||
| 434 | let basenameWithoutExt = file.basenameWithoutExt; | 426 | let basenameWithoutExt = file.basenameWithoutExt; |
| 435 | 427 | ||
| 436 | let meta = file.kind === MediaFile.Kind.directory | 428 | let meta = file.kind === MediaFileKind.directory |
| 437 | ? formatSize(file.size) | 429 | ? formatSize(file.size) |
| 438 | : (file.duration ?? 0) > 0 | 430 | : (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.directory | 463 | {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 | } |
| 483 | 475 | ||
| 484 | function fileIcon(file: MediaFile, dirOpen?: boolean) { | 476 | function 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 | } |
| 753 | 745 | ||
| 754 | function VideoView({ file }: { file: MediaFile }) { | 746 | function 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 | <source | 761 | <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 directory | 783 | // - 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.file | 962 | // f.kind === MediaFileKind.file |
| 971 | // && (extensionToViewer[path.extname(f.basename)] === AudioView | 963 | // && (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 | } |
| 988 | 980 | ||
| 989 | export 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 components | 981 | // Add class properties to the components |
| 1002 | AudioView.class = "audio"; | 982 | AudioView.class = "audio"; |
| 1003 | ImageView.class = "image"; | 983 | ImageView.class = "image"; |
| ... | @@ -1005,3 +985,19 @@ VideoView.class = "video"; | ... | @@ -1005,3 +985,19 @@ VideoView.class = "video"; |
| 1005 | TextView.class = "text"; | 985 | TextView.class = "text"; |
| 1006 | DownloadView.class = "download"; | 986 | DownloadView.class = "download"; |
| 1007 | CodeView.class = "code"; | 987 | CodeView.class = "code"; |
| 988 | |||
| 989 | import "./clofi.css"; | ||
| 990 | import assert from "node:assert"; | ||
| 991 | import path, { dirname } from "node:path"; | ||
| 992 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; | ||
| 993 | import { addScript } from "#sitegen"; | ||
| 994 | import { | ||
| 995 | escapeUri, | ||
| 996 | formatDuration, | ||
| 997 | formatSize, | ||
| 998 | highlightConvo, | ||
| 999 | highlightHashComments, | ||
| 1000 | highlightLinksInTextView, | ||
| 1001 | } from "@/file-viewer/format.ts"; | ||
| 1002 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; | ||
| 1003 | import * 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) => { |
| 72 | import { type Context, Hono } from "hono"; | 72 | import { type Context, Hono } from "hono"; |
| 73 | import { serveAsset } from "#sitegen/assets"; | 73 | import { serveAsset } from "#sitegen/assets"; |
| 74 | import { setTimeout } from "node:timers/promises"; | 74 | import { setTimeout } from "node:timers/promises"; |
| 75 | import { getConnInfo } from "hono/bun"; | 75 | import { getConnInfo } from "#hono/conninfo"; |
src/pages/friends/auth.fail.marko+2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | export 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 @@ |
| 1 | export 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 @@ |
| 1 | import { Video } from "@/tags/Video.tsx"; | 1 | import { Video } from "@/tags/Video.tsx"; |
| 2 | |||
| 3 | export const meta = { title: "waterfalls by paper clover" }; | ||
| 4 | |||
| 2 | export default () => ( | 5 | export 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 @@ |
| 1 | import { EditorState } from "@codemirror/state"; | 1 | import { EditorState } from "@codemirror/state"; |
| 2 | import { basicSetup, EditorView } from "codemirror"; | 2 | import { basicSetup, EditorView } from "codemirror"; |
| 3 | import { ssrSync } from "#ssr"; | 3 | import { ssrSync } from "#ssr"; |
| 4 | import { ScriptPayload } from "@/q+a/view/editor.marko"; | 4 | import type { ScriptPayload } from "@/q+a/views/editor.marko"; |
| 5 | import QuestionRender from "@/q+a/tags/question.marko"; | 5 | import QuestionRender from "@/q+a/tags/question.marko"; |
| 6 | 6 | ||
| 7 | declare const payload: ScriptPayload; | 7 | declare 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 { |
| 18 | 18 | ||
| 19 | <const/{ question } = input /> | 19 | <const/{ question } = input /> |
| 20 | 20 | ||
| 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 | ||
| 16 | import { Question } from '@/q+a/models/Question'; | 16 | import { 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 @@ |
| 1 | import "./video.css"; | ||
| 1 | import * as path from "node:path"; | 2 | import * as path from "node:path"; |
| 2 | import { addScript } from "#sitegen"; | 3 | import { addScript } from "#sitegen"; |
| 3 | import { PrecomputedBlurhash } from "./blurhash.tsx"; | 4 | import { PrecomputedBlurhash } from "./blurhash.tsx"; |
| 4 | import "./Video.css"; | 5 | |
| 5 | export namespace Video { | 6 | export 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 | |||
| 17 | export function Video( | 19 | export 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 @@ | ||
| 1 | import type Hls from "hls.js"; | ||
| 2 | |||
| 3 | let cachedHls: null | Promise<typeof Hls | false> | typeof Hls | false = null; | ||
| 4 | |||
| 5 | const loadHls = () => | ||
| 6 | cachedHls ?? (cachedHls = ( | ||
| 7 | // @ts-ignore | ||
| 8 | +"HLS POLYFILL", import("hls.js") | ||
| 9 | ).then((m, y = m.default) => cachedHls = y.isSupported() && y)); | ||
| 10 | |||
| 11 | const 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 | |||
| 29 | document.querySelectorAll("video").forEach(createVideoPlayer); | ||
src/tags/video.client.ts deleted-29| ... | @@ -1,29 +0,0 @@ | ||
| 1 | import type Hls from "hls.js"; | ||
| 2 | |||
| 3 | let cachedHls: null | Promise<typeof Hls | false> | typeof Hls | false = null; | ||
| 4 | |||
| 5 | const loadHls = () => | ||
| 6 | cachedHls ?? (cachedHls = ( | ||
| 7 | // @ts-ignore | ||
| 8 | +"HLS POLYFILL", import("hls.js") | ||
| 9 | ).then((m, y = m.default) => cachedHls = y.isSupported() && y)); | ||
| 10 | |||
| 11 | const 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 | |||
| 29 | document.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 | |||