Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
222e01d18e
|
|||
|
384ae63300
|
|||
|
eac8018a83
|
|||
|
de546d6ff0
|
|||
|
9965bd3529
|
|||
|
f0ae0b9ce1
|
|||
|
87d3b3bfa6
|
|||
|
f6873546df
|
|||
|
e7ada63431
|
|||
|
53065a11dc
|
|||
|
2c5784c6e2
|
|||
|
9b626faba8
|
|||
|
153bd0cf39
|
|||
|
162032e3f3
|
|||
|
237cacb612
|
|||
|
f6e9e16227
|
|||
|
db46f7d6ba
|
|||
|
e640e87d3f
|
|||
|
1cd76b03df
|
|||
|
5ac736cad4
|
|||
|
997106eb92
|
|||
|
3f103c3e15
|
|||
|
16f271c1c9
|
|||
|
1a445548f2
|
|||
|
dc7ca40b9b
|
|||
|
14f9ef3ffd
|
|||
|
336c652bf7
|
|||
|
873090310a
|
|||
|
c7762f099c
|
|||
|
c2407408fa
|
|||
|
bab4a516be
|
|||
|
adc1f21204
|
|||
|
99e4e65d92
|
|||
|
11f05e0d6f
|
|||
|
367470b54e
|
|||
|
78f1bc2ef6
|
|||
|
174ca69dcd
|
|||
|
f6f9c15e0c
|
|||
|
16902f00f4
|
|||
|
2c5f64a769
|
|||
|
b2cd74385f
|
|||
|
95081b8b77
|
|||
|
40b6359d8f
|
|||
|
d61080722d
|
|||
|
5117218a1a
|
|||
|
f355373ba1
|
|||
|
384cb82efb
|
|||
|
7ff6f6542b
|
|||
|
9ad08dc85d
|
|||
|
12631dbd42
|
|||
|
1758dc3153
|
|||
|
9496030d41
|
|||
|
30f264a6bb
|
|||
|
7992fcbd49
|
|||
|
60a9fb0339
|
|||
|
6711de5eb6
|
|||
|
f2b4660300
|
|||
|
97608e983c
|
|||
|
ce812e8466
|
|||
|
d44988b39c
|
|||
|
d885ea4e6b
|
|||
|
6cfa4c5b7d
|
|||
|
f2e85dc6d8
|
|||
|
fce17d397e
|
|||
|
7cc954ae07
|
|||
|
c6aa014d29
|
|||
|
a9cbbb7e8e
|
|||
|
788eb84488
|
|||
|
4fc5a07249
|
|||
|
aca5d53bd1
|
|||
|
f1af80afaf
|
|||
|
257000e81d
|
|||
|
8b30228c4a
|
|||
|
99e1cd5639
|
|||
|
7446d8296a
|
|||
|
6b424ae8e4
|
|||
|
04489a53d1
|
|||
|
b40134833b
|
|||
|
e2bf036919
|
|||
|
7443947131
|
|||
|
0589ff9c7c
|
|||
|
0c2e7f505d
|
|||
|
cfbe43ab8b
|
|||
|
b5120b60df
|
|||
|
b6b98023da
|
|||
|
37c63db863
|
|||
|
61cca45350
|
|||
|
4b37d29a43
|
|||
|
d4f51b121e
|
|||
|
2e088c5c9f
|
|||
|
6ef97bb5f7
|
|||
|
bc4ddb7eae
|
|||
|
d69d3a0249
|
|||
|
ee3918f428
|
|||
|
c9ab7a37b9
|
|||
|
935d2a9077
|
|||
|
|
3c067b6c49
|
||
|
|
8bb28cffa6
|
||
|
|
a24fea8f3b
|
||
|
|
8e32f21462
|
||
|
|
de871e775e
|
||
|
|
c89318ddd8
|
||
|
|
b14fd5d7e7
|
||
|
|
3d07b2b714
|
||
|
|
3880e2ab7b
|
||
|
|
72ee036c08
|
||
|
|
d083ec090c
|
||
|
|
05844b2446
|
||
|
|
844c8d49d4
|
||
|
|
de1411b01a
|
||
|
|
bfda37ee0b
|
||
|
|
2777d14007
|
||
|
|
f37688f2d1
|
||
|
|
acad2cc0ca
|
||
|
|
6466602276
|
||
|
|
d657951158
|
||
|
|
6a35f48097 | ||
|
|
6805fe57d7
|
||
|
|
133c0944bc
|
||
|
|
02290388da
|
||
|
|
b2455cb1e2
|
||
|
|
5f06079b5b
|
||
|
|
5681e4b1ad
|
||
|
|
2519182e86
|
||
|
|
42495f2316
|
||
|
|
035944887b
|
||
|
|
21772ae6cb
|
||
|
|
efe0b9713f
|
||
|
|
f5211cc799
|
||
|
|
b618f6e807
|
||
|
|
d5cbe73c2d
|
||
|
|
d96a27e612
|
||
|
|
b3da439864
|
||
|
|
76ecd1a392 | ||
|
|
deeef2f8a0
|
||
|
|
26877cf18a
|
||
|
|
dfd5b15ed9
|
||
|
|
22c9391c37
|
||
|
|
b90108e70f
|
||
|
|
0ff2116794
|
||
|
|
2fcdf6272e
|
||
|
|
9d7414e0c9
|
||
|
|
93d9b3e014
|
||
|
|
c3bc253182
|
||
|
|
aaf29f45a0
|
||
|
|
502b1a93e1
|
||
|
|
efa4be2fd9
|
||
|
|
6bd0616d54
|
||
|
|
73e6e2c354
|
||
|
|
f96629a6b4
|
||
|
|
4c97f4f52d
|
||
|
|
ef9522cf3e
|
||
|
|
189774def8
|
||
|
|
6fd37f854d
|
||
|
|
55f1ff96d4
|
||
|
|
4b5de24ef6
|
||
|
|
dc4cf3fbbc
|
||
|
|
0a08ef4b0c
|
||
|
|
3e3bc486e2
|
||
|
|
df411e42ce
|
||
|
|
bc6e7a0278
|
||
|
|
1a72c07e82
|
||
|
|
afa9013ff0
|
||
|
|
b459052b44
|
||
|
|
21d1fc9f8c
|
||
|
|
068d2a5c7a
|
||
|
|
e20fc0d197
|
||
|
|
bded192500
|
||
|
|
d813123d95
|
||
|
|
b626ce3abb
|
||
|
|
242e4d8a7f
|
||
|
|
c16f92e576
|
||
|
|
8b33094cef | ||
|
|
6b09180743
|
||
|
|
4d205596fc
|
||
|
|
4f0959f433
|
||
|
|
8bf39116e9
|
||
|
|
2879ab0563
|
||
|
|
3ba0a94793
|
||
|
|
a2555d1940
|
||
|
|
55391f7ee5
|
||
|
|
7c3bd72fa0
|
||
|
|
1cf61969af
|
||
|
|
8d23faf7ad
|
||
|
|
4136bf2622
|
||
|
|
2c9c0b08d0
|
||
|
|
9720e6faf4
|
||
|
|
2acb40c90c
|
||
|
|
b04cd10453
|
||
|
|
b37f35350b
|
||
|
|
773085b2e0
|
||
|
|
6c2b82086a
|
||
|
|
53a3832fc4
|
||
|
|
62fdbf1fc1
|
||
|
|
fbcc0385a4
|
||
|
|
d69b327bb6
|
||
|
|
93a2bf9caa
|
||
|
|
7b0d09f2c9
|
||
|
|
385b237906
|
||
|
|
70c7d03576
|
||
|
|
9f4c069f7f
|
||
|
|
36112fe04e
|
||
|
|
9422553c9c
|
||
|
|
ed1dc91bd7
|
||
|
|
998841e1e7
|
||
|
|
e96e679a35
|
||
|
|
73402aec0b
|
||
|
|
65a46162d7
|
||
|
|
6a6804f43a
|
||
|
|
2fd5c7ec36
|
||
|
|
b87d34410a
|
||
|
|
bceec10c3f
|
||
|
|
f23ddf6e5c
|
||
|
|
c6c5f1c067
|
||
|
|
09365d828a
|
||
|
|
d1684a1472
|
||
|
|
ec1a5103c3
|
||
|
|
e7f70b4c02
|
||
|
|
8b6a760d91
|
||
|
|
56f799266b
|
||
|
|
893c59585e
|
||
|
|
1c255069e7
|
||
|
|
47bbbb01fa
|
||
|
|
71b28b6059
|
||
|
|
9483382799
|
||
|
|
42215fcad4
|
||
|
|
d3c260a0fa
|
||
|
|
cb2ac819e0
|
||
|
|
fde907781a
|
||
|
|
3b7fe795e8
|
||
|
|
1fee9df3a1
|
||
|
|
4f93517f9e
|
||
|
|
9204d1c569
|
||
|
|
8f57e420b5
|
||
|
|
0e534d670d
|
||
|
|
6799028dff
|
||
|
|
219d891c23
|
||
|
|
7820806c26
|
||
|
|
d666c62af1
|
||
|
|
99d518f475
|
||
|
|
4117802d8c
|
||
|
|
a827dba86f
|
||
|
|
34dfde16d5
|
||
|
|
bf43cbe9fd
|
||
|
|
7561484d25
|
||
|
|
34aecb70d5
|
||
|
|
df1e0b5e00
|
||
|
|
d7fdb4866a | ||
|
|
6d7b58d2a9 | ||
|
|
eeee364c93 | ||
|
|
32c0b774a2 | ||
|
|
259d92dfe1 | ||
|
|
974bb7956e | ||
|
|
3c54f2470e | ||
|
|
447098b7ef | ||
|
|
30e6d32b09 | ||
|
|
bba2c6fbb9 | ||
|
|
41f9cd8fd3 | ||
|
|
1723aae6fc | ||
|
|
60b9e62881 | ||
|
|
f134a3c3d5 | ||
|
|
3c371383d5 | ||
|
|
074f13683a | ||
|
|
c9385a50b8 | ||
|
|
76b2b20e2f | ||
|
|
e207049644 | ||
|
|
64718e30e5 | ||
|
|
b26b405eca | ||
|
|
78c96c0aff | ||
|
|
73aecb076f | ||
|
|
9114ee14af | ||
|
|
69f75857be | ||
|
|
8a2f8edb30 | ||
|
|
8cb801f51e | ||
|
|
c66ee24dce | ||
|
|
441b26912f | ||
|
|
4433a1523d | ||
|
|
56934cf46a | ||
|
|
a2cd51deae | ||
|
|
638e90e858 | ||
|
|
ace2695d7f | ||
|
|
dad9d4fd1b | ||
|
|
f39d8aa690 | ||
|
|
98a7a589cc | ||
|
|
ad75d90d9f | ||
|
|
8a9a1335f3 | ||
|
|
bbe25f26e7 | ||
|
|
3c979be288 | ||
|
|
fbbca11032 | ||
|
|
f4e630ee76 | ||
|
|
af2a4c104a | ||
|
|
8e86979394 | ||
|
|
7b875a85c1 | ||
|
|
1f820e4008 | ||
|
|
54361ac79b | ||
|
|
e9ac5400b4 |
BIN
.github/preview.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 77 KiB |
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# astro
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnpm-store
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "public/scripts"]
|
||||||
|
path = public/scripts
|
||||||
|
url = https://github.com/timmypidashev/scripts
|
||||||
6
.stackblitzrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"startCommand": "npm start",
|
||||||
|
"env": {
|
||||||
|
"ENABLE_CJS_IMPORTS": true
|
||||||
|
}
|
||||||
|
}
|
||||||
19
LICENSE
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,2 +1 @@
|
|||||||
# Portfolio
|
<img src=".github/preview.jpeg" title="Preview"/>
|
||||||
My portfolio website!
|
|
||||||
|
|||||||
183
astro.config.mjs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import vercel from "@astrojs/vercel";
|
||||||
|
import tailwind from "@astrojs/tailwind";
|
||||||
|
import react from "@astrojs/react";
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
import rehypePrettyCode from "rehype-pretty-code";
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
output: "server",
|
||||||
|
adapter: vercel(),
|
||||||
|
site: "https://timmypidashev.dev",
|
||||||
|
devToolbar: { enabled: false },
|
||||||
|
build: {
|
||||||
|
// Enable build-time optimizations
|
||||||
|
inlineStylesheets: "auto",
|
||||||
|
// Split large components into smaller chunks
|
||||||
|
splitComponents: true,
|
||||||
|
},
|
||||||
|
integrations: [
|
||||||
|
tailwind(),
|
||||||
|
react(),
|
||||||
|
mdx({
|
||||||
|
syntaxHighlight: false,
|
||||||
|
rehypePlugins: [
|
||||||
|
/**
|
||||||
|
* Adds ids to headings
|
||||||
|
*/
|
||||||
|
rehypeSlug,
|
||||||
|
[
|
||||||
|
/**
|
||||||
|
* Enhances code blocks with syntax highlighting, line numbers,
|
||||||
|
* titles, and allows highlighting specific lines and words
|
||||||
|
*/
|
||||||
|
|
||||||
|
rehypePrettyCode,
|
||||||
|
{
|
||||||
|
theme: {
|
||||||
|
"name": "Darkbox",
|
||||||
|
"type": "dark",
|
||||||
|
"colors": {
|
||||||
|
"editor.background": "#000000",
|
||||||
|
"editor.foreground": "#ebdbb2"
|
||||||
|
},
|
||||||
|
"tokenColors": [
|
||||||
|
{
|
||||||
|
"scope": ["comment", "punctuation.definition.comment"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#928374",
|
||||||
|
"fontStyle": "italic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["constant", "variable.other.constant"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#d3869b"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "variable",
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#ebdbb2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["keyword", "storage.type", "storage.modifier"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fb4934"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["string", "punctuation.definition.string"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#b8bb26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["entity.name.function", "support.function"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#b8bb26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "entity.name.type",
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fabd2f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["entity.name.tag", "punctuation.definition.tag"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#83a598"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["entity.other.attribute-name"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#8ec07c"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["punctuation", "meta.brace"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#ebdbb2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "markup.inline.raw",
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fe8019"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.heading"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#b8bb26",
|
||||||
|
"fontStyle": "bold"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.bold"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fe8019",
|
||||||
|
"fontStyle": "bold"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.italic"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fe8019",
|
||||||
|
"fontStyle": "italic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.list"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#83a598"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.quote"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#928374",
|
||||||
|
"fontStyle": "italic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": ["markup.link"],
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#8ec07c"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "support.class",
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#fabd2f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "number",
|
||||||
|
"settings": {
|
||||||
|
"foreground": "#d3869b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
keepBackground: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
sitemap({
|
||||||
|
filter: (page) => {
|
||||||
|
return !page.includes("/drafts/") && !page.includes("/private/");
|
||||||
|
},
|
||||||
|
changefreq: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
lastmod: new Date(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
58
blog.html
@@ -1,58 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Blog</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="Timothy Pidashev" content="A 17-year-old on an epic journey to become a software developer!" />
|
|
||||||
<meta name="keywords" content="Timothy Pidashev, timmypidashev, timmy, programming, github" />
|
|
||||||
<link rel="shortcut icon" type="image/png" href="images/elements/png/timmy.png"/>
|
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/aos@next/dist/aos.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Overlay -->
|
|
||||||
<div id="myNav" class="overlay">
|
|
||||||
<a href="javascript:void(0)" class="closebtn" onclick="closeNav()">×</a>
|
|
||||||
<div class="overlay-content">
|
|
||||||
<a href="download.html">Download YT</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navbar -->
|
|
||||||
<div class="header">
|
|
||||||
<a href="#default" class="logo" data-aos="zoom-in">Timmy</a>
|
|
||||||
<div class="header-right">
|
|
||||||
<a href="index.html" data-aos="zoom-in">About</a>
|
|
||||||
<a class="active" href="blog.html" data-aos="zoom-in" data-aos="zoom-in">Blog</a>
|
|
||||||
<a href="projects.html" data-aos="zoom-in">Projects</a>
|
|
||||||
<span style="font-size:30px;cursor:pointer" onclick="openNav()">☰ </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<!-- Section -->
|
|
||||||
<section class="centered">
|
|
||||||
<div>
|
|
||||||
<h1 class="hero__header" data-aos="flip-down">Work In Progress</h1>
|
|
||||||
</div>
|
|
||||||
<img src="images/elements/png/line_short.png" alt="line" class="divider__line" data-aos="flip-left">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- JS -->
|
|
||||||
<script src="js/closeNav.js"></script>
|
|
||||||
<script src="js/openNav.js"></script>
|
|
||||||
<script src="https://unpkg.com/aos@next/dist/aos.js"></script>
|
|
||||||
<script>AOS.init(
|
|
||||||
{
|
|
||||||
once: false,
|
|
||||||
mirror: true,
|
|
||||||
anchorPlacement: 'top-bottom',
|
|
||||||
offset: 0,
|
|
||||||
duration: 800
|
|
||||||
});</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
188
css/styles.css
@@ -1,188 +0,0 @@
|
|||||||
* {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar */
|
|
||||||
.header {
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #FB93DA;
|
|
||||||
padding: 20px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header a {
|
|
||||||
float: left;
|
|
||||||
color: #404082;
|
|
||||||
text-shadow: 3px 2px 2px rgba(199, 130, 59, 0.15);
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 25px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header a.logo {
|
|
||||||
font-size: 25px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header a:hover {
|
|
||||||
background-color: #D26BB9;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header a.active {
|
|
||||||
background-color: #404082;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overlay */
|
|
||||||
.overlay {
|
|
||||||
height: 100%;
|
|
||||||
width: 0;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: rgb(0,0,0);
|
|
||||||
background-color: rgba(0,0,0, 0.9);
|
|
||||||
overflow-x: hidden;
|
|
||||||
transition: 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-content {
|
|
||||||
position: relative;
|
|
||||||
top: 25%;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay a {
|
|
||||||
padding: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 36px;
|
|
||||||
color: #818181;
|
|
||||||
display: block;
|
|
||||||
transition: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay a:hover, .overlay a:focus {
|
|
||||||
color: #f1f1f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay .closebtn {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 45px;
|
|
||||||
font-size: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style */
|
|
||||||
html {
|
|
||||||
background-color: #FB93DA;
|
|
||||||
color: #404082;
|
|
||||||
font-family: 'Montserrat', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 1vh;
|
|
||||||
text-shadow: 3px 2px 2px rgba(199, 130, 59, 0.15);
|
|
||||||
-ms-overflow-style: none; /* Hide scrollbar for Internet Explorer and Edge */
|
|
||||||
scrollbar-width: none; /* Hide scrollbar for Firefox */
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.html::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#end__of__page {
|
|
||||||
margin-bottom: -30vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pink__colored {
|
|
||||||
color: #D26BB9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orange__highlight {
|
|
||||||
background-color: #D26BB9;
|
|
||||||
border-radius: 20px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding-right: 0.9vw;
|
|
||||||
padding-left: 0.9vw;
|
|
||||||
font-weight: 520;
|
|
||||||
text-shadow: none;
|
|
||||||
box-shadow: 3px 2px 2px rgba(199, 130, 59, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__header {
|
|
||||||
font-size: 6vw;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider__line {
|
|
||||||
height: 4vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about__header {
|
|
||||||
font-size: 3vw;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo__image {
|
|
||||||
margin-left: 2vw;
|
|
||||||
margin-right: 2vw;
|
|
||||||
width: 10vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 1024px) {
|
|
||||||
.hero__header {
|
|
||||||
font-size: 12vw;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about__header {
|
|
||||||
font-size: 5vw;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider__line {
|
|
||||||
height: 8vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo__image {
|
|
||||||
margin-left: 2vw;
|
|
||||||
margin-right: 2vw;
|
|
||||||
width: 14vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Download YT</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="Timothy Pidashev" content="A 17-year-old on an epic journey to become a software developer!" />
|
|
||||||
<meta name="keywords" content="Timothy Pidashev, timmypidashev, timmy, programming, github" />
|
|
||||||
<link rel="shortcut icon" type="image/png" href="images/elements/png/timmy.png"/>
|
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/aos@next/dist/aos.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Overlay -->
|
|
||||||
<div id="myNav" class="overlay">
|
|
||||||
<a href="javascript:void(0)" class="closebtn" onclick="closeNav()">×</a>
|
|
||||||
<div class="overlay-content">
|
|
||||||
<a href="download.html">Download YT</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navbar -->
|
|
||||||
<div class="header">
|
|
||||||
<a href="#default" class="logo" data-aos="zoom-in">Timmy</a>
|
|
||||||
<div class="header-right">
|
|
||||||
<a href="index.html" data-aos="zoom-in">About</a>
|
|
||||||
<a href="blog.html" data-aos="zoom-in" data-aos="zoom-in">Blog</a>
|
|
||||||
<a href="projects.html" data-aos="zoom-in">Projects</a>
|
|
||||||
<span style="font-size:30px;cursor:pointer" onclick="openNav()">☰ </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<!-- Section -->
|
|
||||||
<section class="centered">
|
|
||||||
<div>
|
|
||||||
<h1 class="hero__header" data-aos="flip-down">Work In Progress</h1>
|
|
||||||
</div>
|
|
||||||
<img src="images/elements/png/line_short.png" alt="line" class="divider__line" data-aos="flip-left">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- JS -->
|
|
||||||
<script src="js/closeNav.js"></script>
|
|
||||||
<script src="js/openNav.js"></script>
|
|
||||||
<script src="https://unpkg.com/aos@next/dist/aos.js"></script>
|
|
||||||
<script>AOS.init(
|
|
||||||
{
|
|
||||||
once: false,
|
|
||||||
mirror: true,
|
|
||||||
anchorPlacement: 'top-bottom',
|
|
||||||
offset: 0,
|
|
||||||
duration: 800
|
|
||||||
});</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 653 B |
|
Before Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 36 KiB |
108
index.html
@@ -1,108 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Timothy Pidashev</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="Timothy Pidashev" content="A 17-year-old on an epic journey to become a software developer!" />
|
|
||||||
<meta name="keywords" content="Timothy Pidashev, timmypidashev, timmy, programming, github" />
|
|
||||||
<link rel="shortcut icon" type="image/png" href="images/elements/png/timmy.png"/>
|
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/aos@next/dist/aos.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Overlay -->
|
|
||||||
<div id="myNav" class="overlay">
|
|
||||||
<a href="javascript:void(0)" class="closebtn" onclick="closeNav()">×</a>
|
|
||||||
<div class="overlay-content">
|
|
||||||
<a href="download.html">Download YT</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navbar -->
|
|
||||||
<div class="header">
|
|
||||||
<a href="#default" class="logo" data-aos="zoom-in">Timmy</a>
|
|
||||||
<div class="header-right">
|
|
||||||
<a class="active" href="index.html" data-aos="zoom-in">About</a>
|
|
||||||
<a href="blog.html" data-aos="zoom-in" data-aos="zoom-in">Blog</a>
|
|
||||||
<a href="projects.html" data-aos="zoom-in">Projects</a>
|
|
||||||
<span style="font-size:30px;cursor:pointer" onclick="openNav()">☰ </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<section class="centered">
|
|
||||||
<div>
|
|
||||||
<h1 class="hero__header pink__colored" data-aos="fade-right">Hello, Im</h1>
|
|
||||||
<h1 class="hero__header" data-aos="flip-down">Timothy</h1>
|
|
||||||
<h1 class="hero__header" data-aos="fade-left">Pidashev</h1>
|
|
||||||
</div>
|
|
||||||
<img src="images/elements/png/line_short.png" alt="line" class="divider__line" data-aos="flip-left">
|
|
||||||
</section>
|
|
||||||
<section class="centered">
|
|
||||||
<div>
|
|
||||||
<h1 class="about__header" data-aos="zoom-out">
|
|
||||||
I'm a 17-year-old on an
|
|
||||||
<span class="orange__highlight">epic journey</span>
|
|
||||||
</h1>
|
|
||||||
<h1 class="about__header" data-aos="flip-down">
|
|
||||||
to become a
|
|
||||||
<span class="orange__highlight">software developer!</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<img src="images/elements/png/line_short.png" alt="line" class="divider__line" data-aos="flip-left">
|
|
||||||
</section>
|
|
||||||
<div id="end__of__page" class="centered">
|
|
||||||
<div class="row">
|
|
||||||
<div class="logo">
|
|
||||||
<a href="https://www.youtube.com/channel/UCEpaCDz-wZ21kR8nA8xDSzg" target="_blank">
|
|
||||||
<img src="images/elements/png/youtube.png" alt="youtube logo" data-aos="fade-left" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="logo">
|
|
||||||
<a href="https://twitter.com/Timothy89184676" target="_blank">
|
|
||||||
<img src="images/elements/png/twitter.png" alt="twitter logo" data-aos="zoom-in" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="logo">
|
|
||||||
<a href="https://discord.gg/34RqygKbtX" target="_blank">
|
|
||||||
<img src="images/elements/png/discord.png" alt="discord logo" data-aos="fade-right" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="logo">
|
|
||||||
<a href="https://timmyverybored.itch.io/" target="_blank">
|
|
||||||
<img src="images/elements/png/itch.png" alt="itch logo" data-aos="fade-right" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="logo">
|
|
||||||
<a href="https://github.com/timmypidashev" target="_blank">
|
|
||||||
<img src="images/elements/png/github.png" alt="github logo" data-aos="zoom-out" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="logo">
|
|
||||||
<a href="mailto: pidashev.tim@gmail.com" target="_blank">
|
|
||||||
<img src="images/elements/png/gmail.png" alt="gmail logo" data-aos="fade-left" class="logo__image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JS -->
|
|
||||||
<script src="js/closeNav.js"></script>
|
|
||||||
<script src="js/openNav.js"></script>
|
|
||||||
<script src="https://unpkg.com/aos@next/dist/aos.js"></script>
|
|
||||||
<script>AOS.init(
|
|
||||||
{
|
|
||||||
once: false,
|
|
||||||
mirror: true,
|
|
||||||
anchorPlacement: 'top-bottom',
|
|
||||||
offset: 0,
|
|
||||||
duration: 800
|
|
||||||
});</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
function closeNav() {
|
|
||||||
document.getElementById("myNav").style.width = "0%";
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
function openNav() {
|
|
||||||
document.getElementById("myNav").style.width = "100%";
|
|
||||||
}
|
|
||||||
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "timmypidashev-web",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev --host",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/react": "^5.0.2",
|
||||||
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/react": "^18.3.28",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@types/three": "^0.175.0",
|
||||||
|
"astro": "^6.1.2",
|
||||||
|
"tailwindcss": "^3.4.19"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "^5.0.3",
|
||||||
|
"@astrojs/rss": "^4.0.18",
|
||||||
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
|
"@astrojs/vercel": "^10.0.3",
|
||||||
|
"@giscus/react": "^3.1.0",
|
||||||
|
"@pilcrowjs/object-parser": "^0.0.4",
|
||||||
|
"@react-hook/intersection-observer": "^3.1.2",
|
||||||
|
"@react-three/drei": "^9.122.0",
|
||||||
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@react-three/postprocessing": "^2.19.1",
|
||||||
|
"@rehype-pretty/transformers": "^0.13.2",
|
||||||
|
"@vercel/analytics": "^2.0.1",
|
||||||
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
|
"arctic": "^3.7.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"marked": "^15.0.12",
|
||||||
|
"postprocessing": "^6.39.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"react-responsive": "^10.0.1",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
|
"rehype-pretty-code": "^0.14.3",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"schema-dts": "^1.1.5",
|
||||||
|
"shiki": "^3.23.0",
|
||||||
|
"three": "^0.175.0",
|
||||||
|
"typewriter-effect": "^2.22.0",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6297
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Projects</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="Timothy Pidashev" content="A 17-year-old on an epic journey to become a software developer!" />
|
|
||||||
<meta name="keywords" content="Timothy Pidashev, timmypidashev, timmy, programming, github" />
|
|
||||||
<link rel="shortcut icon" type="image/png" href="images/elements/png/timmy.png"/>
|
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/aos@next/dist/aos.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Overlay -->
|
|
||||||
<div id="myNav" class="overlay">
|
|
||||||
<a href="javascript:void(0)" class="closebtn" onclick="closeNav()">×</a>
|
|
||||||
<div class="overlay-content">
|
|
||||||
<a href="download.html">Download YT</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navbar -->
|
|
||||||
<div class="header">
|
|
||||||
<a href="#default" class="logo" data-aos="zoom-in">Timmy</a>
|
|
||||||
<div class="header-right">
|
|
||||||
<a href="index.html" data-aos="zoom-in">About</a>
|
|
||||||
<a href="blog.html" data-aos="zoom-in" data-aos="zoom-in">Blog</a>
|
|
||||||
<a class="active" href="projects.html" data-aos="zoom-in">Projects</a>
|
|
||||||
<span style="font-size:30px;cursor:pointer" onclick="openNav()">☰ </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<!-- Section -->
|
|
||||||
<section class="centered">
|
|
||||||
<div>
|
|
||||||
<h1 class="hero__header" data-aos="flip-down">Work In Progress</h1>
|
|
||||||
</div>
|
|
||||||
<img src="images/elements/png/line_short.png" alt="line" class="divider__line" data-aos="flip-left">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- JS -->
|
|
||||||
<script src="js/closeNav.js"></script>
|
|
||||||
<script src="js/openNav.js"></script>
|
|
||||||
<script src="https://unpkg.com/aos@next/dist/aos.js"></script>
|
|
||||||
<script>AOS.init(
|
|
||||||
{
|
|
||||||
once: false,
|
|
||||||
mirror: true,
|
|
||||||
anchorPlacement: 'top-bottom',
|
|
||||||
offset: 0,
|
|
||||||
duration: 800
|
|
||||||
});</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
BIN
public/blog/breaking-the-chromebook-cage/thumbnail.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/blog/my-first-post/thumbnail.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
public/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_4mb.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_8mb.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 742 KiB |
BIN
public/blog/thinkpad-t440p-coreboot-guide/thumbnail.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/emoji/bubbles.webp
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
public/emoji/coffee.webp
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/emoji/eyes.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
public/emoji/gift.webp
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
public/emoji/infinity.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/emoji/lightbulb.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/emoji/memo.webp
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
public/emoji/mood-cold.webp
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/emoji/mood-cool.webp
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/emoji/mood-dotted.webp
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
public/emoji/mood-expressionless.webp
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/emoji/mood-fire.webp
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
public/emoji/mood-melting.webp
Normal file
|
After Width: | Height: | Size: 867 KiB |
BIN
public/emoji/mood-nerd.webp
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
public/emoji/mood-neutral.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/emoji/mood-nod.webp
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
public/emoji/mood-nomouth.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/emoji/mood-salute.webp
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
public/emoji/mood-sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/mood-starstruck.webp
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
public/emoji/mood-think.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/moon.webp
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
public/emoji/muscle.webp
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/emoji/point-down.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
public/emoji/robot.webp
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/emoji/shush.webp
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
public/emoji/sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/thinking.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/tinker.webp
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
public/emoji/trophy.webp
Normal file
|
After Width: | Height: | Size: 582 KiB |
BIN
public/emoji/wave.webp
Normal file
|
After Width: | Height: | Size: 390 KiB |
14
public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,16.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M0 80 l0 -80 30 32 c17 17 30 35 30 39 0 5 12 9 26 9 14 0 22 -4 19
|
||||||
|
-10 -6 -10 33 -70 47 -70 4 0 8 36 8 80 l0 80 -80 0 -80 0 0 -80z m105 20 c-3
|
||||||
|
-5 -16 -10 -28 -10 -18 0 -19 2 -7 10 20 13 43 13 35 0z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 642 B |
BIN
public/me.jpeg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
65
public/pgp.asc
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQINBGTwzLsBEADehIeiC1fV/GiRBclTmM9e6rmG29/YQbrRfJZ+sa1gWlAws/yR
|
||||||
|
sXbrCDh1S/JU85lirhc0A2N+OZSqCSkGUtvDhCttxLi5VUyVxgqshJWN/mU5eZEN
|
||||||
|
x4/5mpAApV6K2WGjAggJjoHecr/+sRpV3Vq/5ypdp6RLt7zeJolJSzKWpFrGeYTd
|
||||||
|
CFaZyZbQVw2NeFe9NBoQHDk0mMS+9bVyXQ2oi4fteKCe205cJkEc4Z8q4NzOlquL
|
||||||
|
BbowQwxKoAosCwz19Em5yeND34WUHJPlgoYTf9HRgsQcmI17hn9Q00gzwm7gotbF
|
||||||
|
bqWKEMduOzGBRyEEWeZ+l8CcWHAo2NbxZx01L4UsrZ6+MkXWv+2NQBFhPL14UcDg
|
||||||
|
uuBVvc5rsgeu7Rm8DE2EXTyA3Bszz1r75TbsQwUianma38kxSuiKArUDE+v5X0vv
|
||||||
|
F6e2z6hkDkKCe53EM9yF4lvVHNsQwxT8RBXPj+sc6Sqv3nIkLnan3++aMhW3up9s
|
||||||
|
YXKouadmvgB4uGqsBjWOme4ViW46C7rNVRtUKQTdx89cFG1c+GuaI203RbHjAZVJ
|
||||||
|
M9fBH5Ycdl0ieSCwmerbHfTRudHqPdNLQMvFYGPEj5pXalwtd3j4CEEC/HqOyklz
|
||||||
|
IN9ReIYIYF0pSC5OmWD3c70vj0INsbWp96eB8skjONjHYL3Y2CXIZ0AzRQARAQAB
|
||||||
|
tClUaW1vdGh5IFBpZGFzaGV2IDxwaWRhc2hldi50aW1AZ21haWwuY29tPokCTgQT
|
||||||
|
AQgAOBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsDBQsJCAcCBhUKCQgL
|
||||||
|
AgQWAgMBAh4BAheAAAoJEPe9h7j9i0NOsxwQALfdoZJAdkBpM2AmsVdx6JqvA08I
|
||||||
|
p/Xr1YgjwJvziq8fnWpu/AGz9VevFVgAt1h1Dsr4XAolEtQM6+aiNX7HGqyLKqT7
|
||||||
|
kum/dpnjw0/tiKvv/P2TRc+YZZLOfb+TYa1bZYGVDzGAHAm17yMJTV3rH8tIKNee
|
||||||
|
VaqWmxMuwmUQXutvF9P2bhaJLOTjGCIVxuAMfLIhRGKz8q8+I5g5aLm/JrpHC0OY
|
||||||
|
ACGSSj1vP0b5m5BLqqv67GueDHTX6w/7U1LAEspIcs+/GxoA5G9WzZFn4qNdq98h
|
||||||
|
RPixuY5y5FYKV6FAVGm5Yu3FSPvKpXAfWZIKM3WzKf7BNhUVaB6HNGbCFAMGDcHz
|
||||||
|
dZ9xXRohWlHidft55qBUpTXAjy3vb2k5eMXGPNMCwQvMyzDZzLkYbN3apuWbjzlQ
|
||||||
|
ARdlGKpRRzmeAHEmAybX1Fel8dT2DWjP05t2z2BRQAFK9sGE7WzYhvllMp31C7SA
|
||||||
|
uJZNzgjjs/aI8oiNc1qpeiQxsEGws3OakHRxd1rnM+d4icwTx13u8lMqHnvORgIe
|
||||||
|
rAa6qKIUnfZLo0ut0X5s8iPjoLZr/qjDGRbVmHH5K/D5Ci6VlsEkmcQ0REE1FWPA
|
||||||
|
hrlSNfKbeaHWAJ7MFKvNxViy8n7MeoR6Nn7EGoxCfekgGQLWuRNanChJ8dm7HZpq
|
||||||
|
o/vu4JH94Ui9xDwBtDlUaW1vdGh5IFBpZGFzaGV2ICh0aW1teXBpZGFzaGV2KSA8
|
||||||
|
bWFpbEB0aW1teXBpZGFzaGV2LmRldj6JAlQEEwEIAD4CGwMFCwkIBwICIgIGFQoJ
|
||||||
|
CAsCBBYCAwECHgcCF4AWIQRoxNZefDLF1F/onrn3vYe4/YtDTgUCZ5J4fQIZAQAK
|
||||||
|
CRD3vYe4/YtDTsGjD/4s/pBI8s+zoV+aBBKi9qmIqFAlZ8+JyY4TzAlIa1qZg/Xk
|
||||||
|
GVEN1+Lwa9m4eI1SFZUOprLPiqqFJ+DSHjrua5FGo56uhYGBEbPBlzIJx0XtXclS
|
||||||
|
1FmpoDOjY6FFsvrkv19jPYB2oXnPjok/nkRLdNWp1BVqisqFq8f//iynMu6GTndF
|
||||||
|
cNHf7iwZ+IHytFTiKFCgMg7jPeXofAkpnFXoOB33wn7ED2I26zhMx9wJE1cKApxv
|
||||||
|
ZmxGtkPk0rv6kiQqGE9zTg+AKSy1+jkXp8eFrnA4P9bIDXxcnDyVs/63X6X/qEK5
|
||||||
|
Yg1a3Xq1U4d6aoQUqAShpAQGbTmEvKusYXzdd2fFJEd0OExXkG2mWndhkF/9+8Rh
|
||||||
|
SroaqS+0G3nG38KTvK7OKnyHhuDVjcvJ5QiWVd1T7M3SBDAZwcOmpkw3SN26b8iS
|
||||||
|
i8iHAUQGjKftG+PDrXvRhMn7lpIshJBXopCGJvzPwLIoMvVzgq0gxAeCUHqI0wr2
|
||||||
|
EXEgboPW18zcAagI2r4B7p0xVtpJ9qPamYcCPqPMfxA8YYKhyzb8owkFUBboSF8j
|
||||||
|
ihBz1NN3ph8ZEa1YbxrdJVMkbOlE/O+DDaegxkXG9gSts/nYZXQx5GZ2LyVlHC4z
|
||||||
|
yVUpGwsRLfSejubhBnkRrXzOn1dFhAL4kIXFvFp4t4ZkYssdWzLVx71UVTyGsrkC
|
||||||
|
DQRk8My7ARAA1NRA04/vS9Cww0MMFQwaBztEb4INAT3dVxybyPZEIiNGttqGzEc9
|
||||||
|
EV+5NlcLwygDraXqw+k5GekIE7Mqf3YukeIqA+4TDVpFv26QbtBnLQ01YM7Z0tU4
|
||||||
|
R/X6IJmn/Uudc5hKLOLms3BH4x6O/XQJERJIALOfMWRfsmcUXw8a05HF5OuNVClT
|
||||||
|
w8FHVawN7frCJdBsh9g2bGJArwQFCxaLcDgpydUTMxNgxQMLgcAuIk3GiFwwWC4e
|
||||||
|
HzrAmp1yHn/iDh+UN8zQBjoi/5Ac4uXJlHKGAiakw/NqYlFccno1vUg5kuW+9QN4
|
||||||
|
ch2fa4zAosd7ObR5uZjNn6sggnq4ejA98vtg5DssCSQpTiFqNu3pBLroh4LsRh3r
|
||||||
|
THuWnXj4HWKDPZ3odlPVy2sIswtMXO3uygyWLJbPuT824iFwD9imshqsnoMxazb1
|
||||||
|
W/GuBFyI7ZM8tzCMVNtZExEBqnOwQdjlSgpla6L3UVWs4KL1UEVWm3doFCGgzQbK
|
||||||
|
JVVH3Uk0Z+w+jylZqXdmSSrB/wkg+j9QK2VxewEP0onS4FBhoJsaezLL5fTYpOy5
|
||||||
|
yAx2k3lqa3YF51ulPoGGg3u75R/37zt8VT3rfEXuCjtHd/H4fieluAOW2w4phXrC
|
||||||
|
u8iMq0eChaedVZsAAsy+DW9Ighf8zy8x/HQ0MKaFGI59B6BOsL6f/2MAEQEAAYkC
|
||||||
|
NgQYAQgAIBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsMAAoJEPe9h7j9
|
||||||
|
i0NOWvEQAJl5UqnzxM9+aYQcCw1qxGYTxdui7mE8QHDU9L7sz5vPeLVXrkxZfFsR
|
||||||
|
+2Y6S92ySk+pTZv8/+TztxYczr3tJ/TUpj6jM7jJROo2BUcAph4wSBD/vxqCN40g
|
||||||
|
XYcvbzbFvmbXJL+I2h/C3Ja7O0DqlDqRB2icaCYqVK0aDFfe5ldHbfs+w4Ox/zh6
|
||||||
|
7kkchA+WauRadwyaqWK3XGdusTw3ZcaFRSGfTRCQfM2U1761mRBppeDhOPoZ6Ymy
|
||||||
|
rWUpiOE7SJNe+gxyX0wVGv/CuUr5RaDEwvl7A5fUA9Sdvtxdr3Eixd6lsCRWoAtq
|
||||||
|
1woDoqpDZpS0wlb28r7/ZtvAUR1EovAOwy41GNri9AwyMeRSg3NLqb21c9yhEV5a
|
||||||
|
cWdGzioSjk89dd1c/pzuHPZ1cTRiizH8SRQjn/rLqJTnH1vVhLFmLv/Ywr9LOw2s
|
||||||
|
RcKxrByu0O+j96zR+6dqsiCo4i0CUS7P1shQlzhRuz6o3eZ1IlepAlif2LDW5waO
|
||||||
|
KdFJe2hD0oe4JKO9TbzJOeupFW1C/TBzdLif3K7VsKVdfPBL1YWinf7V7gryxJdc
|
||||||
|
pAE4764aIhfCkLa85tt7Tn/ii4ZLLMQG+Ww7LJ7BRarMlcyrw3nf0dICLxFgAnMS
|
||||||
|
vclOqTbTH082uTM/wa3dhxByz0rKZEz7xXAjPiZfK+2nncOdQhAm
|
||||||
|
=T4Wx
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
BIN
public/projects/darkbox/thumbnail.jpeg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/projects/fhccenter/thumbnail.jpeg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/projects/iridescent/thumbnail.jpeg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/projects/reviveauto/thumbnail.jpeg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/projects/web/thumbnail.jpeg
Normal file
|
After Width: | Height: | Size: 32 KiB |
1
public/scripts
Submodule
11
sandbox.config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"infiniteLoopProtection": true,
|
||||||
|
"hardReloadOnChange": false,
|
||||||
|
"view": "browser",
|
||||||
|
"template": "node",
|
||||||
|
"container": {
|
||||||
|
"port": 3000,
|
||||||
|
"startScript": "start",
|
||||||
|
"node": "14"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/components/404/glitched-text.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const GlitchText = () => {
|
||||||
|
const originalText = 'Error 404';
|
||||||
|
const [characters, setCharacters] = useState(
|
||||||
|
originalText.split("").map(char => ({ char, isGlitched: false }))
|
||||||
|
);
|
||||||
|
const glitchChars = "!<>-_\\/[]{}—=+*^?#________";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const glitchInterval = setInterval(() => {
|
||||||
|
if (Math.random() < 0.2) { // 20% chance to trigger glitch
|
||||||
|
setCharacters(prev => {
|
||||||
|
return originalText.split('').map((originalChar, index) => {
|
||||||
|
if (Math.random() < 0.3) { // 30% chance to glitch each character
|
||||||
|
return {
|
||||||
|
char: glitchChars[Math.floor(Math.random() * glitchChars.length)],
|
||||||
|
isGlitched: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { char: originalChar, isGlitched: false };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setCharacters(originalText.split('').map(char => ({
|
||||||
|
char,
|
||||||
|
isGlitched: false
|
||||||
|
})));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearInterval(glitchInterval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<h1 className="text-6xl font-bold mb-4 relative">
|
||||||
|
<span className="relative inline-block">
|
||||||
|
{characters.map((charObj, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={charObj.isGlitched ? "text-red" : "text-purple"}
|
||||||
|
>
|
||||||
|
{charObj.char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlitchText;
|
||||||
142
src/components/about/current-focus.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
|
||||||
|
import { AnimateIn } from "@/components/animate-in";
|
||||||
|
|
||||||
|
export default function CurrentFocus() {
|
||||||
|
const recentProjects = [
|
||||||
|
{
|
||||||
|
title: "Darkbox",
|
||||||
|
description: "My gruvbox theme, with a pure black background",
|
||||||
|
href: "/projects/darkbox",
|
||||||
|
tech: ["Neovim", "Lua"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Revive Auto Parts",
|
||||||
|
description: "A car parts listing site built for a client",
|
||||||
|
href: "/projects/reviveauto",
|
||||||
|
tech: ["Tanstack", "React Query", "Fastapi"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fhccenter",
|
||||||
|
description: "Website made for a private school",
|
||||||
|
href: "/projects/fhccenter",
|
||||||
|
tech: ["Nextjs", "Typescript", "Prisma"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center w-full">
|
||||||
|
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
|
||||||
|
<AnimateIn>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
|
||||||
|
Current Focus
|
||||||
|
</h2>
|
||||||
|
</AnimateIn>
|
||||||
|
|
||||||
|
{/* Recent Projects Section */}
|
||||||
|
<div className="mb-8 sm:mb-16">
|
||||||
|
<AnimateIn delay={100}>
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-6">
|
||||||
|
<Code2 className="text-yellow-bright" size={24} />
|
||||||
|
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
|
||||||
|
{recentProjects.map((project, i) => (
|
||||||
|
<AnimateIn key={project.title} delay={200 + i * 100}>
|
||||||
|
<a
|
||||||
|
href={project.href}
|
||||||
|
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
|
||||||
|
transition-colors duration-300 group bg-background/50 h-full"
|
||||||
|
>
|
||||||
|
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{project.tech.map((tech) => (
|
||||||
|
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</AnimateIn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Learning & Interests */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
||||||
|
<AnimateIn delay={100}>
|
||||||
|
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<BookOpen className="text-green-bright" size={24} />
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||||
|
<span>Rust Programming</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||||
|
<span>WebAssembly with Rust</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||||
|
<span>HTTP/3 & WebTransport</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
|
||||||
|
<AnimateIn delay={200}>
|
||||||
|
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<RocketIcon className="text-blue-bright" size={24} />
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||||
|
<span>AI Model Integration</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||||
|
<span>Rust Systems Programming</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||||
|
<span>Cross-platform WASM Apps</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
|
||||||
|
<AnimateIn delay={300}>
|
||||||
|
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Compass className="text-purple-bright" size={24} />
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||||
|
<span>LLM Fine-tuning</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||||
|
<span>Rust 2024 Edition</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||||
|
<span>Real-time Web Transport</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/about/intro.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Intro() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||||
|
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||||
|
const isSpaNav = !!(window as any).__astroNavigation;
|
||||||
|
if (inView && (isReload || isSpaNav)) {
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inView) {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToNext = () => {
|
||||||
|
const nextSection = document.querySelector("section")?.nextElementSibling;
|
||||||
|
if (nextSection) {
|
||||||
|
const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
|
||||||
|
window.scrollTo({ top: offset, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const anim = (delay: number) =>
|
||||||
|
({
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
|
||||||
|
transition: `opacity 0.7s ease-out ${delay}ms, transform 0.7s ease-out ${delay}ms`,
|
||||||
|
willChange: "transform, opacity",
|
||||||
|
}) as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="w-full max-w-4xl px-4">
|
||||||
|
<div className="space-y-8 md:space-y-12">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 sm:gap-16">
|
||||||
|
<div
|
||||||
|
className="w-44 h-44 sm:w-40 sm:h-40 lg:w-48 lg:h-48 shrink-0"
|
||||||
|
style={anim(0)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/me.jpeg"
|
||||||
|
alt="Timothy Pidashev"
|
||||||
|
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-colors duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
|
||||||
|
<h2 className="text-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
|
||||||
|
Timothy Pidashev
|
||||||
|
</h2>
|
||||||
|
<div className="text-base sm:text-lg lg:text-xl text-foreground/70 space-y-2 sm:space-y-3">
|
||||||
|
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(300)}>
|
||||||
|
<span className="text-blue">Software Systems Engineer</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(450)}>
|
||||||
|
<span className="text-green">Open Source Enthusiast</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(600)}>
|
||||||
|
<span className="text-yellow">Coffee Connoisseur</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8" style={anim(750)}>
|
||||||
|
<p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium">
|
||||||
|
"Turning coffee into code" isn't just a clever phrase –
|
||||||
|
<span className="text-aqua-bright"> it's how I approach each project:</span>
|
||||||
|
<span className="text-purple-bright"> methodically,</span>
|
||||||
|
<span className="text-blue-bright"> with attention to detail,</span>
|
||||||
|
<span className="text-green-bright"> and a refined process.</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center" style={anim(900)}>
|
||||||
|
<button
|
||||||
|
onClick={scrollToNext}
|
||||||
|
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||||
|
aria-label="Scroll to next section"
|
||||||
|
>
|
||||||
|
<ChevronDown size={40} className="animate-bounce" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/about/outside-coding.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Cross, Fish, Mountain, Book } from "lucide-react";
|
||||||
|
import { AnimateIn } from "@/components/animate-in";
|
||||||
|
|
||||||
|
const interests = [
|
||||||
|
{
|
||||||
|
icon: <Cross className="text-red-bright" size={20} />,
|
||||||
|
title: "Faith",
|
||||||
|
description: "My walk with Jesus is the foundation of everything I do, guiding my purpose and perspective",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Fish className="text-blue-bright" size={20} />,
|
||||||
|
title: "Fishing",
|
||||||
|
description: "Finding peace and adventure on the water, always looking for the next great fishing spot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Mountain className="text-green-bright" size={20} />,
|
||||||
|
title: "Hiking",
|
||||||
|
description: "Exploring trails with friends and seeking out scenic viewpoints in nature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Book className="text-purple-bright" size={20} />,
|
||||||
|
title: "Reading",
|
||||||
|
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OutsideCoding() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center w-full">
|
||||||
|
<div className="w-full max-w-4xl px-4 py-8">
|
||||||
|
<AnimateIn>
|
||||||
|
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
|
||||||
|
Outside of Programming
|
||||||
|
</h2>
|
||||||
|
</AnimateIn>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
|
{interests.map((interest, i) => (
|
||||||
|
<AnimateIn key={interest.title} delay={100 + i * 100}>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
|
||||||
|
hover:border-yellow-bright/50 transition-colors duration-300 bg-background/50 h-full"
|
||||||
|
>
|
||||||
|
<div className="mb-3">{interest.icon}</div>
|
||||||
|
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>
|
||||||
|
<p className="text-sm text-foreground/70">{interest.description}</p>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimateIn delay={500}>
|
||||||
|
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
|
||||||
|
When I'm not writing code, you'll find me
|
||||||
|
<span className="text-red-bright"> walking with Christ,</span>
|
||||||
|
<span className="text-blue-bright"> out on the water,</span>
|
||||||
|
<span className="text-green-bright"> hiking trails,</span>
|
||||||
|
<span className="text-purple-bright"> or reading books.</span>
|
||||||
|
</p>
|
||||||
|
</AnimateIn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/about/stats-activity.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ActivityDay {
|
||||||
|
grand_total: { total_seconds: number };
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityGridProps {
|
||||||
|
data: ActivityDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActivityGrid = ({ data }: ActivityGridProps) => {
|
||||||
|
const [tapped, setTapped] = useState<string | null>(null);
|
||||||
|
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
const getIntensity = (hours: number) => {
|
||||||
|
if (hours === 0) return 0;
|
||||||
|
if (hours < 2) return 1;
|
||||||
|
if (hours < 4) return 2;
|
||||||
|
if (hours < 6) return 3;
|
||||||
|
return 4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorClass = (intensity: number) => {
|
||||||
|
if (intensity === 0) return "bg-foreground/5";
|
||||||
|
if (intensity === 1) return "bg-green-DEFAULT/30";
|
||||||
|
if (intensity === 2) return "bg-green-DEFAULT/60";
|
||||||
|
if (intensity === 3) return "bg-green-DEFAULT/80";
|
||||||
|
return "bg-green-bright";
|
||||||
|
};
|
||||||
|
|
||||||
|
const weeks: ActivityDay[][] = [];
|
||||||
|
let currentWeek: ActivityDay[] = [];
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
data.forEach((day, index) => {
|
||||||
|
currentWeek.push(day);
|
||||||
|
if (currentWeek.length === 7 || index === data.length - 1) {
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="text-lg text-aqua-bright mb-6">Activity</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Days labels */}
|
||||||
|
<div className="flex flex-col gap-2 pt-6 text-xs">
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ""}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="flex-grow overflow-x-auto">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{weeks.map((week, weekIndex) => (
|
||||||
|
<div key={weekIndex} className="flex flex-col gap-2">
|
||||||
|
{week.map((day, dayIndex) => {
|
||||||
|
const hours = day.grand_total.total_seconds / 3600;
|
||||||
|
const intensity = getIntensity(hours);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dayIndex}
|
||||||
|
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
|
||||||
|
hover:ring-1 hover:ring-foreground/30 transition-colors cursor-pointer
|
||||||
|
group relative`}
|
||||||
|
onClick={() => setTapped(tapped === day.date ? null : day.date)}
|
||||||
|
>
|
||||||
|
<div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
|
||||||
|
bg-background border border-foreground/10 rounded-md transition-opacity z-10 whitespace-nowrap text-xs
|
||||||
|
${tapped === day.date ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
|
||||||
|
{hours.toFixed(1)} hours on {day.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Months labels */}
|
||||||
|
<div className="flex text-xs text-foreground/60 mt-2">
|
||||||
|
{weeks.map((week, i) => {
|
||||||
|
const date = new Date(week[0].date);
|
||||||
|
const isFirstOfMonth = date.getDate() <= 7;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-3 mx-1"
|
||||||
|
style={{ marginLeft: i === 0 ? "0" : undefined }}
|
||||||
|
>
|
||||||
|
{isFirstOfMonth && months[date.getMonth()]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
|
||||||
|
<span>Less</span>
|
||||||
|
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||||
|
<div key={intensity} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`} />
|
||||||
|
))}
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityGrid;
|
||||||
168
src/components/about/stats-alltime.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const Stats = () => {
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [skipAnim, setSkipAnim] = useState(false);
|
||||||
|
const hasAnimated = useRef(false);
|
||||||
|
const sectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/wakatime/alltime")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("API error");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => setStats(data.data))
|
||||||
|
.catch(() => setError(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Observe visibility — skip animation if already in view on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sectionRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||||
|
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||||
|
const isSpaNav = !!(window as any).__astroNavigation;
|
||||||
|
if (inView && (isReload || isSpaNav)) {
|
||||||
|
setSkipAnim(true);
|
||||||
|
setIsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inView) {
|
||||||
|
requestAnimationFrame(() => setIsVisible(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Start counter when both visible and data is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible || !stats || hasAnimated.current) return;
|
||||||
|
hasAnimated.current = true;
|
||||||
|
|
||||||
|
const totalSeconds = stats.total_seconds;
|
||||||
|
const duration = 2000;
|
||||||
|
let start: number | null = null;
|
||||||
|
let rafId: number;
|
||||||
|
|
||||||
|
const step = (timestamp: number) => {
|
||||||
|
if (!start) start = timestamp;
|
||||||
|
const elapsed = timestamp - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 4);
|
||||||
|
setCount(Math.floor(totalSeconds * eased));
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
rafId = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [isVisible, stats]);
|
||||||
|
|
||||||
|
if (error) return null;
|
||||||
|
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
|
||||||
|
|
||||||
|
const hours = Math.floor(count / 3600);
|
||||||
|
const formattedHours = hours.toLocaleString("en-US", {
|
||||||
|
minimumIntegerDigits: 4,
|
||||||
|
useGrouping: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
|
||||||
|
<div className={skipAnim ? "text-lg md:text-2xl opacity-80" : `text-lg md:text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
|
||||||
|
I've spent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="text-5xl md:text-8xl text-center relative z-10">
|
||||||
|
<span className="font-bold relative">
|
||||||
|
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
|
||||||
|
{formattedHours}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={skipAnim ? "text-2xl md:text-4xl opacity-60 ml-4" : `text-2xl md:text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
|
||||||
|
hours
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className={skipAnim ? "text-base md:text-xl opacity-80" : `text-base md:text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
|
||||||
|
writing code & building apps
|
||||||
|
</div>
|
||||||
|
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>
|
||||||
|
<span>since</span>
|
||||||
|
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.bg-gradient-text {
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgb(var(--color-yellow-bright)),
|
||||||
|
rgb(var(--color-orange-bright)),
|
||||||
|
rgb(var(--color-orange)),
|
||||||
|
rgb(var(--color-yellow)),
|
||||||
|
rgb(var(--color-orange-bright)),
|
||||||
|
rgb(var(--color-yellow-bright))
|
||||||
|
);
|
||||||
|
background-size: 200% auto;
|
||||||
|
color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInFirst {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 0.8; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fadeInSecond {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes slideInHours {
|
||||||
|
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
|
||||||
|
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
|
||||||
|
}
|
||||||
|
@keyframes fadeInThird {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 0.8; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fadeInFourth {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 0.6; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-first { animation: fadeInFirst 0.7s ease-out forwards; }
|
||||||
|
.animate-fade-in-second { animation: fadeInSecond 0.7s ease-out 0.4s forwards; }
|
||||||
|
.animate-slide-in-hours { animation: slideInHours 0.7s ease-out 0.6s forwards; }
|
||||||
|
.animate-fade-in-third { animation: fadeInThird 0.7s ease-out 0.8s forwards; }
|
||||||
|
.animate-fade-in-fourth { animation: fadeInFourth 0.7s ease-out 1s forwards; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
233
src/components/about/stats-detailed.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
|
||||||
|
import { ActivityGrid } from "@/components/about/stats-activity";
|
||||||
|
|
||||||
|
const DetailedStats = () => {
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
|
const [activity, setActivity] = useState<any>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [skipAnim, setSkipAnim] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/wakatime/detailed")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => setStats(data.data))
|
||||||
|
.catch(() => setError(true));
|
||||||
|
|
||||||
|
fetch("/api/wakatime/activity")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => setActivity(data.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||||
|
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||||
|
const isSpaNav = !!(window as any).__astroNavigation;
|
||||||
|
if (inView && (isReload || isSpaNav)) {
|
||||||
|
setSkipAnim(true);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inView) {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: "-15% 0px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
if (error) return null;
|
||||||
|
|
||||||
|
const progressColors = [
|
||||||
|
"bg-red-bright",
|
||||||
|
"bg-orange-bright",
|
||||||
|
"bg-yellow-bright",
|
||||||
|
"bg-green-bright",
|
||||||
|
"bg-blue-bright",
|
||||||
|
"bg-purple-bright",
|
||||||
|
"bg-aqua-bright",
|
||||||
|
];
|
||||||
|
|
||||||
|
const statCards = stats
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "Total Time",
|
||||||
|
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
|
||||||
|
unit: "hours",
|
||||||
|
subtitle: "this week",
|
||||||
|
color: "text-yellow-bright",
|
||||||
|
borderHover: "hover:border-yellow-bright/50",
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: "stroke-yellow-bright",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Daily Average",
|
||||||
|
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
|
||||||
|
unit: "hours",
|
||||||
|
subtitle: "per day",
|
||||||
|
color: "text-orange-bright",
|
||||||
|
borderHover: "hover:border-orange-bright/50",
|
||||||
|
icon: CalendarClock,
|
||||||
|
iconColor: "stroke-orange-bright",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Primary Editor",
|
||||||
|
value: stats.editors?.[0]?.name || "None",
|
||||||
|
unit: `${Math.round(stats.editors?.[0]?.percent || 0)}%`,
|
||||||
|
subtitle: "of the time",
|
||||||
|
color: "text-blue-bright",
|
||||||
|
borderHover: "hover:border-blue-bright/50",
|
||||||
|
icon: CodeXml,
|
||||||
|
iconColor: "stroke-blue-bright",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Operating System",
|
||||||
|
value: stats.operating_systems?.[0]?.name || "None",
|
||||||
|
unit: `${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`,
|
||||||
|
subtitle: "of the time",
|
||||||
|
color: "text-green-bright",
|
||||||
|
borderHover: "hover:border-green-bright/50",
|
||||||
|
icon: Computer,
|
||||||
|
iconColor: "stroke-green-bright",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const languages =
|
||||||
|
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
|
||||||
|
name: lang.name,
|
||||||
|
percent: Math.round(lang.percent),
|
||||||
|
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
|
||||||
|
color: progressColors[index % progressColors.length],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
|
||||||
|
{!stats ? null : (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<h2
|
||||||
|
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
|
||||||
|
style={skipAnim ? {} : {
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translateY(0)" : "translateY(20px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Weekly Statistics
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{statCards.map((card, i) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.title}
|
||||||
|
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-[opacity,transform] duration-500 ease-out"}`}
|
||||||
|
style={skipAnim ? {} : {
|
||||||
|
transitionDelay: `${150 + i * 100}ms`,
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="p-3 rounded-lg bg-foreground/5">
|
||||||
|
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className="text-2xl font-bold">{card.value}</div>
|
||||||
|
<div className="text-lg opacity-80">{card.unit}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Languages */}
|
||||||
|
<div
|
||||||
|
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
|
||||||
|
style={skipAnim ? {} : {
|
||||||
|
transitionDelay: "550ms",
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-purple-bright mb-6 text-lg">Languages</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
|
||||||
|
{languages.map((lang: any, i: number) => (
|
||||||
|
<div key={lang.name} className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium">{lang.name}</span>
|
||||||
|
<span className="text-sm opacity-70 tabular-nums">{lang.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="flex-grow h-2 bg-foreground/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${lang.color} rounded-full`}
|
||||||
|
style={{
|
||||||
|
width: `${lang.percent}%`,
|
||||||
|
opacity: 0.85,
|
||||||
|
transform: visible ? "scaleX(1)" : "scaleX(0)",
|
||||||
|
transformOrigin: "left",
|
||||||
|
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
|
||||||
|
{lang.percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Grid */}
|
||||||
|
{activity && (
|
||||||
|
<div
|
||||||
|
className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
|
||||||
|
style={skipAnim ? {} : {
|
||||||
|
transitionDelay: "750ms",
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityGrid data={activity} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailedStats;
|
||||||
185
src/components/about/timeline.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, Code, GitBranch, Star, Rocket } from "lucide-react";
|
||||||
|
|
||||||
|
const timelineItems = [
|
||||||
|
{
|
||||||
|
year: "2026",
|
||||||
|
title: "Present",
|
||||||
|
description: "Building domain-specific languages, diving deep into the Salesforce ecosystem, and writing production Java and Python daily. The craft keeps evolving.",
|
||||||
|
technologies: ["Java", "Python", "Salesforce", "DSLs"],
|
||||||
|
icon: <Rocket className="text-red-bright" size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2024",
|
||||||
|
title: "Shipping & Scaling",
|
||||||
|
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.",
|
||||||
|
technologies: ["Rust", "Typescript", "Go", "Postgres"],
|
||||||
|
icon: <Code className="text-yellow-bright" size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2022",
|
||||||
|
title: "Diving Deeper",
|
||||||
|
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.",
|
||||||
|
technologies: ["C++", "Cmake", "Docker", "Docker Compose"],
|
||||||
|
icon: <GitBranch className="text-green-bright" size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2020",
|
||||||
|
title: "Exploring the Stack",
|
||||||
|
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.",
|
||||||
|
technologies: ["Javascript", "Tailwind", "React", "Express"],
|
||||||
|
icon: <Star className="text-blue-bright" size={20} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2018",
|
||||||
|
title: "Starting the Journey",
|
||||||
|
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
|
||||||
|
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
|
||||||
|
icon: <Check className="text-purple-bright" size={20} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; index: number }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [skip, setSkip] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||||
|
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||||
|
const isSpaNav = !!(window as any).__astroNavigation;
|
||||||
|
|
||||||
|
if (inView && (isReload || isSpaNav)) {
|
||||||
|
setSkip(true);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inView) {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isLeft = index % 2 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative mb-8 md:mb-12 last:mb-0">
|
||||||
|
<div className={`flex flex-col sm:flex-row items-start ${isLeft ? "sm:flex-row-reverse" : ""}`}>
|
||||||
|
{/* Node */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
|
||||||
|
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
|
||||||
|
flex items-center justify-center z-10
|
||||||
|
${skip ? "" : "transition-[opacity,transform] duration-500"}
|
||||||
|
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-full sm:w-[calc(50%-32px)]
|
||||||
|
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
|
||||||
|
${skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
|
||||||
|
${visible
|
||||||
|
? "opacity-100 translate-x-0"
|
||||||
|
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
|
||||||
|
hover:border-yellow-bright/50 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
|
||||||
|
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{item.technologies.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
|
||||||
|
text-foreground/60 hover:text-yellow-bright transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const lineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [lineHeight, setLineHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Animate line to full height over time
|
||||||
|
const el = lineRef.current;
|
||||||
|
if (el) {
|
||||||
|
setLineHeight(100);
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
|
||||||
|
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
|
||||||
|
My Journey Through Code
|
||||||
|
</h2>
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{/* Animated vertical line */}
|
||||||
|
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
|
||||||
|
<div
|
||||||
|
ref={lineRef}
|
||||||
|
className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
|
||||||
|
style={{ transform: `scaleY(${lineHeight / 100})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-8 sm:ml-0">
|
||||||
|
{timelineItems.map((item, index) => (
|
||||||
|
<TimelineCard key={item.year} item={item} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/analytics.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
|
import { SpeedInsights } from "@vercel/speed-insights/react";
|
||||||
|
|
||||||
|
export default function VercelAnalytics() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Analytics />
|
||||||
|
<SpeedInsights />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/animate-in.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { prefersReducedMotion } from "@/lib/reduced-motion";
|
||||||
|
|
||||||
|
interface AnimateInProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [skip, setSkip] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
setSkip(true);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||||
|
const isReload = (performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming)?.type === "reload";
|
||||||
|
const isSpaNav = !!(window as any).__astroNavigation;
|
||||||
|
|
||||||
|
if (inView && (isReload || isSpaNav)) {
|
||||||
|
setSkip(true);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inView) {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
|
||||||
|
style={skip ? {} : {
|
||||||
|
transitionDelay: `${delay}ms`,
|
||||||
|
willChange: "transform, opacity",
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/animation-switcher/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
getStoredAnimationId,
|
||||||
|
getNextAnimation,
|
||||||
|
saveAnimation,
|
||||||
|
} from "@/lib/animations/engine";
|
||||||
|
import { ANIMATION_LABELS } from "@/lib/animations";
|
||||||
|
|
||||||
|
export default function AnimationSwitcher() {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [currentLabel, setCurrentLabel] = useState("");
|
||||||
|
const committedRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
committedRef.current = getStoredAnimationId();
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const id = getStoredAnimationId();
|
||||||
|
committedRef.current = id;
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("astro:after-swap", handleSwap);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("astro:after-swap", handleSwap);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const nextId = getNextAnimation(
|
||||||
|
committedRef.current as Parameters<typeof getNextAnimation>[0]
|
||||||
|
);
|
||||||
|
saveAnimation(nextId);
|
||||||
|
committedRef.current = nextId;
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[nextId]);
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden desk:block"
|
||||||
|
onMouseEnter={() => setHovering(true)}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
|
>
|
||||||
|
{currentLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
574
src/components/background/engines/asciiquarium.ts
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
// --- ASCII Art ---
|
||||||
|
|
||||||
|
interface AsciiPattern {
|
||||||
|
lines: string[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pat(lines: string[]): AsciiPattern {
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
width: Math.max(...lines.map((l) => l.length)),
|
||||||
|
height: lines.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FISH_DEFS: {
|
||||||
|
size: "small" | "medium";
|
||||||
|
weight: number;
|
||||||
|
right: AsciiPattern;
|
||||||
|
left: AsciiPattern;
|
||||||
|
}[] = [
|
||||||
|
{ size: "small", weight: 30, right: pat(["><>"]), left: pat(["<><"]) },
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
weight: 30,
|
||||||
|
right: pat(["><(('>"]),
|
||||||
|
left: pat(["<'))><"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: "medium",
|
||||||
|
weight: 20,
|
||||||
|
right: pat(["><((o>"]),
|
||||||
|
left: pat(["<o))><"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: "medium",
|
||||||
|
weight: 10,
|
||||||
|
right: pat(["><((('>"]),
|
||||||
|
left: pat(["<')))><"]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOTAL_FISH_WEIGHT = FISH_DEFS.reduce((s, d) => s + d.weight, 0);
|
||||||
|
|
||||||
|
const BUBBLE_CHARS = [".", "o", "O"];
|
||||||
|
|
||||||
|
// --- Entity Interfaces ---
|
||||||
|
|
||||||
|
interface FishEntity {
|
||||||
|
kind: "fish";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
pattern: AsciiPattern;
|
||||||
|
size: "small" | "medium";
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BubbleEntity {
|
||||||
|
kind: "bubble";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vy: number;
|
||||||
|
wobblePhase: number;
|
||||||
|
wobbleAmplitude: number;
|
||||||
|
char: string;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
burst: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AquariumEntity = FishEntity | BubbleEntity;
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
const BASE_AREA = 1920 * 1080;
|
||||||
|
const BASE_FISH = 16;
|
||||||
|
const BASE_BUBBLES = 12;
|
||||||
|
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const FONT_SIZE_MIN = 24;
|
||||||
|
const FONT_SIZE_MAX = 36;
|
||||||
|
const FONT_SIZE_REF_WIDTH = 1920;
|
||||||
|
const LINE_HEIGHT_RATIO = 1.15;
|
||||||
|
const STAGGER_INTERVAL = 15;
|
||||||
|
const PI_2 = Math.PI * 2;
|
||||||
|
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const ELEVATION_FACTOR = 6;
|
||||||
|
const ELEVATION_LERP_SPEED = 0.05;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const SHADOW_OFFSET_RATIO = 1.1;
|
||||||
|
|
||||||
|
const FISH_SPEED: Record<string, { min: number; max: number }> = {
|
||||||
|
small: { min: 0.8, max: 1.4 },
|
||||||
|
medium: { min: 0.5, max: 0.9 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUBBLE_SPEED_MIN = 0.3;
|
||||||
|
const BUBBLE_SPEED_MAX = 0.7;
|
||||||
|
const BUBBLE_WOBBLE_MIN = 0.3;
|
||||||
|
const BUBBLE_WOBBLE_MAX = 1.0;
|
||||||
|
|
||||||
|
const BURST_BUBBLE_COUNT = 10;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function range(a: number, b: number): number {
|
||||||
|
return (b - a) * Math.random() + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFishDef() {
|
||||||
|
let r = Math.random() * TOTAL_FISH_WEIGHT;
|
||||||
|
for (const def of FISH_DEFS) {
|
||||||
|
r -= def.weight;
|
||||||
|
if (r <= 0) return def;
|
||||||
|
}
|
||||||
|
return FISH_DEFS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Engine ---
|
||||||
|
|
||||||
|
export class AsciiquariumEngine implements AnimationEngine {
|
||||||
|
id = "asciiquarium";
|
||||||
|
name = "Asciiquarium";
|
||||||
|
|
||||||
|
private fish: FishEntity[] = [];
|
||||||
|
private bubbles: BubbleEntity[] = [];
|
||||||
|
private exiting = false;
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private elapsed = 0;
|
||||||
|
private charWidth = 0;
|
||||||
|
private fontSize = FONT_SIZE_MAX;
|
||||||
|
private lineHeight = FONT_SIZE_MAX * LINE_HEIGHT_RATIO;
|
||||||
|
private font = `bold ${FONT_SIZE_MAX}px monospace`;
|
||||||
|
|
||||||
|
private computeFont(width: number): void {
|
||||||
|
const t = Math.sqrt(Math.min(1, width / FONT_SIZE_REF_WIDTH));
|
||||||
|
this.fontSize = Math.round(FONT_SIZE_MIN + (FONT_SIZE_MAX - FONT_SIZE_MIN) * t);
|
||||||
|
this.lineHeight = Math.round(this.fontSize * LINE_HEIGHT_RATIO);
|
||||||
|
this.font = `bold ${this.fontSize}px monospace`;
|
||||||
|
this.charWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.computeFont(width);
|
||||||
|
this.initEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Stagger fade-out over 3 seconds
|
||||||
|
for (const f of this.fish) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
f.staggerDelay = -2; // signal: fading out
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
b.staggerDelay = -2;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
for (const f of this.fish) {
|
||||||
|
if (f.opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
if (b.opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.fish = [];
|
||||||
|
this.bubbles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCounts(): { fish: number; bubbles: number } {
|
||||||
|
const ratio = (this.width * this.height) / BASE_AREA;
|
||||||
|
return {
|
||||||
|
fish: Math.max(5, Math.round(BASE_FISH * ratio)),
|
||||||
|
bubbles: Math.max(5, Math.round(BASE_BUBBLES * ratio)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initEntities(): void {
|
||||||
|
this.fish = [];
|
||||||
|
this.bubbles = [];
|
||||||
|
|
||||||
|
const counts = this.getCounts();
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < counts.fish; i++) {
|
||||||
|
this.fish.push(this.spawnFish(idx++));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < counts.bubbles; i++) {
|
||||||
|
this.bubbles.push(this.spawnBubble(idx++, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnFish(staggerIdx: number): FishEntity {
|
||||||
|
const def = pickFishDef();
|
||||||
|
const goRight = Math.random() > 0.5;
|
||||||
|
const speed = range(FISH_SPEED[def.size].min, FISH_SPEED[def.size].max);
|
||||||
|
const pattern = goRight ? def.right : def.left;
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const cw = this.charWidth || 9.6;
|
||||||
|
const pw = pattern.width * cw;
|
||||||
|
|
||||||
|
// Start off-screen on the side they swim from
|
||||||
|
const startX = goRight
|
||||||
|
? -pw - range(0, this.width * 0.5)
|
||||||
|
: this.width + range(0, this.width * 0.5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "fish",
|
||||||
|
x: startX,
|
||||||
|
y: range(this.height * 0.05, this.height * 0.9),
|
||||||
|
vx: goRight ? speed : -speed,
|
||||||
|
pattern,
|
||||||
|
size: def.size,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnBubble(staggerIdx: number, burst: boolean): BubbleEntity {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
return {
|
||||||
|
kind: "bubble",
|
||||||
|
x: range(0, this.width),
|
||||||
|
y: burst ? 0 : this.height + range(10, this.height * 0.5),
|
||||||
|
vy: -range(BUBBLE_SPEED_MIN, BUBBLE_SPEED_MAX),
|
||||||
|
wobblePhase: range(0, PI_2),
|
||||||
|
wobbleAmplitude: range(BUBBLE_WOBBLE_MIN, BUBBLE_WOBBLE_MAX),
|
||||||
|
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
burst,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
const cw = this.charWidth || 9.6;
|
||||||
|
|
||||||
|
// Fish
|
||||||
|
for (let i = this.fish.length - 1; i >= 0; i--) {
|
||||||
|
const f = this.fish[i];
|
||||||
|
if (f.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= f.staggerDelay) f.staggerDelay = -1;
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out during exit
|
||||||
|
if (f.staggerDelay === -2) {
|
||||||
|
f.opacity -= 0.02 * dt;
|
||||||
|
if (f.opacity <= 0) { f.opacity = 0; continue; }
|
||||||
|
} else if (f.opacity < 1) {
|
||||||
|
f.opacity = Math.min(1, f.opacity + 0.03 * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.x += f.vx * dt;
|
||||||
|
|
||||||
|
const pw = f.pattern.width * cw;
|
||||||
|
if (f.vx > 0 && f.x > this.width + pw) {
|
||||||
|
f.x = -pw;
|
||||||
|
} else if (f.vx < 0 && f.x < -pw) {
|
||||||
|
f.x = this.width + pw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = f.x + (f.pattern.width * cw) / 2;
|
||||||
|
const cy = f.y + (f.pattern.height * this.lineHeight) / 2;
|
||||||
|
this.applyMouseInfluence(f, cx, cy, mouseX, mouseY, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubbles (reverse iteration for safe splice)
|
||||||
|
for (let i = this.bubbles.length - 1; i >= 0; i--) {
|
||||||
|
const b = this.bubbles[i];
|
||||||
|
|
||||||
|
if (b.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= b.staggerDelay) b.staggerDelay = -1;
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out during exit
|
||||||
|
if (b.staggerDelay === -2) {
|
||||||
|
b.opacity -= 0.02 * dt;
|
||||||
|
if (b.opacity <= 0) { b.opacity = 0; continue; }
|
||||||
|
} else if (b.opacity < 1) {
|
||||||
|
b.opacity = Math.min(1, b.opacity + 0.03 * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
b.y += b.vy * dt;
|
||||||
|
b.x +=
|
||||||
|
Math.sin(this.elapsed * 0.003 + b.wobblePhase) *
|
||||||
|
b.wobbleAmplitude *
|
||||||
|
dt;
|
||||||
|
|
||||||
|
if (b.y < -20) {
|
||||||
|
if (b.burst) {
|
||||||
|
this.bubbles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
b.y = this.height + range(10, 40);
|
||||||
|
b.x = range(0, this.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyMouseInfluence(b, b.x, b.y, mouseX, mouseY, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMouseInfluence(
|
||||||
|
entity: AquariumEntity,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
dt: number
|
||||||
|
): void {
|
||||||
|
const dx = cx - mouseX;
|
||||||
|
const dy = cy - mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < MOUSE_INFLUENCE_RADIUS && entity.opacity > 0.1) {
|
||||||
|
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
|
||||||
|
entity.targetElevation = ELEVATION_FACTOR * inf * inf;
|
||||||
|
|
||||||
|
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
entity.color = [
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[0] + shift)),
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[1] + shift)),
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[2] + shift)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
entity.targetElevation = 0;
|
||||||
|
entity.color[0] += (entity.baseColor[0] - entity.color[0]) * 0.1;
|
||||||
|
entity.color[1] += (entity.baseColor[1] - entity.color[1]) * 0.1;
|
||||||
|
entity.color[2] += (entity.baseColor[2] - entity.color[2]) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.elevation +=
|
||||||
|
(entity.targetElevation - entity.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_width: number,
|
||||||
|
_height: number
|
||||||
|
): void {
|
||||||
|
if (!this.charWidth) {
|
||||||
|
ctx.font = this.font;
|
||||||
|
this.charWidth = ctx.measureText("M").width;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = this.font;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
|
// Fish
|
||||||
|
for (const f of this.fish) {
|
||||||
|
if (f.opacity <= 0.01 || f.staggerDelay >= 0) continue;
|
||||||
|
this.renderPattern(
|
||||||
|
ctx,
|
||||||
|
f.pattern,
|
||||||
|
f.x,
|
||||||
|
f.y,
|
||||||
|
f.color,
|
||||||
|
f.opacity,
|
||||||
|
f.elevation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubbles
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
if (b.opacity <= 0.01 || b.staggerDelay >= 0) continue;
|
||||||
|
this.renderChar(ctx, b.char, b.x, b.y, b.color, b.opacity, b.elevation);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPattern(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pattern: AsciiPattern,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: [number, number, number],
|
||||||
|
opacity: number,
|
||||||
|
elevation: number
|
||||||
|
): void {
|
||||||
|
const drawY = y - elevation;
|
||||||
|
const [r, g, b] = color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
for (let line = 0; line < pattern.height; line++) {
|
||||||
|
ctx.fillText(
|
||||||
|
pattern.lines[line],
|
||||||
|
x,
|
||||||
|
drawY + line * this.lineHeight + elevation * SHADOW_OFFSET_RATIO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main text
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
for (let line = 0; line < pattern.height; line++) {
|
||||||
|
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight (top half of lines)
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
const topLines = Math.ceil(pattern.height / 2);
|
||||||
|
for (let line = 0; line < topLines; line++) {
|
||||||
|
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderChar(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
char: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: [number, number, number],
|
||||||
|
opacity: number,
|
||||||
|
elevation: number
|
||||||
|
): void {
|
||||||
|
const drawY = y - elevation;
|
||||||
|
const [r, g, b] = color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
ctx.fillText(char, x, drawY + elevation * SHADOW_OFFSET_RATIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.fillText(char, x, drawY);
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.fillText(char, x, drawY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Events ---
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.computeFont(width);
|
||||||
|
this.initEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
for (let i = 0; i < BURST_BUBBLE_COUNT; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const angle = (i / BURST_BUBBLE_COUNT) * PI_2 + range(-0.3, 0.3);
|
||||||
|
const speed = range(0.3, 1.0);
|
||||||
|
this.bubbles.push({
|
||||||
|
kind: "bubble",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vy: -Math.abs(Math.sin(angle) * speed) - 0.3,
|
||||||
|
wobblePhase: range(0, PI_2),
|
||||||
|
wobbleAmplitude: Math.cos(angle) * speed * 0.5,
|
||||||
|
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: this.exiting ? -2 : -1,
|
||||||
|
burst: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.palette = palette;
|
||||||
|
for (let i = 0; i < this.fish.length; i++) {
|
||||||
|
this.fish[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
for (let i = 0; i < this.bubbles.length; i++) {
|
||||||
|
this.bubbles[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/components/background/engines/confetti.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface ConfettiParticle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
r: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
dop: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
burst: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_CONFETTI = 385;
|
||||||
|
const BASE_AREA = 1920 * 1080;
|
||||||
|
const PI_2 = 2 * Math.PI;
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const SPEED_FACTOR = 0.15;
|
||||||
|
const STAGGER_INTERVAL = 12;
|
||||||
|
const COLOR_LERP_SPEED = 0.02;
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const ELEVATION_FACTOR = 6;
|
||||||
|
const ELEVATION_LERP_SPEED = 0.05;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const SHADOW_OFFSET_RATIO = 1.1;
|
||||||
|
|
||||||
|
function range(a: number, b: number): number {
|
||||||
|
return (b - a) * Math.random() + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfettiEngine implements AnimationEngine {
|
||||||
|
id = "confetti";
|
||||||
|
name = "Confetti";
|
||||||
|
|
||||||
|
private particles: ConfettiParticle[] = [];
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private mouseXNorm = 0.5;
|
||||||
|
private elapsed = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.mouseXNorm = 0.5;
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Stagger fade-out over 3 seconds
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
p.staggerDelay = -1; // ensure visible
|
||||||
|
// Random delay before fade starts, stored as negative dop
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
p.dop = -0.02;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
if (this.particles[i].opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.particles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getParticleCount(): number {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private initParticles(): void {
|
||||||
|
this.particles = [];
|
||||||
|
const count = this.getParticleCount();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const r = ~~range(3, 8);
|
||||||
|
this.particles.push({
|
||||||
|
x: range(-r * 2, this.width - r * 2),
|
||||||
|
y: range(-20, this.height - r * 2),
|
||||||
|
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
|
||||||
|
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
|
||||||
|
r,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 0,
|
||||||
|
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
|
||||||
|
burst: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceParticle(p: ConfettiParticle): void {
|
||||||
|
p.opacity = 0;
|
||||||
|
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
|
||||||
|
p.x = range(-p.r * 2, this.width - p.r * 2);
|
||||||
|
p.y = range(-20, -p.r * 2);
|
||||||
|
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
|
||||||
|
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
|
||||||
|
p.elevation = 0;
|
||||||
|
p.targetElevation = 0;
|
||||||
|
p.baseColor = this.randomColor();
|
||||||
|
p.color = [...p.baseColor];
|
||||||
|
p.burst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
|
||||||
|
// Stagger gate
|
||||||
|
if (p.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= p.staggerDelay) {
|
||||||
|
p.staggerDelay = -1;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravity (capped so falling particles don't accelerate)
|
||||||
|
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
|
||||||
|
if (p.vy < maxVy) {
|
||||||
|
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position update
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
|
||||||
|
// Fade in, or fade out during exit
|
||||||
|
if (this.exiting && p.dop < 0) {
|
||||||
|
p.opacity += p.dop * dt;
|
||||||
|
if (p.opacity < 0) p.opacity = 0;
|
||||||
|
} else if (p.opacity < 1) {
|
||||||
|
p.opacity += Math.abs(p.dop) * dt;
|
||||||
|
if (p.opacity > 1) p.opacity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
|
||||||
|
if (p.y > this.height + p.r) {
|
||||||
|
if (p.burst || this.exiting) {
|
||||||
|
this.particles.splice(i, 1);
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
this.replaceParticle(p);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal wrap
|
||||||
|
const xmax = this.width - p.r;
|
||||||
|
if (p.x < 0 || p.x > xmax) {
|
||||||
|
p.x = ((p.x % xmax) + xmax) % xmax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse proximity elevation
|
||||||
|
const dx = p.x - mouseX;
|
||||||
|
const dy = p.y - mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
|
||||||
|
const influenceFactor = Math.cos(
|
||||||
|
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||||
|
);
|
||||||
|
p.targetElevation =
|
||||||
|
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||||
|
|
||||||
|
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
p.color = [
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
p.targetElevation = 0;
|
||||||
|
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
|
||||||
|
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
|
||||||
|
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation lerp
|
||||||
|
p.elevation +=
|
||||||
|
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_width: number,
|
||||||
|
_height: number
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
|
||||||
|
|
||||||
|
const drawX = ~~p.x;
|
||||||
|
const drawY = ~~p.y - p.elevation;
|
||||||
|
const [r, g, b] = p.color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (p.elevation > 0.5) {
|
||||||
|
const shadowAlpha =
|
||||||
|
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
ctx.shadowBlur = 2;
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.1)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
drawX,
|
||||||
|
drawY + p.elevation * SHADOW_OFFSET_RATIO,
|
||||||
|
p.r,
|
||||||
|
0,
|
||||||
|
PI_2
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main circle
|
||||||
|
ctx.globalAlpha = p.opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight on elevated particles
|
||||||
|
if (p.elevation > 0.5) {
|
||||||
|
const highlightAlpha =
|
||||||
|
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
if (this.width > 0) {
|
||||||
|
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
const count = 12;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const r = ~~range(3, 8);
|
||||||
|
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
|
||||||
|
const speed = range(0.3, 1.2);
|
||||||
|
this.particles.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
r,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
dop: this.exiting ? -0.02 : 0,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
burst: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
this.mouseXNorm = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
this.particles[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
670
src/components/background/engines/game-of-life.ts
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
alive: boolean;
|
||||||
|
next: boolean;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
opacity: number;
|
||||||
|
targetOpacity: number;
|
||||||
|
scale: number;
|
||||||
|
targetScale: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
transitioning: boolean;
|
||||||
|
transitionComplete: boolean;
|
||||||
|
rippleEffect: number;
|
||||||
|
rippleStartTime: number;
|
||||||
|
rippleDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Grid {
|
||||||
|
cells: Cell[][];
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELL_SIZE_MOBILE = 15;
|
||||||
|
const CELL_SIZE_DESKTOP = 25;
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const CYCLE_TIME = 3000;
|
||||||
|
const TRANSITION_SPEED = 0.05;
|
||||||
|
const SCALE_SPEED = 0.05;
|
||||||
|
const INITIAL_DENSITY = 0.15;
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const RIPPLE_ELEVATION_FACTOR = 4;
|
||||||
|
const ELEVATION_FACTOR = 8;
|
||||||
|
|
||||||
|
export class GameOfLifeEngine implements AnimationEngine {
|
||||||
|
id = "game-of-life";
|
||||||
|
name = "Game of Life";
|
||||||
|
|
||||||
|
private grid: Grid | null = null;
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private bgColor = "rgb(0, 0, 0)";
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private mouseIsDown = false;
|
||||||
|
private mouseCellX = -1;
|
||||||
|
private mouseCellY = -1;
|
||||||
|
private lastCycleTime = 0;
|
||||||
|
private timeAccumulator = 0;
|
||||||
|
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
private canvasWidth = 0;
|
||||||
|
private canvasHeight = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
this.canvasWidth = width;
|
||||||
|
this.canvasHeight = height;
|
||||||
|
this.lastCycleTime = 0;
|
||||||
|
this.timeAccumulator = 0;
|
||||||
|
this.grid = this.initGrid(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
this.grid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCellSize(): number {
|
||||||
|
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private initGrid(width: number, height: number): Grid {
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const cols = Math.floor(width / cellSize);
|
||||||
|
const rows = Math.floor(height / cellSize);
|
||||||
|
const offsetX = Math.floor((width - cols * cellSize) / 2);
|
||||||
|
const offsetY = Math.floor((height - rows * cellSize) / 2);
|
||||||
|
|
||||||
|
const cells = Array(cols)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) =>
|
||||||
|
Array(rows)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, j) => {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
return {
|
||||||
|
alive: Math.random() < INITIAL_DENSITY,
|
||||||
|
next: false,
|
||||||
|
color: [...baseColor] as [number, number, number],
|
||||||
|
baseColor,
|
||||||
|
currentX: i,
|
||||||
|
currentY: j,
|
||||||
|
targetX: i,
|
||||||
|
targetY: j,
|
||||||
|
opacity: 0,
|
||||||
|
targetOpacity: 0,
|
||||||
|
scale: 0,
|
||||||
|
targetScale: 0,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
transitioning: false,
|
||||||
|
transitionComplete: false,
|
||||||
|
rippleEffect: 0,
|
||||||
|
rippleStartTime: 0,
|
||||||
|
rippleDistance: 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = { cells, cols, rows, offsetX, offsetY };
|
||||||
|
this.computeNextState(grid);
|
||||||
|
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
for (let j = 0; j < rows; j++) {
|
||||||
|
const cell = cells[i][j];
|
||||||
|
if (cell.next) {
|
||||||
|
cell.alive = true;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetScale = 1;
|
||||||
|
}, Math.random() * 1000);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
} else {
|
||||||
|
cell.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countNeighbors(
|
||||||
|
grid: Grid,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): { count: number; colors: [number, number, number][] } {
|
||||||
|
const neighbors = { count: 0, colors: [] as [number, number, number][] };
|
||||||
|
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
for (let j = -1; j <= 1; j++) {
|
||||||
|
if (i === 0 && j === 0) continue;
|
||||||
|
|
||||||
|
const col = (x + i + grid.cols) % grid.cols;
|
||||||
|
const row = (y + j + grid.rows) % grid.rows;
|
||||||
|
|
||||||
|
if (grid.cells[col][row].alive) {
|
||||||
|
neighbors.count++;
|
||||||
|
neighbors.colors.push(grid.cells[col][row].baseColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private averageColors(
|
||||||
|
colors: [number, number, number][]
|
||||||
|
): [number, number, number] {
|
||||||
|
if (colors.length === 0) return [0, 0, 0];
|
||||||
|
const sum = colors.reduce(
|
||||||
|
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
|
||||||
|
[0, 0, 0]
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
Math.round(sum[0] / colors.length),
|
||||||
|
Math.round(sum[1] / colors.length),
|
||||||
|
Math.round(sum[2] / colors.length),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeNextState(grid: Grid): void {
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
const { count, colors } = this.countNeighbors(grid, i, j);
|
||||||
|
|
||||||
|
if (cell.alive) {
|
||||||
|
cell.next = count === 2 || count === 3;
|
||||||
|
} else {
|
||||||
|
cell.next = count === 3;
|
||||||
|
if (cell.next) {
|
||||||
|
cell.baseColor = this.averageColors(colors);
|
||||||
|
cell.color = [...cell.baseColor];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
if (cell.alive !== cell.next && !cell.transitioning) {
|
||||||
|
cell.transitioning = true;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
|
||||||
|
const delay = Math.random() * 800;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
if (!cell.next) {
|
||||||
|
cell.targetScale = 0;
|
||||||
|
cell.targetOpacity = 0;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
} else {
|
||||||
|
cell.targetScale = 1;
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRippleEffect(
|
||||||
|
grid: Grid,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number
|
||||||
|
): void {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
const dx = i - centerX;
|
||||||
|
const dy = j - centerY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (cell.opacity > 0.1) {
|
||||||
|
cell.rippleStartTime = currentTime + distance * 100;
|
||||||
|
cell.rippleDistance = distance;
|
||||||
|
cell.rippleEffect = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
|
||||||
|
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
|
||||||
|
const cell = grid.cells[x][y];
|
||||||
|
|
||||||
|
if (!cell.alive && !cell.transitioning) {
|
||||||
|
cell.alive = true;
|
||||||
|
cell.next = true;
|
||||||
|
cell.transitioning = true;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
cell.baseColor = this.randomColor();
|
||||||
|
cell.color = [...cell.baseColor];
|
||||||
|
cell.targetScale = 1;
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
|
||||||
|
this.createRippleEffect(grid, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting || !this.grid) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Cancel all pending GOL transitions so they don't revive cells
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
// Force cell into dying state, clear any pending transition
|
||||||
|
cell.next = false;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
|
||||||
|
if (cell.opacity > 0.01) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
cell.targetOpacity = 0;
|
||||||
|
cell.targetScale = 0;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}, delay);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
if (!this.grid) return true;
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
if (grid.cells[i][j].opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
if (!this.grid) return;
|
||||||
|
|
||||||
|
if (!this.exiting) {
|
||||||
|
this.timeAccumulator += deltaTime;
|
||||||
|
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||||
|
this.computeNextState(this.grid);
|
||||||
|
this.timeAccumulator -= CYCLE_TIME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCellAnimations(this.grid, deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCellAnimations(grid: Grid, deltaTime: number): void {
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
|
||||||
|
const transitionFactor =
|
||||||
|
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||||
|
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
|
||||||
|
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
|
||||||
|
cell.elevation +=
|
||||||
|
(cell.targetElevation - cell.elevation) * scaleFactor;
|
||||||
|
|
||||||
|
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
|
||||||
|
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
|
||||||
|
const dx = cellCenterX - mouseX;
|
||||||
|
const dy = cellCenterY - mouseY;
|
||||||
|
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
|
||||||
|
const influenceFactor = Math.cos(
|
||||||
|
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||||
|
);
|
||||||
|
cell.targetElevation =
|
||||||
|
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||||
|
|
||||||
|
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
cell.color = [
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
|
||||||
|
] as [number, number, number];
|
||||||
|
} else {
|
||||||
|
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
|
||||||
|
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
|
||||||
|
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
|
||||||
|
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// During exit: snap to zero once close enough
|
||||||
|
if (this.exiting) {
|
||||||
|
if (cell.opacity < 0.05) {
|
||||||
|
cell.opacity = 0;
|
||||||
|
cell.scale = 0;
|
||||||
|
cell.elevation = 0;
|
||||||
|
cell.alive = false;
|
||||||
|
}
|
||||||
|
} else if (cell.transitioning) {
|
||||||
|
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
|
||||||
|
cell.alive = false;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = true;
|
||||||
|
cell.opacity = 0;
|
||||||
|
cell.scale = 0;
|
||||||
|
cell.elevation = 0;
|
||||||
|
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
|
||||||
|
cell.alive = true;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.rippleStartTime > 0) {
|
||||||
|
const elapsedTime = Date.now() - cell.rippleStartTime;
|
||||||
|
if (elapsedTime > 0) {
|
||||||
|
const rippleProgress = elapsedTime / 1000;
|
||||||
|
|
||||||
|
if (rippleProgress < 1) {
|
||||||
|
const wavePhase = rippleProgress * Math.PI * 2;
|
||||||
|
const waveHeight =
|
||||||
|
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
|
||||||
|
|
||||||
|
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||||
|
cell.rippleEffect = waveHeight;
|
||||||
|
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
|
||||||
|
} else {
|
||||||
|
cell.rippleEffect = waveHeight * 0.3;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.rippleEffect = 0;
|
||||||
|
cell.rippleStartTime = 0;
|
||||||
|
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void {
|
||||||
|
if (!this.grid) return;
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const displayCellSize = cellSize * 0.8;
|
||||||
|
const roundness = displayCellSize * 0.2;
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
if (
|
||||||
|
(cell.alive || cell.targetOpacity > 0) &&
|
||||||
|
cell.opacity > 0.01
|
||||||
|
) {
|
||||||
|
const [r, g, b] = cell.color;
|
||||||
|
|
||||||
|
ctx.globalAlpha = cell.opacity * 0.9;
|
||||||
|
|
||||||
|
const scaledSize = displayCellSize * cell.scale;
|
||||||
|
const xOffset = (displayCellSize - scaledSize) / 2;
|
||||||
|
const yOffset = (displayCellSize - scaledSize) / 2;
|
||||||
|
|
||||||
|
const elevationOffset = cell.elevation;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
grid.offsetX +
|
||||||
|
i * cellSize +
|
||||||
|
(cellSize - displayCellSize) / 2 +
|
||||||
|
xOffset;
|
||||||
|
const y =
|
||||||
|
grid.offsetY +
|
||||||
|
j * cellSize +
|
||||||
|
(cellSize - displayCellSize) / 2 +
|
||||||
|
yOffset -
|
||||||
|
elevationOffset;
|
||||||
|
const scaledRoundness = roundness * cell.scale;
|
||||||
|
|
||||||
|
// Shadow for 3D effect
|
||||||
|
if (elevationOffset > 0.5) {
|
||||||
|
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1,
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize,
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize,
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1,
|
||||||
|
x + scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main cell
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y);
|
||||||
|
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||||
|
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
|
||||||
|
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + scaledSize,
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + scaledSize
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + scaledRoundness, y + scaledSize);
|
||||||
|
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
|
||||||
|
ctx.lineTo(x, y + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight on elevated cells
|
||||||
|
if (elevationOffset > 0.5) {
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y);
|
||||||
|
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y,
|
||||||
|
x + scaledSize,
|
||||||
|
y + scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + scaledSize, y + scaledSize / 3);
|
||||||
|
ctx.lineTo(x, y + scaledSize / 3);
|
||||||
|
ctx.lineTo(x, y + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.canvasWidth = width;
|
||||||
|
this.canvasHeight = height;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
if (
|
||||||
|
!this.grid ||
|
||||||
|
this.grid.cols !== Math.floor(width / cellSize) ||
|
||||||
|
this.grid.rows !== Math.floor(height / cellSize)
|
||||||
|
) {
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
this.grid = this.initGrid(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
this.mouseIsDown = isDown;
|
||||||
|
|
||||||
|
if (isDown && this.grid && !this.exiting) {
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||||
|
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||||
|
|
||||||
|
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
|
||||||
|
this.mouseCellX = cellX;
|
||||||
|
this.mouseCellY = cellY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cellX >= 0 &&
|
||||||
|
cellX < grid.cols &&
|
||||||
|
cellY >= 0 &&
|
||||||
|
cellY < grid.rows
|
||||||
|
) {
|
||||||
|
const cell = grid.cells[cellX][cellY];
|
||||||
|
if (!cell.alive && !cell.transitioning) {
|
||||||
|
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
this.mouseIsDown = true;
|
||||||
|
|
||||||
|
if (!this.grid || this.exiting) return;
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
|
||||||
|
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||||
|
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||||
|
|
||||||
|
if (
|
||||||
|
cellX >= 0 &&
|
||||||
|
cellX < grid.cols &&
|
||||||
|
cellY >= 0 &&
|
||||||
|
cellY < grid.rows
|
||||||
|
) {
|
||||||
|
this.mouseCellX = cellX;
|
||||||
|
this.mouseCellY = cellY;
|
||||||
|
|
||||||
|
const cell = grid.cells[cellX][cellY];
|
||||||
|
if (cell.alive) {
|
||||||
|
this.createRippleEffect(grid, cellX, cellY);
|
||||||
|
} else {
|
||||||
|
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {
|
||||||
|
this.mouseIsDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseIsDown = false;
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
|
||||||
|
if (this.grid) {
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
if (cell.alive && cell.opacity > 0.01) {
|
||||||
|
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
520
src/components/background/engines/lava-lamp.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface Blob {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
baseRadius: number;
|
||||||
|
radiusScale: number;
|
||||||
|
targetRadiusScale: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
targetColor: [number, number, number];
|
||||||
|
phase: number;
|
||||||
|
phaseSpeed: number;
|
||||||
|
staggerDelay: number; // -1 means already revealed
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOB_COUNT = 26;
|
||||||
|
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
|
||||||
|
const MIN_SPEED = 0.1;
|
||||||
|
const MAX_SPEED = 0.35;
|
||||||
|
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
|
||||||
|
const FIELD_THRESHOLD = 1.0;
|
||||||
|
const SMOOTHSTEP_RANGE = 0.25;
|
||||||
|
const MOUSE_REPEL_RADIUS = 150;
|
||||||
|
const MOUSE_REPEL_FORCE = 0.2;
|
||||||
|
const COLOR_LERP_SPEED = 0.02;
|
||||||
|
const DRIFT_AMPLITUDE = 0.2;
|
||||||
|
const RADIUS_LERP_SPEED = 0.06;
|
||||||
|
const STAGGER_INTERVAL = 60;
|
||||||
|
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
|
||||||
|
const CYCLE_MAX_MS = 5000; // max time
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||||
|
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LavaLampEngine implements AnimationEngine {
|
||||||
|
id = "lava-lamp";
|
||||||
|
name = "Lava Lamp";
|
||||||
|
|
||||||
|
private blobs: Blob[] = [];
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private bgRgb: [number, number, number] = [0, 0, 0];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private offCanvas: HTMLCanvasElement | null = null;
|
||||||
|
private offCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||||
|
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
private elapsed = 0;
|
||||||
|
private nextCycleTime = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
||||||
|
private blobX: Float64Array = new Float64Array(0);
|
||||||
|
private blobY: Float64Array = new Float64Array(0);
|
||||||
|
private blobR: Float64Array = new Float64Array(0);
|
||||||
|
private blobCR: Float64Array = new Float64Array(0);
|
||||||
|
private blobCG: Float64Array = new Float64Array(0);
|
||||||
|
private blobCB: Float64Array = new Float64Array(0);
|
||||||
|
private activeBlobCount = 0;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.parseBgColor(bgColor);
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
this.initBlobs();
|
||||||
|
this.initOffscreenCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBgColor(bgColor: string): void {
|
||||||
|
const match = bgColor.match(/(\d+)/g);
|
||||||
|
if (match && match.length >= 3) {
|
||||||
|
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxBlobs(): number {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
const scale = area / 2_073_600; // normalize to 1080p
|
||||||
|
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRadiusRange(): { min: number; max: number } {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
const scale = Math.sqrt(area / 2_073_600);
|
||||||
|
const min = Math.max(8, Math.round(25 * scale));
|
||||||
|
const max = Math.max(15, Math.round(65 * scale));
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
|
||||||
|
const { min, max } = this.getRadiusRange();
|
||||||
|
const color = this.palette[
|
||||||
|
Math.floor(Math.random() * this.palette.length)
|
||||||
|
] || [128, 128, 128];
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||||
|
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||||
|
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
|
||||||
|
radiusScale: 0,
|
||||||
|
targetRadiusScale: 1,
|
||||||
|
color: [...color],
|
||||||
|
targetColor: [...color],
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
phaseSpeed: 0.0005 + Math.random() * 0.001,
|
||||||
|
staggerDelay: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initBlobs(): void {
|
||||||
|
this.blobs = [];
|
||||||
|
const { max } = this.getRadiusRange();
|
||||||
|
const minDist = max * 2.5; // minimum distance between blob centers
|
||||||
|
|
||||||
|
for (let i = 0; i < BLOB_COUNT; i++) {
|
||||||
|
let x: number, y: number;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
// Try to find a position that doesn't overlap existing blobs
|
||||||
|
do {
|
||||||
|
x = Math.random() * this.width;
|
||||||
|
y = Math.random() * this.height;
|
||||||
|
attempts++;
|
||||||
|
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
|
||||||
|
|
||||||
|
const blob = this.makeBlob(x, y);
|
||||||
|
blob.targetRadiusScale = 0;
|
||||||
|
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
|
||||||
|
this.blobs.push(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
const dx = blob.x - x;
|
||||||
|
const dy = blob.y - y;
|
||||||
|
if (dx * dx + dy * dy < minDist * minDist) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initOffscreenCanvas(): void {
|
||||||
|
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
|
||||||
|
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
|
||||||
|
|
||||||
|
this.offCanvas = document.createElement("canvas");
|
||||||
|
this.offCanvas.width = rw;
|
||||||
|
this.offCanvas.height = rh;
|
||||||
|
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
|
||||||
|
this.shadowCanvas = document.createElement("canvas");
|
||||||
|
this.shadowCanvas.width = rw;
|
||||||
|
this.shadowCanvas.height = rh;
|
||||||
|
this.shadowCtx = this.shadowCanvas.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
const blob = this.blobs[i];
|
||||||
|
if (blob.staggerDelay >= 0) {
|
||||||
|
blob.staggerDelay = -1;
|
||||||
|
}
|
||||||
|
// Stagger the shrink over ~2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
blob.targetRadiusScale = 0;
|
||||||
|
}, Math.random() * 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
return this.blobs.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.blobs = [];
|
||||||
|
this.offCanvas = null;
|
||||||
|
this.offCtx = null;
|
||||||
|
this.shadowCanvas = null;
|
||||||
|
this.shadowCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
|
||||||
|
private syncBlobArrays(): void {
|
||||||
|
const blobs = this.blobs;
|
||||||
|
const n = blobs.length;
|
||||||
|
|
||||||
|
// Grow arrays if needed
|
||||||
|
if (this.blobX.length < n) {
|
||||||
|
const cap = n + 32;
|
||||||
|
this.blobX = new Float64Array(cap);
|
||||||
|
this.blobY = new Float64Array(cap);
|
||||||
|
this.blobR = new Float64Array(cap);
|
||||||
|
this.blobCR = new Float64Array(cap);
|
||||||
|
this.blobCG = new Float64Array(cap);
|
||||||
|
this.blobCB = new Float64Array(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const b = blobs[i];
|
||||||
|
const r = b.baseRadius * b.radiusScale;
|
||||||
|
if (r < 1) continue; // skip invisible blobs entirely
|
||||||
|
this.blobX[count] = b.x;
|
||||||
|
this.blobY[count] = b.y;
|
||||||
|
this.blobR[count] = r;
|
||||||
|
this.blobCR[count] = b.color[0];
|
||||||
|
this.blobCG[count] = b.color[1];
|
||||||
|
this.blobCB[count] = b.color[2];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
this.activeBlobCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / 60);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
// Staggered load-in
|
||||||
|
if (blob.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= blob.staggerDelay) {
|
||||||
|
blob.targetRadiusScale = 1;
|
||||||
|
blob.staggerDelay = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.radiusScale +=
|
||||||
|
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
|
||||||
|
|
||||||
|
blob.phase += blob.phaseSpeed * deltaTime;
|
||||||
|
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
|
||||||
|
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
|
||||||
|
|
||||||
|
blob.vx += driftX * dt * 0.01;
|
||||||
|
blob.vy += driftY * dt * 0.01;
|
||||||
|
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
|
||||||
|
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
|
||||||
|
|
||||||
|
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
|
||||||
|
if (speed > MAX_SPEED) {
|
||||||
|
blob.vx = (blob.vx / speed) * MAX_SPEED;
|
||||||
|
blob.vy = (blob.vy / speed) * MAX_SPEED;
|
||||||
|
}
|
||||||
|
if (speed < MIN_SPEED) {
|
||||||
|
const angle = Math.atan2(blob.vy, blob.vx);
|
||||||
|
blob.vx = Math.cos(angle) * MIN_SPEED;
|
||||||
|
blob.vy = Math.sin(angle) * MIN_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.x += blob.vx * dt;
|
||||||
|
blob.y += blob.vy * dt;
|
||||||
|
|
||||||
|
const pad = blob.baseRadius * 0.3;
|
||||||
|
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
|
||||||
|
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
|
||||||
|
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
|
||||||
|
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
|
||||||
|
|
||||||
|
// Mouse repulsion
|
||||||
|
const dx = blob.x - this.mouseX;
|
||||||
|
const dy = blob.y - this.mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
|
||||||
|
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
|
||||||
|
blob.vx += (dx / dist) * force;
|
||||||
|
blob.vy += (dy / dist) * force;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
|
||||||
|
for (let i = this.blobs.length - 1; i >= 0; i--) {
|
||||||
|
const b = this.blobs[i];
|
||||||
|
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
|
||||||
|
this.blobs.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Natural spawn/despawn cycle — keeps the scene alive
|
||||||
|
if (!this.exiting && this.elapsed >= this.nextCycleTime) {
|
||||||
|
// Pick a random visible blob to fade out (skip ones still staggering in)
|
||||||
|
const visible = [];
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
|
||||||
|
visible.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visible.length > 0) {
|
||||||
|
const killIdx = visible[Math.floor(Math.random() * visible.length)];
|
||||||
|
this.blobs[killIdx].targetRadiusScale = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a fresh one at a random position
|
||||||
|
const blob = this.makeBlob(
|
||||||
|
Math.random() * this.width,
|
||||||
|
Math.random() * this.height
|
||||||
|
);
|
||||||
|
this.blobs.push(blob);
|
||||||
|
|
||||||
|
// Schedule next cycle
|
||||||
|
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
|
||||||
|
const maxBlobs = this.getMaxBlobs();
|
||||||
|
if (this.blobs.length > maxBlobs) {
|
||||||
|
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void {
|
||||||
|
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Snapshot blob positions/radii into typed arrays for fast pixel loop
|
||||||
|
this.syncBlobArrays();
|
||||||
|
|
||||||
|
const rw = this.offCanvas.width;
|
||||||
|
const rh = this.offCanvas.height;
|
||||||
|
|
||||||
|
// Render shadow layer
|
||||||
|
const shadowData = this.shadowCtx.createImageData(rw, rh);
|
||||||
|
this.renderField(shadowData, rw, rh, true);
|
||||||
|
this.shadowCtx.putImageData(shadowData, 0, 0);
|
||||||
|
|
||||||
|
// Render main layer
|
||||||
|
const imageData = this.offCtx.createImageData(rw, rh);
|
||||||
|
this.renderField(imageData, rw, rh, false);
|
||||||
|
this.offCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "medium";
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.2;
|
||||||
|
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(this.offCanvas, 0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderField(
|
||||||
|
imageData: ImageData,
|
||||||
|
rw: number,
|
||||||
|
rh: number,
|
||||||
|
isShadow: boolean
|
||||||
|
): void {
|
||||||
|
const data = imageData.data;
|
||||||
|
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
|
||||||
|
const bgR = this.bgRgb[0];
|
||||||
|
const bgG = this.bgRgb[1];
|
||||||
|
const bgB = this.bgRgb[2];
|
||||||
|
const scale = RESOLUTION_SCALE;
|
||||||
|
const n = this.activeBlobCount;
|
||||||
|
const bx = this.blobX;
|
||||||
|
const by = this.blobY;
|
||||||
|
const br = this.blobR;
|
||||||
|
const bcr = this.blobCR;
|
||||||
|
const bcg = this.blobCG;
|
||||||
|
const bcb = this.blobCB;
|
||||||
|
const threshLow = threshold - SMOOTHSTEP_RANGE;
|
||||||
|
|
||||||
|
for (let py = 0; py < rh; py++) {
|
||||||
|
const wy = py * scale;
|
||||||
|
for (let px = 0; px < rw; px++) {
|
||||||
|
const wx = px * scale;
|
||||||
|
|
||||||
|
let fieldSum = 0;
|
||||||
|
let weightedR = 0;
|
||||||
|
let weightedG = 0;
|
||||||
|
let weightedB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const dx = wx - bx[i];
|
||||||
|
const dy = wy - by[i];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
const ri = br[i];
|
||||||
|
const rSq = ri * ri;
|
||||||
|
// Raw metaball field
|
||||||
|
const raw = rSq / (distSq + rSq * 0.1);
|
||||||
|
// Cap per-blob contribution so color stays flat inside the blob
|
||||||
|
const contribution = raw > 2 ? 2 : raw;
|
||||||
|
|
||||||
|
fieldSum += contribution;
|
||||||
|
|
||||||
|
if (contribution > 0.01) {
|
||||||
|
weightedR += bcr[i] * contribution;
|
||||||
|
weightedG += bcg[i] * contribution;
|
||||||
|
weightedB += bcb[i] * contribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = (py * rw + px) << 2;
|
||||||
|
|
||||||
|
if (fieldSum > threshLow) {
|
||||||
|
const alpha = smoothstep(threshLow, threshold, fieldSum);
|
||||||
|
|
||||||
|
if (isShadow) {
|
||||||
|
data[idx] = 0;
|
||||||
|
data[idx + 1] = 0;
|
||||||
|
data[idx + 2] = 0;
|
||||||
|
data[idx + 3] = (alpha * 150) | 0;
|
||||||
|
} else {
|
||||||
|
const invField = 1 / fieldSum;
|
||||||
|
const r = Math.min(255, (weightedR * invField) | 0);
|
||||||
|
const g = Math.min(255, (weightedG * invField) | 0);
|
||||||
|
const b = Math.min(255, (weightedB * invField) | 0);
|
||||||
|
|
||||||
|
data[idx] = bgR + (r - bgR) * alpha;
|
||||||
|
data[idx + 1] = bgG + (g - bgG) * alpha;
|
||||||
|
data[idx + 2] = bgB + (b - bgB) * alpha;
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isShadow) {
|
||||||
|
// data stays 0 (already zeroed by createImageData)
|
||||||
|
} else {
|
||||||
|
data[idx] = bgR;
|
||||||
|
data[idx + 1] = bgG;
|
||||||
|
data[idx + 2] = bgB;
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
this.initBlobs();
|
||||||
|
this.initOffscreenCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sampleColorAt(x: number, y: number): [number, number, number] | null {
|
||||||
|
let closest: Blob | null = null;
|
||||||
|
let closestDist = Infinity;
|
||||||
|
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
const dx = blob.x - x;
|
||||||
|
const dy = blob.y - y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
|
||||||
|
closestDist = dist;
|
||||||
|
closest = blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest ? ([...closest.color] as [number, number, number]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnAt(x: number, y: number): void {
|
||||||
|
const { max } = this.getRadiusRange();
|
||||||
|
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
|
||||||
|
const nearby = this.sampleColorAt(x, y);
|
||||||
|
if (nearby) {
|
||||||
|
blob.color = nearby;
|
||||||
|
blob.targetColor = [...nearby];
|
||||||
|
}
|
||||||
|
this.blobs.push(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.spawnAt(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.parseBgColor(bgColor);
|
||||||
|
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
this.blobs[i].targetColor = [
|
||||||
|
...palette[i % palette.length],
|
||||||
|
] as [number, number, number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||