Compare commits
20 Commits
658f4ab841
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d014e4f1b | ||
|
|
55ec01e3cd | ||
|
|
d5bac98b76 | ||
|
|
8cfa39a4d4 | ||
|
|
a79f05c043 | ||
|
|
f4a0e2a82b | ||
|
|
cabf963e94 | ||
|
|
2cdc9bd0b6 | ||
|
|
5cd52f2785 | ||
|
|
a9d2c43bfd | ||
|
|
73a6681ceb | ||
|
|
faa9609254 | ||
|
|
bb4ca0610d | ||
|
|
28bde53707 | ||
|
|
36cb793048 | ||
|
|
8dae3578b1 | ||
|
|
ea05b814b4 | ||
|
|
a137a98377 | ||
|
|
9b9976c70a | ||
|
|
961383b402 |
15
Caddyfile
Normal file
15
Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
email me@syntaxbullet.com
|
||||||
|
}
|
||||||
|
|
||||||
|
yourdomain.com {
|
||||||
|
reverse_proxy web:4321
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Robust logging
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Dockerfile
17
Dockerfile
@@ -1,10 +1,17 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
COPY . .
|
COPY --from=build /app/package*.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
command: npm run dev -- --host
|
command: npm run dev -- --host
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: hello-world
|
||||||
|
entrypoint: ["true"]
|
||||||
|
restart: "no"
|
||||||
|
ports: []
|
||||||
|
volumes: []
|
||||||
|
depends_on: []
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
restart: always
|
||||||
- "4321:4321"
|
|
||||||
environment:
|
environment:
|
||||||
- PORT=4321
|
- PORT=4321
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:latest
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
- caddy_logs:/var/log/caddy
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
caddy_logs:
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -11,8 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "^9.5.2",
|
||||||
|
"@lucide/astro": "^0.563.0",
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
"gifuct-js": "^2.1.2",
|
"gifuct-js": "^2.1.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1180,6 +1182,15 @@
|
|||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lucide/astro": {
|
||||||
|
"version": "0.563.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lucide/astro/-/astro-0.563.0.tgz",
|
||||||
|
"integrity": "sha512-X9fNJvRR6pLJfkIEAFQkizWaNVvcduunJoFyR3fwPu30Y6jOu5S9k4k7HTSk3ZrEfqK2eFEqrBqqWH4fwSNKCg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"astro": "^4 || ^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oslojs/encoding": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
@@ -4387,6 +4398,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "^9.5.2",
|
||||||
|
"@lucide/astro": "^0.563.0",
|
||||||
"astro": "^5.17.1",
|
"astro": "^5.17.1",
|
||||||
"gifuct-js": "^2.1.2",
|
"gifuct-js": "^2.1.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/assets/blue-noise.png
Normal file
BIN
public/assets/blue-noise.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="black"/>
|
||||||
|
<text x="50" y="70" font-family="monospace" font-size="70" fill="white" text-anchor="middle" font-weight="900">S</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://syntaxbullet.com/sitemap-index.xml
|
||||||
2145
src/components/ControlPanel.astro
Normal file
2145
src/components/ControlPanel.astro
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,8 +55,11 @@ const { pathname } = Astro.url;
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: #000;
|
background: rgba(10, 10, 10, 0.8);
|
||||||
border-bottom: 1px solid var(--text-color);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -81,30 +84,32 @@ const { pathname } = Astro.url;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-right: 1px solid rgba(255, 103, 0, 0.2);
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
transition: all 0.1s;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: var(--text-color);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #000;
|
color: #fff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover .nav-index {
|
.nav-link:hover .nav-index {
|
||||||
color: #000;
|
color: #fff;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item.active {
|
.status-item.active {
|
||||||
background: var(--text-color);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
color: #000;
|
color: #fff;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
box-shadow: inset 0 -2px 0 var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item.brand {
|
.status-item.brand {
|
||||||
background: rgba(255, 103, 0, 0.1);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-index {
|
.nav-index {
|
||||||
@@ -122,7 +127,7 @@ const { pathname } = Astro.url;
|
|||||||
|
|
||||||
.status-right .status-item {
|
.status-right .status-item {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-left: 1px solid rgba(255, 103, 0, 0.2);
|
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefix {
|
.prefix {
|
||||||
@@ -134,6 +139,7 @@ const { pathname } = Astro.url;
|
|||||||
#system-status {
|
#system-status {
|
||||||
color: #0f0;
|
color: #0f0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
139
src/components/Tooltip.astro
Normal file
139
src/components/Tooltip.astro
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="tui-tooltip" class="tui-tooltip">
|
||||||
|
<div class="tooltip-header">
|
||||||
|
<span class="tooltip-title"></span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-body">
|
||||||
|
<span class="tooltip-desc"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tui-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(10, 10, 10, 0.95);
|
||||||
|
border: 1px solid var(--text-color, #ff6700);
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 300px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
will-change: transform, display;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-header {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid rgba(255, 103, 0, 0.3);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-color, #ff6700);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-desc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tooltip = document.getElementById("tui-tooltip");
|
||||||
|
const titleEl = tooltip?.querySelector(".tooltip-title");
|
||||||
|
const descEl = tooltip?.querySelector(".tooltip-desc");
|
||||||
|
const OFFSET_X = 15;
|
||||||
|
const OFFSET_Y = 15;
|
||||||
|
|
||||||
|
if (tooltip && titleEl && descEl) {
|
||||||
|
let isVisible = false;
|
||||||
|
|
||||||
|
const updatePosition = (e: MouseEvent) => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const rect = tooltip.getBoundingClientRect();
|
||||||
|
const winW = window.innerWidth;
|
||||||
|
const winH = window.innerHeight;
|
||||||
|
|
||||||
|
// Calculate potential position
|
||||||
|
let x = e.clientX + OFFSET_X;
|
||||||
|
let y = e.clientY + OFFSET_Y;
|
||||||
|
|
||||||
|
// Flip horizontally if toolip goes off right edge
|
||||||
|
if (x + rect.width > winW) {
|
||||||
|
x = e.clientX - rect.width - OFFSET_X;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip vertically if tooltip goes off bottom edge
|
||||||
|
if (y + rect.height > winH) {
|
||||||
|
y = e.clientY - rect.height - OFFSET_Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it doesn't go off top/left
|
||||||
|
x = Math.max(0, x);
|
||||||
|
y = Math.max(0, y);
|
||||||
|
|
||||||
|
tooltip.style.left = `${x}px`;
|
||||||
|
tooltip.style.top = `${y}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTooltip = (target: Element, e: MouseEvent) => {
|
||||||
|
// Only show on devices with hover capability (mouse)
|
||||||
|
if (!window.matchMedia("(hover: hover)").matches) return;
|
||||||
|
|
||||||
|
const title = target.getAttribute("data-tooltip-title");
|
||||||
|
const desc = target.getAttribute("data-tooltip-desc");
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
titleEl.textContent = title;
|
||||||
|
descEl.textContent = desc || "";
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
isVisible = true;
|
||||||
|
updatePosition(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
isVisible = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event delegation
|
||||||
|
document.addEventListener("mouseover", (e) => {
|
||||||
|
const target = (e.target as HTMLElement).closest(
|
||||||
|
"[data-tooltip-title]",
|
||||||
|
);
|
||||||
|
if (target) {
|
||||||
|
showTooltip(target, e as MouseEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseout", (e) => {
|
||||||
|
const target = (e.target as HTMLElement).closest(
|
||||||
|
"[data-tooltip-title]",
|
||||||
|
);
|
||||||
|
if (target) {
|
||||||
|
const related = e.relatedTarget as HTMLElement;
|
||||||
|
if (related && target.contains(related)) return;
|
||||||
|
hideTooltip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", (e) => {
|
||||||
|
if (isVisible) {
|
||||||
|
updatePosition(e as MouseEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,89 +2,129 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
abbr?: string;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
variant?: "default" | "primary" | "subtle";
|
variant?: "default" | "primary" | "subtle";
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, label, shortcut, variant = "default", title = "" } = Astro.props;
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
abbr,
|
||||||
|
shortcut,
|
||||||
|
variant = "default",
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:list={["tui-button", `tui-button--${variant}`]}
|
class:list={["tui-button", `tui-button--${variant}`]}
|
||||||
id={id}
|
id={id}
|
||||||
title={title}
|
data-tooltip-title={title}
|
||||||
|
data-tooltip-desc={description}
|
||||||
>
|
>
|
||||||
{shortcut && <span class="tui-button-shortcut">{shortcut}</span>}
|
{shortcut && <span class="tui-button-shortcut">{shortcut}</span>}
|
||||||
<span class="tui-button-label">{label}</span>
|
<span class:list={["tui-button-label", { "has-abbr": !!abbr }]}>
|
||||||
|
<span class="full">{label}</span>
|
||||||
|
{abbr && <span class="abbr">{abbr}</span>}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tui-button {
|
.tui-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
background: none;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 103, 0, 0.4);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: var(--text-color);
|
color: #fff;
|
||||||
|
opacity: 0.8;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 10px;
|
padding: 4px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.8;
|
transition: all 0.2s;
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button:hover {
|
.tui-button:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-color: var(--text-color);
|
border-color: var(--accent-color);
|
||||||
background: rgba(255, 103, 0, 0.1);
|
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button:active {
|
.tui-button:active {
|
||||||
background: rgba(255, 103, 0, 0.2);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button--primary {
|
.tui-button--primary {
|
||||||
border-color: var(--text-color);
|
background: var(--accent-color);
|
||||||
background: rgba(255, 103, 0, 0.1);
|
border-color: var(--accent-color);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 15px
|
||||||
|
color-mix(in srgb, var(--accent-color), transparent 80%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button--primary:hover {
|
.tui-button--primary:hover {
|
||||||
background: var(--text-color);
|
background: color-mix(in srgb, var(--accent-color), black 10%);
|
||||||
color: #000;
|
border-color: color-mix(in srgb, var(--accent-color), black 10%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 20px
|
||||||
|
color-mix(in srgb, var(--accent-color), transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button--subtle {
|
.tui-button--subtle {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button--subtle:hover {
|
.tui-button--subtle:hover {
|
||||||
border-color: rgba(255, 103, 0, 0.3);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button-shortcut {
|
.tui-button-shortcut {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
padding: 0 3px;
|
padding: 1px 4px;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
line-height: 1.2;
|
line-height: 1;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button:hover .tui-button-shortcut {
|
.tui-button:hover .tui-button-shortcut {
|
||||||
opacity: 1;
|
opacity: 0.8;
|
||||||
}
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
.tui-button--primary:hover .tui-button-shortcut {
|
|
||||||
border-color: #000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-button-label {
|
.tui-button-label {
|
||||||
font-weight: bold;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-button-label .abbr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.tui-button-label.has-abbr .full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tui-button-label.has-abbr .abbr {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
abbr?: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
value?: string;
|
value?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, label, options, value = options[0], title = "" } = Astro.props;
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
abbr,
|
||||||
|
options,
|
||||||
|
value = options[0],
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="tui-segment" data-segment-id={id} title={title}>
|
<div
|
||||||
<span class="tui-segment-label">{label}</span>
|
class="tui-segment"
|
||||||
|
data-segment-id={id}
|
||||||
|
data-tooltip-title={title}
|
||||||
|
data-tooltip-desc={description}
|
||||||
|
>
|
||||||
|
<span class:list={["tui-segment-label", { "has-abbr": !!abbr }]}>
|
||||||
|
<span class="full">{label}</span>
|
||||||
|
{abbr && <span class="abbr">{abbr}</span>}
|
||||||
|
</span>
|
||||||
<div class="tui-segment-options" id={id} data-value={value}>
|
<div class="tui-segment-options" id={id} data-value={value}>
|
||||||
{
|
{
|
||||||
options.map((opt) => (
|
options.map((opt) => (
|
||||||
@@ -34,33 +52,53 @@ const { id, label, options, value = options[0], title = "" } = Astro.props;
|
|||||||
.tui-segment {
|
.tui-segment {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment-label {
|
.tui-segment-label {
|
||||||
min-width: 3ch;
|
min-width: 3ch;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-segment-label .abbr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.tui-segment-label.has-abbr .full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tui-segment-label.has-abbr .abbr {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment-options {
|
.tui-segment-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid rgba(255, 103, 0, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment-option {
|
.tui-segment-option {
|
||||||
background: none;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-right: 1px solid rgba(255, 103, 0, 0.2);
|
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text-color);
|
color: #fff;
|
||||||
|
opacity: 0.6;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
padding: 2px 8px;
|
padding: 4px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.5;
|
transition: all 0.2s;
|
||||||
transition: all 0.15s;
|
|
||||||
min-width: 3ch;
|
min-width: 3ch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -70,24 +108,26 @@ const { id, label, options, value = options[0], title = "" } = Astro.props;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment-option:hover {
|
.tui-segment-option:hover {
|
||||||
opacity: 0.8;
|
color: var(--accent-color);
|
||||||
background: rgba(255, 103, 0, 0.1);
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment-option.active {
|
.tui-segment-option.active {
|
||||||
background: var(--text-color);
|
background: var(--accent-color);
|
||||||
color: #000;
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover the whole group */
|
/* Hover the whole group */
|
||||||
.tui-segment:hover .tui-segment-label {
|
.tui-segment:hover .tui-segment-label {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-segment:hover .tui-segment-options {
|
.tui-segment:hover .tui-segment-options {
|
||||||
border-color: var(--text-color);
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,46 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
abbr?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
value?: number;
|
value?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
abbr,
|
||||||
min = 0,
|
min = 0,
|
||||||
max = 5,
|
max = 5,
|
||||||
step = 0.1,
|
step = 0.1,
|
||||||
value = 1.0,
|
value = 1.0,
|
||||||
title = "",
|
title = "",
|
||||||
|
description = "",
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// Generate slider visual (12 segments for better resolution)
|
// Generate slider visual (12 segments for better resolution)
|
||||||
const segments = 12;
|
const segments = 12;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="tui-slider" data-slider-id={id} title={title}>
|
<div
|
||||||
<span class="tui-slider-label">{label}</span>
|
class="tui-slider"
|
||||||
|
data-slider-id={id}
|
||||||
|
data-default-value={value}
|
||||||
|
data-tooltip-title={title}
|
||||||
|
data-tooltip-desc={description}
|
||||||
|
>
|
||||||
|
<div class="tui-slider-header">
|
||||||
|
<span class:list={["tui-slider-label", { "has-abbr": !!abbr }]}>
|
||||||
|
<span class="full">{label}</span>
|
||||||
|
{abbr && <span class="abbr">{abbr}</span>}
|
||||||
|
</span>
|
||||||
|
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div class="tui-slider-track-wrapper">
|
<div class="tui-slider-track-wrapper">
|
||||||
<div class="tui-slider-visual">
|
<div class="tui-slider-visual">
|
||||||
<span class="tui-slider-track" data-for={id}>
|
<span class="tui-slider-track" data-for={id}>
|
||||||
@@ -49,22 +66,50 @@ const segments = 12;
|
|||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(1)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tui-slider {
|
.tui-slider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-label {
|
.tui-slider-label {
|
||||||
min-width: 3ch;
|
font-weight: 700;
|
||||||
font-weight: bold;
|
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider-label .abbr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.tui-slider-label.has-abbr .full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tui-slider-label.has-abbr .abbr {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-track-wrapper {
|
.tui-slider-track-wrapper {
|
||||||
@@ -82,22 +127,54 @@ const segments = 12;
|
|||||||
|
|
||||||
.tui-slider-track {
|
.tui-slider-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
letter-spacing: -1px;
|
letter-spacing: 2px;
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-segment {
|
.tui-slider-segment {
|
||||||
transition: color 0.1s;
|
transition: color 0.1s;
|
||||||
color: rgba(255, 103, 0, 0.25);
|
color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-segment.filled {
|
.tui-slider-segment.filled {
|
||||||
color: var(--text-color);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-segment.thumb {
|
.tui-slider-segment.thumb {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 0 4px var(--text-color);
|
text-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
|
||||||
|
scale: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider:hover .tui-slider-segment.filled {
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider:hover .tui-slider-segment.thumb {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-shadow: 0 0 8px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modified state (moved from default) */
|
||||||
|
.tui-slider.modified .tui-slider-segment.filled {
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider.modified .tui-slider-segment.thumb {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-shadow: 0 0 8px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider.modified .tui-slider-label {
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider.modified .tui-slider-value {
|
||||||
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-input {
|
.tui-slider-input {
|
||||||
@@ -113,27 +190,27 @@ const segments = 12;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider-value {
|
.tui-slider-value {
|
||||||
min-width: 3ch;
|
font-weight: 400;
|
||||||
text-align: right;
|
opacity: 0.8;
|
||||||
font-weight: bold;
|
font-family: var(--font-mono);
|
||||||
opacity: 0.9;
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover effect */
|
/* Hover effect */
|
||||||
.tui-slider:hover .tui-slider-label {
|
.tui-slider:hover .tui-slider-label {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-slider:hover .tui-slider-value {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-slider:hover .tui-slider-segment {
|
.tui-slider:hover .tui-slider-segment {
|
||||||
color: rgba(255, 103, 0, 0.4);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
|
||||||
|
|
||||||
.tui-slider:hover .tui-slider-segment.filled {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-slider:hover .tui-slider-segment.thumb {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -150,6 +227,9 @@ const segments = 12;
|
|||||||
const valueDisplay = sliderContainer.querySelector(
|
const valueDisplay = sliderContainer.querySelector(
|
||||||
".tui-slider-value",
|
".tui-slider-value",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
|
const defaultValue = parseFloat(
|
||||||
|
sliderContainer.getAttribute("data-default-value") || "0",
|
||||||
|
);
|
||||||
|
|
||||||
if (!input || !track || !valueDisplay) return;
|
if (!input || !track || !valueDisplay) return;
|
||||||
|
|
||||||
@@ -179,7 +259,11 @@ const segments = 12;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
valueDisplay.textContent = val.toFixed(1);
|
valueDisplay.textContent = val.toFixed(2);
|
||||||
|
|
||||||
|
// Add modified class if value differs from default
|
||||||
|
const isModified = Math.abs(val - defaultValue) > 0.001;
|
||||||
|
sliderContainer.classList.toggle("modified", isModified);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener("input", updateVisual);
|
input.addEventListener("input", updateVisual);
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
abbr?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, label, checked = false, title = "" } = Astro.props;
|
const {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
abbr,
|
||||||
|
checked = false,
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -14,9 +23,13 @@ const { id, label, checked = false, title = "" } = Astro.props;
|
|||||||
class:list={["tui-toggle", { active: checked }]}
|
class:list={["tui-toggle", { active: checked }]}
|
||||||
id={id}
|
id={id}
|
||||||
data-checked={checked ? "true" : "false"}
|
data-checked={checked ? "true" : "false"}
|
||||||
title={title}
|
data-tooltip-title={title}
|
||||||
|
data-tooltip-desc={description}
|
||||||
>
|
>
|
||||||
<span class="tui-toggle-label">{label}</span>
|
<span class:list={["tui-toggle-label", { "has-abbr": !!abbr }]}>
|
||||||
|
<span class="full">{label}</span>
|
||||||
|
{abbr && <span class="abbr">{abbr}</span>}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -24,35 +37,55 @@ const { id, label, checked = false, title = "" } = Astro.props;
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: none;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 103, 0, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: var(--text-color);
|
color: #fff;
|
||||||
|
opacity: 0.8;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 4px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.5;
|
transition: all 0.2s;
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
min-width: 3ch;
|
min-width: 3ch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-toggle:hover {
|
.tui-toggle:hover {
|
||||||
opacity: 0.8;
|
color: var(--accent-color);
|
||||||
background: rgba(255, 103, 0, 0.1);
|
opacity: 1;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: color-mix(in srgb, var(--accent-color), transparent 95%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-toggle.active {
|
.tui-toggle.active {
|
||||||
background: var(--text-color);
|
background: var(--accent-color);
|
||||||
color: #000;
|
color: #fff;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-weight: bold;
|
border-color: var(--accent-color);
|
||||||
border-color: var(--text-color);
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 15px
|
||||||
|
color-mix(in srgb, var(--accent-color), transparent 80%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-toggle-label {
|
.tui-toggle-label {
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-toggle-label .abbr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.tui-toggle-label.has-abbr .full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tui-toggle-label.has-abbr .abbr {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
20
src/content/blog/coming-soon.md
Normal file
20
src/content/blog/coming-soon.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: "The Future of Syntaxbullet"
|
||||||
|
description: "A glimpse into what's coming next for this digital garden."
|
||||||
|
pubDate: "2026-02-10"
|
||||||
|
heroImage: "/blog-placeholder-1.jpg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Welcome to my Website
|
||||||
|
|
||||||
|
This digital garden is currently sprouting.
|
||||||
|
|
||||||
|
I'm working on a series of articles that explore the intersection of **engineering, design, and artificial intelligence**.
|
||||||
|
|
||||||
|
Upcoming topics will include:
|
||||||
|
- Deep dives into the ASCII art generation techniques used on the homepage.
|
||||||
|
- Modern web performance optimization strategies.
|
||||||
|
- Thoughts on the evolving role of AI in software development.
|
||||||
|
- Case studies of successful AI-powered software projects and papers.
|
||||||
|
|
||||||
|
Stay tuned for updates. In the meantime, feel free to play with the [generator](/).
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
title: 'System Initialization'
|
|
||||||
description: 'Bootstrapping the Neko ASCII Generator.'
|
|
||||||
pubDate: '2026-02-08'
|
|
||||||
heroImage: '/blog/boot.png'
|
|
||||||
---
|
|
||||||
|
|
||||||
## Initializing Core Systems...
|
|
||||||
|
|
||||||
The Neko ASCII Auto-Generator has been successfully migrated to the Astro framework.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Real-time image processing
|
|
||||||
- CLI-inspired controls
|
|
||||||
- Dynamic font scaling
|
|
||||||
- Automatic parameter tuning based on image histogram
|
|
||||||
|
|
||||||
### Changelog v2.0
|
|
||||||
- Migrated from vanilla HTML/JS to Astro
|
|
||||||
- Added Blog module
|
|
||||||
- Improved mobile responsiveness
|
|
||||||
|
|
||||||
Running diagnostics... **OK**
|
|
||||||
Systems online.
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
---
|
---
|
||||||
import Navbar from '../components/Navbar.astro';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
showScroll?: boolean;
|
showScroll?: boolean;
|
||||||
@@ -14,30 +12,33 @@ const { title, showScroll = false } = Astro.props;
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Syntaxbullet - Full Stack Engineer & Creative Technologist. Building high-performance digital experiences with a focus on engineering, design, and AI."
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@import "../styles/global.css";
|
@import "../styles/global.css";
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style define:vars={{ overflow: showScroll ? 'auto' : 'hidden' }}>
|
<style define:vars={{ overflow: showScroll ? "auto" : "hidden" }}>
|
||||||
body {
|
body {
|
||||||
overflow: var(--overflow);
|
overflow: var(--overflow);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
padding-top: 24px;
|
box-sizing: border-box;
|
||||||
box-sizing: border-box;
|
}
|
||||||
}
|
</style>
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Navbar />
|
|
||||||
<slot />
|
<slot />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
362
src/pages/ascii.astro
Normal file
362
src/pages/ascii.astro
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import Tooltip from "../components/Tooltip.astro";
|
||||||
|
import ControlPanel from "../components/ControlPanel.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="ASCII Art Generator - Syntaxbullet">
|
||||||
|
<div class="ascii-layout">
|
||||||
|
<main class="ascii-workspace">
|
||||||
|
<div class="canvas-layer">
|
||||||
|
<div id="loading">Loading...</div>
|
||||||
|
<pre id="ascii-result"></pre>
|
||||||
|
<canvas id="ascii-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ControlPanel />
|
||||||
|
|
||||||
|
<div id="landing-screen" class="landing-overlay">
|
||||||
|
<div class="landing-content">
|
||||||
|
<h1>ASCII Art Generator</h1>
|
||||||
|
<p>
|
||||||
|
Generate stunning ASCII art from images. Pull a random
|
||||||
|
image from an anime API or upload your own to get
|
||||||
|
started.
|
||||||
|
</p>
|
||||||
|
<div class="landing-buttons">
|
||||||
|
<button id="btn-start-api" class="landing-btn"
|
||||||
|
>Anime API</button
|
||||||
|
>
|
||||||
|
<button id="btn-start-upload" class="landing-btn"
|
||||||
|
>Upload Image</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="disclaimer">
|
||||||
|
<b>Disclaimer:</b> Images loaded via the API are not my own
|
||||||
|
and are not filtered or curated. In rare cases, they might
|
||||||
|
contain sensitive material.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { AsciiController } from "../scripts/ascii-controller";
|
||||||
|
import { ImageQueue } from "../scripts/image-queue";
|
||||||
|
import { UIBindings } from "../scripts/ui-bindings";
|
||||||
|
|
||||||
|
if (window.__ASCII_APP__) {
|
||||||
|
console.log("♻️ Disposing previous application instance...");
|
||||||
|
try {
|
||||||
|
window.__ASCII_APP__.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to dispose previous instance:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById(
|
||||||
|
"ascii-canvas",
|
||||||
|
) as HTMLCanvasElement;
|
||||||
|
const asciiResult = document.getElementById(
|
||||||
|
"ascii-result",
|
||||||
|
) as HTMLPreElement;
|
||||||
|
const loadingIndicator = document.getElementById(
|
||||||
|
"loading",
|
||||||
|
) as HTMLDivElement;
|
||||||
|
|
||||||
|
if (!canvas || !asciiResult || !loadingIndicator) {
|
||||||
|
throw new Error("Critical UI elements missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller: AsciiController;
|
||||||
|
let queue: ImageQueue;
|
||||||
|
let ui: UIBindings;
|
||||||
|
|
||||||
|
controller = new AsciiController(
|
||||||
|
canvas,
|
||||||
|
asciiResult,
|
||||||
|
loadingIndicator,
|
||||||
|
);
|
||||||
|
queue = new ImageQueue(2);
|
||||||
|
ui = new UIBindings(controller, queue, loadNewImage);
|
||||||
|
|
||||||
|
window.__ASCII_APP__ = {
|
||||||
|
controller: controller!,
|
||||||
|
queue: queue!,
|
||||||
|
ui: ui!,
|
||||||
|
dispose: () => {
|
||||||
|
controller?.dispose();
|
||||||
|
ui?.dispose();
|
||||||
|
queue?.dispose();
|
||||||
|
window.__ASCII_APP__ = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.onSettingsChanged(() => ui!.updateUI());
|
||||||
|
|
||||||
|
let retryCount = 0;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
async function loadNewImage(): Promise<void> {
|
||||||
|
if (!controller || !queue || !ui) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let item;
|
||||||
|
|
||||||
|
if (queue.getLength() === 0) {
|
||||||
|
controller.showLoading("FETCHING...");
|
||||||
|
item = await queue.fetchDirect();
|
||||||
|
} else {
|
||||||
|
item = queue.pop()!;
|
||||||
|
queue.ensureFilled();
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.setCurrentImage(item.url, item.suggestions);
|
||||||
|
retryCount = 0;
|
||||||
|
|
||||||
|
ui.updateUI();
|
||||||
|
await controller.generate();
|
||||||
|
controller.hideLoading();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (retryCount < MAX_RETRIES) {
|
||||||
|
retryCount++;
|
||||||
|
asciiResult.textContent = `Connection lost. Retrying (${retryCount}/${MAX_RETRIES})...`;
|
||||||
|
setTimeout(loadNewImage, 2000);
|
||||||
|
} else {
|
||||||
|
asciiResult.textContent =
|
||||||
|
"Connection failed. Please refresh.";
|
||||||
|
controller.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.init();
|
||||||
|
|
||||||
|
const landingScreen = document.getElementById("landing-screen");
|
||||||
|
const btnStartApi = document.getElementById("btn-start-api");
|
||||||
|
const btnStartUpload = document.getElementById("btn-start-upload");
|
||||||
|
const fileInput = document.getElementById(
|
||||||
|
"file-upload",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const controlPanel = document.querySelector(".control-panel");
|
||||||
|
|
||||||
|
const hideLanding = () => {
|
||||||
|
landingScreen?.classList.add("hidden");
|
||||||
|
if (controlPanel) {
|
||||||
|
controlPanel.classList.add("visible");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btnStartApi?.addEventListener("click", () => {
|
||||||
|
hideLanding();
|
||||||
|
loadNewImage().then(() => {
|
||||||
|
queue?.ensureFilled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btnStartUpload?.addEventListener("click", () => {
|
||||||
|
fileInput?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("ascii-image-imported", () => {
|
||||||
|
hideLanding();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<Tooltip />
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ascii-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascii-workspace {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #050505;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-layer {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at center, #111 0%, #000 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ascii-result {
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: pre;
|
||||||
|
color: var(--text-color);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ascii-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: none;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: none;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
transition:
|
||||||
|
opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
visibility 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-content {
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(15, 15, 15, 0.9);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
animation: landing-in 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes landing-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-content h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-content p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-btn {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-start-api {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-start-api:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
text-align: left;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.control-panel) {
|
||||||
|
width: 100%;
|
||||||
|
transform: translateY(150%);
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
opacity 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.control-panel.visible) {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.ascii-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,117 +16,240 @@ if (!entry) {
|
|||||||
const { Content } = await entry.render();
|
const { Content } = await entry.render();
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={entry.data.title} showScroll={true}>
|
<Layout title={entry.data.title}>
|
||||||
<main>
|
<div class="split-layout">
|
||||||
<article>
|
<main class="content-workspace">
|
||||||
<section class="h-entry">
|
<div class="content-container">
|
||||||
<header>
|
<article class="h-entry">
|
||||||
<h1 class="p-name">{entry.data.title}</h1>
|
<header class="post-header">
|
||||||
<div class="metadata">
|
<a href="/blog" class="back-link">
|
||||||
<time
|
← Back to Blog
|
||||||
class="dt-published"
|
</a>
|
||||||
datetime={entry.data.pubDate.toISOString()}
|
|
||||||
>
|
<h1 class="p-name">{entry.data.title}</h1>
|
||||||
{entry.data.pubDate.toISOString().slice(0, 10)}
|
|
||||||
</time>
|
<div class="metadata">
|
||||||
{
|
<span class="dt-published">
|
||||||
entry.data.updatedDate && (
|
{entry.data.pubDate.toISOString().slice(0, 10)}
|
||||||
<div class="last-updated">
|
</span>
|
||||||
Last updated on{" "}
|
{
|
||||||
<time>
|
entry.data.updatedDate && (
|
||||||
|
<span class="updated-date">
|
||||||
|
• Updated:{" "}
|
||||||
{entry.data.updatedDate
|
{entry.data.updatedDate
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10)}
|
.slice(0, 10)}
|
||||||
</time>
|
</span>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="e-content">
|
||||||
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</article>
|
||||||
<div class="e-content">
|
</div>
|
||||||
<Content />
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
/* Split Layout */
|
||||||
width: calc(100% - 2em);
|
.split-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Workspace */
|
||||||
|
.content-workspace {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0;
|
width: 100%;
|
||||||
padding: 2em;
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
/* Header Styling */
|
||||||
margin-bottom: 2rem;
|
.post-header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
color: var(--text-color);
|
font-family:
|
||||||
opacity: 0.6;
|
system-ui,
|
||||||
font-family: var(--font-mono);
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a:hover {
|
.back-link:hover {
|
||||||
opacity: 1;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
h1 {
|
||||||
font-size: 2em;
|
font-size: 2.5rem;
|
||||||
margin: 0.25em 0 0;
|
font-weight: 800;
|
||||||
}
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.2;
|
||||||
hr {
|
color: #fff;
|
||||||
border-top: 1px solid var(--text-color);
|
letter-spacing: -0.5px;
|
||||||
opacity: 0.3;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata {
|
.metadata {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown Styles */
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Styling (Markdown) */
|
||||||
.e-content {
|
.e-content {
|
||||||
line-height: 1.6;
|
line-height: 1.8;
|
||||||
font-family: var(--font-mono); /* Keep vibe */
|
font-size: 1.1rem;
|
||||||
font-size: 1rem;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif; /* Clean reading font */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography Overrides */
|
||||||
.e-content :global(h1),
|
.e-content :global(h1),
|
||||||
.e-content :global(h2),
|
.e-content :global(h2),
|
||||||
.e-content :global(h3),
|
.e-content :global(h3),
|
||||||
.e-content :global(h4) {
|
.e-content :global(h4) {
|
||||||
margin-top: 2rem;
|
font-family:
|
||||||
margin-bottom: 1rem;
|
system-ui,
|
||||||
color: var(--text-color);
|
-apple-system,
|
||||||
font-weight: bold;
|
sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(h2) {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.e-content :global(h3) {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(p) {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(a) {
|
.e-content :global(a) {
|
||||||
color: var(--text-color);
|
color: #fff;
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(a:hover) {
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(ul),
|
||||||
|
.e-content :global(ol) {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(li) {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(blockquote) {
|
||||||
|
border-left: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e-content :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px; /* Softer corners */
|
||||||
|
margin: 2rem 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks */
|
||||||
|
.e-content :global(pre) {
|
||||||
|
background: #111 !important; /* Force override */
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px; /* Softer corners */
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(code) {
|
.e-content :global(code) {
|
||||||
background: #111;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-content :global(pre) {
|
.e-content :global(pre code) {
|
||||||
background: #111;
|
background: none;
|
||||||
padding: 1rem;
|
padding: 0;
|
||||||
border: 1px solid #333;
|
color: inherit;
|
||||||
overflow-x: auto;
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.split-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-workspace {
|
||||||
|
height: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,92 +8,214 @@ const posts = (await getCollection("blog")).sort(
|
|||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="System Logs" showScroll={true}>
|
<Layout title="System Logs">
|
||||||
<main>
|
<div class="split-layout">
|
||||||
<section>
|
<main class="content-workspace">
|
||||||
<ul>
|
<div class="content-container">
|
||||||
{
|
<header class="page-header">
|
||||||
posts.map((post: any) => (
|
<a href="/" class="back-link"> ← Back to Home </a>
|
||||||
<li>
|
<h1>Blog Articles</h1>
|
||||||
<a href={`/blog/${post.slug}/`}>
|
<div class="divider"></div>
|
||||||
<span class="date">
|
</header>
|
||||||
[
|
|
||||||
{post.data.pubDate
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}
|
|
||||||
]
|
|
||||||
</span>
|
|
||||||
<span class="title">{post.data.title}</span>
|
|
||||||
<span class="desc">
|
|
||||||
// {post.data.description}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
<ul class="post-list">
|
||||||
<p>END OF STREAM</p>
|
{
|
||||||
</footer>
|
posts.map((post: any) => (
|
||||||
</main>
|
<li>
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.slug}/`}
|
||||||
|
class="post-link"
|
||||||
|
>
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="date">
|
||||||
|
{post.data.pubDate
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-info">
|
||||||
|
<span class="title">
|
||||||
|
{post.data.title}
|
||||||
|
</span>
|
||||||
|
<span class="desc">
|
||||||
|
{post.data.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© {new Date().getFullYear()} Syntaxbullet</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
/* Split Layout - Consistent with Homepage */
|
||||||
width: 960px;
|
.split-layout {
|
||||||
max-width: calc(100% - 2em);
|
display: flex;
|
||||||
margin: 0 auto;
|
width: 100vw;
|
||||||
padding: 2em 0;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
/* Content Workspace (Right Side) */
|
||||||
|
.content-workspace {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto; /* Enable scrolling for content */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post List Styling */
|
||||||
|
.post-list {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
.post-link {
|
||||||
margin-bottom: 1rem;
|
display: flex;
|
||||||
border-left: 2px solid transparent;
|
flex-direction: column; /* Mobile first */
|
||||||
transition: border-left-color 0.2s;
|
gap: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
li:hover {
|
|
||||||
border-left-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 5px 10px;
|
padding: 1.5rem;
|
||||||
color: var(--text-color);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-family: var(--font-mono);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date {
|
.post-link:hover {
|
||||||
color: rgba(255, 103, 0, 0.6);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
margin-right: 1rem;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-size: 1.25rem;
|
||||||
margin-right: 1rem;
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
color: rgba(255, 103, 0, 0.4);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-style: italic;
|
font-family:
|
||||||
}
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
a:hover .title {
|
sans-serif;
|
||||||
text-decoration: underline;
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.3;
|
opacity: 0.5;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.post-link {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.split-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Prevent body scroll, use inner scrolling */
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-workspace {
|
||||||
|
height: auto;
|
||||||
|
flex-grow: 1; /* Fill remaining space */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
820
src/scripts/ascii-controller.ts
Normal file
820
src/scripts/ascii-controller.ts
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
/**
|
||||||
|
* ASCII Renderer Controller
|
||||||
|
* Manages state, render loop, and grid calculations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CHAR_SETS, type CharSetKey, type AsciiOptions, type AsciiSettings } from './ascii-shared';
|
||||||
|
import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii';
|
||||||
|
import { AsciiExporter } from './ascii-exporter';
|
||||||
|
|
||||||
|
// ============= Types =============
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface GridCache {
|
||||||
|
widthCols: number;
|
||||||
|
heightRows: number;
|
||||||
|
imgEl: HTMLImageElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomState {
|
||||||
|
zoom: number;
|
||||||
|
zoomCenter: { x: number; y: number };
|
||||||
|
mousePos: { x: number; y: number };
|
||||||
|
showMagnifier: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderDirtyFlags = 'texture' | 'grid' | 'uniforms' | 'all';
|
||||||
|
|
||||||
|
// ============= Controller =============
|
||||||
|
|
||||||
|
export class AsciiController {
|
||||||
|
// DOM
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private asciiResult: HTMLPreElement;
|
||||||
|
private loadingIndicator: HTMLDivElement;
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
private renderer: WebGLAsciiRenderer | null = null;
|
||||||
|
|
||||||
|
// State
|
||||||
|
private settings: AsciiSettings;
|
||||||
|
private detectedSettings: Partial<AsciiSettings> = {};
|
||||||
|
private invertMode: 'auto' | 'on' | 'off' = 'auto';
|
||||||
|
private detectedInvert = false;
|
||||||
|
private currentImgUrl: string | null = null;
|
||||||
|
|
||||||
|
// Render loop
|
||||||
|
private dirtyTexture = false;
|
||||||
|
private dirtyGrid = false;
|
||||||
|
private dirtyUniforms = false;
|
||||||
|
private cachedGrid: GridCache = { widthCols: 0, heightRows: 0, imgEl: null };
|
||||||
|
private animFrameId: number | null = null;
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
private zoomState: ZoomState = {
|
||||||
|
zoom: 1.0,
|
||||||
|
zoomCenter: { x: 0.5, y: 0.5 },
|
||||||
|
mousePos: { x: -1, y: -1 },
|
||||||
|
showMagnifier: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch state
|
||||||
|
private lastTouchDist = 0;
|
||||||
|
private isDragging = false;
|
||||||
|
private lastTouchPos = { x: 0, y: 0 };
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// Edge panning state
|
||||||
|
private edgePanState = {
|
||||||
|
active: false,
|
||||||
|
directionX: 0, // -1 = left, 1 = right, 0 = none
|
||||||
|
directionY: 0, // -1 = up, 1 = down, 0 = none
|
||||||
|
animationId: null as number | null
|
||||||
|
};
|
||||||
|
private readonly EDGE_THRESHOLD = 0.08; // 8% of canvas edge triggers pan
|
||||||
|
private readonly PAN_SPEED = 0.012; // Speed of panning per frame
|
||||||
|
|
||||||
|
// Middle mouse panning state
|
||||||
|
private middleMousePanState = {
|
||||||
|
isDragging: false,
|
||||||
|
lastMousePos: { x: 0, y: 0 }
|
||||||
|
};
|
||||||
|
private mouseDragHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
private mouseUpHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private onSettingsChange?: () => void;
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement, asciiResult: HTMLPreElement, loadingIndicator: HTMLDivElement) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.asciiResult = asciiResult;
|
||||||
|
this.loadingIndicator = loadingIndicator;
|
||||||
|
|
||||||
|
this.settings = this.getDefaultSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.renderer = new WebGLAsciiRenderer(canvas);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WebGL not available:', e);
|
||||||
|
throw new Error('WebGL is required for this application');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startRenderLoop();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
// Resize handling
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Debounce resize
|
||||||
|
if (this.animFrameId) {
|
||||||
|
// We are in a loop, just toggle a flag or call safely
|
||||||
|
this.handleResize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.canvas.parentElement) {
|
||||||
|
this.resizeObserver.observe(this.canvas.parentElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
||||||
|
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||||
|
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||||
|
|
||||||
|
// Middle mouse button panning
|
||||||
|
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||||
|
this.mouseDragHandler = this.handleMouseDrag.bind(this);
|
||||||
|
this.mouseUpHandler = this.handleMouseUp.bind(this);
|
||||||
|
document.addEventListener('mousemove', this.mouseDragHandler);
|
||||||
|
document.addEventListener('mouseup', this.mouseUpHandler);
|
||||||
|
|
||||||
|
// Prevent context menu on right-click to allow all mouse buttons
|
||||||
|
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResize(): void {
|
||||||
|
// Re-calculate grid based on new dimensions
|
||||||
|
this.calculateGrid().then(() => {
|
||||||
|
this.requestRender('all');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultSettings(): AsciiSettings {
|
||||||
|
return {
|
||||||
|
exposure: 1.0,
|
||||||
|
contrast: 1.0,
|
||||||
|
saturation: 1.2,
|
||||||
|
gamma: 1.0,
|
||||||
|
invert: false,
|
||||||
|
color: false,
|
||||||
|
dither: 0,
|
||||||
|
denoise: false,
|
||||||
|
edgeMode: 0,
|
||||||
|
overlayStrength: 0.3,
|
||||||
|
resolution: 1.0,
|
||||||
|
charSet: 'standard',
|
||||||
|
sharpen: 0,
|
||||||
|
edgeThreshold: 0.5,
|
||||||
|
shadows: 0,
|
||||||
|
highlights: 0,
|
||||||
|
scanlines: 0,
|
||||||
|
vignette: 0,
|
||||||
|
monoColor: '#ffffff',
|
||||||
|
backgroundColor: '#000000'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Getters/Setters =============
|
||||||
|
|
||||||
|
getSettings(): AsciiSettings {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
getSetting<K extends keyof AsciiSettings>(key: K): AsciiSettings[K] {
|
||||||
|
return this.settings[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetting<K extends keyof AsciiSettings>(key: K, value: AsciiSettings[K]): void {
|
||||||
|
if (this.settings[key] === value) return; // Prevent redundant updates and recursion
|
||||||
|
|
||||||
|
this.settings[key] = value;
|
||||||
|
if (key === 'resolution') {
|
||||||
|
this.calculateGrid().then(() => this.requestRender('grid'));
|
||||||
|
} else {
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
this.onSettingsChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
getInvertMode(): 'auto' | 'on' | 'off' {
|
||||||
|
return this.invertMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvertMode(mode: 'auto' | 'on' | 'off'): void {
|
||||||
|
this.invertMode = mode;
|
||||||
|
if (mode === 'auto') {
|
||||||
|
this.settings.invert = this.detectedInvert;
|
||||||
|
} else {
|
||||||
|
this.settings.invert = mode === 'on';
|
||||||
|
}
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
this.onSettingsChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleInvertMode(): void {
|
||||||
|
if (this.invertMode === 'auto') {
|
||||||
|
this.setInvertMode('on');
|
||||||
|
} else if (this.invertMode === 'on') {
|
||||||
|
this.setInvertMode('off');
|
||||||
|
} else {
|
||||||
|
this.setInvertMode('auto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleCharSet(): void {
|
||||||
|
const keys = Object.keys(CHAR_SETS) as CharSetKey[];
|
||||||
|
const idx = keys.indexOf(this.settings.charSet);
|
||||||
|
const nextIdx = (idx + 1) % keys.length;
|
||||||
|
this.setSetting('charSet', keys[nextIdx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoomState(): ZoomState {
|
||||||
|
return { ...this.zoomState };
|
||||||
|
}
|
||||||
|
|
||||||
|
getDetectedInvert(): boolean {
|
||||||
|
return this.detectedInvert;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedGrid(): GridCache {
|
||||||
|
return { ...this.cachedGrid };
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentImageUrl(): string | null {
|
||||||
|
return this.currentImgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Callbacks =============
|
||||||
|
|
||||||
|
onSettingsChanged(callback: () => void): void {
|
||||||
|
this.onSettingsChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Image Loading =============
|
||||||
|
|
||||||
|
setCurrentImage(url: string, suggestions: Partial<AsciiOptions>): void {
|
||||||
|
this.currentImgUrl = url;
|
||||||
|
|
||||||
|
// Reset zoom
|
||||||
|
this.zoomState = {
|
||||||
|
zoom: 1.0,
|
||||||
|
zoomCenter: { x: 0.5, y: 0.5 },
|
||||||
|
mousePos: { x: -1, y: -1 },
|
||||||
|
showMagnifier: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply auto-detected settings
|
||||||
|
this.invertMode = 'auto';
|
||||||
|
this.detectedInvert = suggestions.invert ?? false;
|
||||||
|
|
||||||
|
// Validate charSet - ensure it's a valid CharSetKey
|
||||||
|
const validCharSet = this.isValidCharSet(suggestions.charSet)
|
||||||
|
? suggestions.charSet
|
||||||
|
: this.settings.charSet;
|
||||||
|
|
||||||
|
this.detectedSettings = {
|
||||||
|
...suggestions,
|
||||||
|
charSet: validCharSet,
|
||||||
|
edgeMode: this.mapEdgeMode(suggestions.edgeMode)
|
||||||
|
} as Partial<AsciiSettings>;
|
||||||
|
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
exposure: suggestions.exposure ?? this.settings.exposure,
|
||||||
|
contrast: suggestions.contrast ?? this.settings.contrast,
|
||||||
|
saturation: suggestions.saturation ?? this.settings.saturation,
|
||||||
|
gamma: suggestions.gamma ?? this.settings.gamma,
|
||||||
|
invert: this.detectedInvert,
|
||||||
|
dither: suggestions.dither ?? this.settings.dither,
|
||||||
|
denoise: suggestions.denoise ?? this.settings.denoise,
|
||||||
|
edgeMode: suggestions.edgeMode ? this.mapEdgeMode(suggestions.edgeMode) : this.settings.edgeMode,
|
||||||
|
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
|
||||||
|
charSet: validCharSet,
|
||||||
|
resolution: this.settings.resolution,
|
||||||
|
color: this.settings.color,
|
||||||
|
sharpen: suggestions.sharpen ?? this.settings.sharpen,
|
||||||
|
edgeThreshold: suggestions.edgeThreshold ?? this.settings.edgeThreshold,
|
||||||
|
shadows: suggestions.shadows ?? this.settings.shadows,
|
||||||
|
highlights: suggestions.highlights ?? this.settings.highlights,
|
||||||
|
scanlines: suggestions.scanlines ?? this.settings.scanlines,
|
||||||
|
vignette: suggestions.vignette ?? this.settings.vignette,
|
||||||
|
monoColor: suggestions.monoColor ?? this.settings.monoColor,
|
||||||
|
backgroundColor: suggestions.backgroundColor ?? this.settings.backgroundColor
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onSettingsChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapEdgeMode(mode: string | undefined): number {
|
||||||
|
if (!mode) return 0;
|
||||||
|
switch (mode) {
|
||||||
|
case 'simple': return 1;
|
||||||
|
case 'sobel': return 2;
|
||||||
|
case 'canny': return 3;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidCharSet(value: string | CharSetKey | undefined): value is CharSetKey {
|
||||||
|
if (!value) return false;
|
||||||
|
return Object.keys(CHAR_SETS).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToAutoSettings(): void {
|
||||||
|
if (Object.keys(this.detectedSettings).length > 0) {
|
||||||
|
this.invertMode = 'auto';
|
||||||
|
this.detectedInvert = this.detectedSettings.invert ?? false;
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
...this.detectedSettings,
|
||||||
|
resolution: this.settings.resolution,
|
||||||
|
color: false
|
||||||
|
};
|
||||||
|
this.settings.invert = this.detectedInvert;
|
||||||
|
|
||||||
|
this.calculateGrid().then(() => this.requestRender('all'));
|
||||||
|
this.onSettingsChange?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Rendering =============
|
||||||
|
|
||||||
|
requestRender(type: RenderDirtyFlags): void {
|
||||||
|
if (type === 'all') {
|
||||||
|
this.dirtyTexture = true;
|
||||||
|
this.dirtyGrid = true;
|
||||||
|
this.dirtyUniforms = true;
|
||||||
|
} else if (type === 'texture') {
|
||||||
|
this.dirtyTexture = true;
|
||||||
|
} else if (type === 'grid') {
|
||||||
|
this.dirtyGrid = true;
|
||||||
|
} else if (type === 'uniforms') {
|
||||||
|
this.dirtyUniforms = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateGrid(): Promise<GridCache | undefined> {
|
||||||
|
if (!this.currentImgUrl) return;
|
||||||
|
|
||||||
|
const parent = this.canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const fontAspectRatio = 0.55;
|
||||||
|
const marginRatio = 0.05; // Reduced margin for container fit
|
||||||
|
let screenW = parent.clientWidth;
|
||||||
|
if (screenW <= 0) screenW = window.innerWidth || 1000;
|
||||||
|
const availW = screenW * (1 - marginRatio);
|
||||||
|
|
||||||
|
let widthCols = Math.floor(availW / 6);
|
||||||
|
widthCols = Math.floor(widthCols * this.settings.resolution);
|
||||||
|
|
||||||
|
// Cap grid resolution on mobile specifically if needed,
|
||||||
|
// but current logic is mostly fine as long as resolution slider is manageable.
|
||||||
|
widthCols = Math.max(10, Math.min(1000, widthCols));
|
||||||
|
|
||||||
|
const imgEl = await this.resolveImage(this.currentImgUrl);
|
||||||
|
const imgRatio = imgEl.width / imgEl.height;
|
||||||
|
const heightRows = widthCols / (imgRatio / fontAspectRatio);
|
||||||
|
|
||||||
|
this.cachedGrid = { widthCols, heightRows, imgEl };
|
||||||
|
return this.cachedGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(): void {
|
||||||
|
const loop = () => {
|
||||||
|
this.renderFrame();
|
||||||
|
this.animFrameId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
this.animFrameId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderFrame(): void {
|
||||||
|
if (!this.renderer || !this.cachedGrid.imgEl) return;
|
||||||
|
|
||||||
|
const charSetContent = CHAR_SETS[this.settings.charSet] || CHAR_SETS.standard;
|
||||||
|
|
||||||
|
if (this.dirtyTexture || this.dirtyGrid || this.dirtyUniforms) {
|
||||||
|
if (this.dirtyTexture) {
|
||||||
|
this.renderer.updateTexture(this.cachedGrid.imgEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dirtyGrid) {
|
||||||
|
this.updateCanvasSize();
|
||||||
|
this.renderer.updateGrid(
|
||||||
|
this.cachedGrid.widthCols,
|
||||||
|
Math.floor(this.cachedGrid.heightRows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dirtyUniforms || this.dirtyGrid) {
|
||||||
|
this.renderer.updateUniforms({
|
||||||
|
width: this.cachedGrid.widthCols,
|
||||||
|
height: Math.floor(this.cachedGrid.heightRows),
|
||||||
|
charSetContent,
|
||||||
|
...this.settings,
|
||||||
|
zoom: this.zoomState.zoom,
|
||||||
|
zoomCenter: this.zoomState.zoomCenter,
|
||||||
|
mousePos: this.zoomState.mousePos,
|
||||||
|
showMagnifier: this.zoomState.showMagnifier,
|
||||||
|
magnifierRadius: 0.15,
|
||||||
|
magnifierZoom: 2.5
|
||||||
|
} as RenderOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.draw();
|
||||||
|
|
||||||
|
this.dirtyTexture = false;
|
||||||
|
this.dirtyGrid = false;
|
||||||
|
this.dirtyUniforms = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCanvasSize(): void {
|
||||||
|
const parent = this.canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const fontAspectRatio = 0.55;
|
||||||
|
// Safeguard against 0 height or NaNs
|
||||||
|
const heightRows = Math.max(1, Math.floor(this.cachedGrid.heightRows));
|
||||||
|
const widthCols = Math.max(1, this.cachedGrid.widthCols);
|
||||||
|
const gridAspect = (widthCols * fontAspectRatio) / heightRows;
|
||||||
|
|
||||||
|
let screenW = parent.clientWidth;
|
||||||
|
let screenH = parent.clientHeight;
|
||||||
|
|
||||||
|
// Fallback for mobile initialization quirks where parent might be 0 initially
|
||||||
|
if (screenW <= 0) screenW = window.innerWidth;
|
||||||
|
if (screenH <= 0) screenH = window.innerHeight * 0.5; // Guessing half screen for workspace
|
||||||
|
|
||||||
|
const maxW = screenW * 0.98;
|
||||||
|
const maxH = screenH * 0.98;
|
||||||
|
|
||||||
|
let finalW: number, finalH: number;
|
||||||
|
if (gridAspect > maxW / maxH) {
|
||||||
|
finalW = maxW;
|
||||||
|
finalH = maxW / gridAspect;
|
||||||
|
} else {
|
||||||
|
finalH = maxH;
|
||||||
|
finalW = maxH * gridAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safeguard against zero or NaN
|
||||||
|
if (!finalW || !finalH || isNaN(finalW) || isNaN(finalH)) {
|
||||||
|
finalW = 300;
|
||||||
|
finalH = 300 / gridAspect;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.style.width = `${finalW}px`;
|
||||||
|
this.canvas.style.height = `${finalH}px`;
|
||||||
|
|
||||||
|
// Cap DPR to improve mobile performance
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2.0);
|
||||||
|
this.canvas.width = finalW * dpr;
|
||||||
|
this.canvas.height = finalH * dpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(): Promise<void> {
|
||||||
|
await this.calculateGrid();
|
||||||
|
this.asciiResult.style.display = 'none';
|
||||||
|
this.canvas.style.display = 'block';
|
||||||
|
this.canvas.style.opacity = '1';
|
||||||
|
this.requestRender('all');
|
||||||
|
|
||||||
|
// Insurance for mobile: trigger a second sizing/render after a short delay
|
||||||
|
// to catch cases where the layout might still be shifting (keyboard, address bar)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.handleResize();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Zoom & Touch =============
|
||||||
|
|
||||||
|
handleWheel(e: WheelEvent): void {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const my = (e.clientY - rect.top) / rect.height;
|
||||||
|
|
||||||
|
const delta = -e.deltaY;
|
||||||
|
const factor = delta > 0 ? 1.1 : 0.9;
|
||||||
|
const oldZoom = this.zoomState.zoom;
|
||||||
|
|
||||||
|
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
|
||||||
|
|
||||||
|
if (this.zoomState.zoom === 1.0) {
|
||||||
|
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
|
||||||
|
} else if (oldZoom !== this.zoomState.zoom) {
|
||||||
|
const imgX = (mx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
|
||||||
|
const imgY = (my - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
|
||||||
|
this.zoomState.zoomCenter.x = (imgX - mx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
this.zoomState.zoomCenter.y = (imgY - my / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(e: MouseEvent): void {
|
||||||
|
if ('ontouchstart' in window && (e as any).sourceCapabilities?.firesTouchEvents) return; // Ignore simulated mouse events
|
||||||
|
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const my = (e.clientY - rect.top) / rect.height;
|
||||||
|
|
||||||
|
this.zoomState.mousePos = { x: mx, y: my };
|
||||||
|
const wasShowing = this.zoomState.showMagnifier;
|
||||||
|
// Only show magnifier if not zoomed out completely
|
||||||
|
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1 && !('ontouchstart' in window);
|
||||||
|
|
||||||
|
if (this.zoomState.showMagnifier || wasShowing) {
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edge panning when zoomed in
|
||||||
|
this.handleEdgePanning(mx, my);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEdgePanning(mx: number, my: number): void {
|
||||||
|
// Only pan when zoomed in
|
||||||
|
if (this.zoomState.zoom <= 1.0) {
|
||||||
|
this.stopEdgePanning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mouse is near edges
|
||||||
|
const nearLeft = mx < this.EDGE_THRESHOLD && mx >= 0;
|
||||||
|
const nearRight = mx > (1 - this.EDGE_THRESHOLD) && mx <= 1;
|
||||||
|
const nearTop = my < this.EDGE_THRESHOLD && my >= 0;
|
||||||
|
const nearBottom = my > (1 - this.EDGE_THRESHOLD) && my <= 1;
|
||||||
|
|
||||||
|
// Determine pan direction
|
||||||
|
const dirX = nearLeft ? -1 : nearRight ? 1 : 0;
|
||||||
|
const dirY = nearTop ? -1 : nearBottom ? 1 : 0;
|
||||||
|
|
||||||
|
// Start or update panning
|
||||||
|
if (dirX !== 0 || dirY !== 0) {
|
||||||
|
this.edgePanState.directionX = dirX;
|
||||||
|
this.edgePanState.directionY = dirY;
|
||||||
|
if (!this.edgePanState.active) {
|
||||||
|
this.startEdgePanning();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.stopEdgePanning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startEdgePanning(): void {
|
||||||
|
if (this.edgePanState.active) return;
|
||||||
|
|
||||||
|
this.edgePanState.active = true;
|
||||||
|
this.runEdgePanLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private runEdgePanLoop(): void {
|
||||||
|
if (!this.edgePanState.active) return;
|
||||||
|
|
||||||
|
// Calculate pan amount based on zoom level (slower when zoomed in more)
|
||||||
|
const panAmount = this.PAN_SPEED / this.zoomState.zoom;
|
||||||
|
|
||||||
|
// Update zoom center
|
||||||
|
let newX = this.zoomState.zoomCenter.x + this.edgePanState.directionX * panAmount;
|
||||||
|
let newY = this.zoomState.zoomCenter.y + this.edgePanState.directionY * panAmount;
|
||||||
|
|
||||||
|
// Clamp zoom center to keep image visible
|
||||||
|
// When zoomed, the visible area is 1/zoom of the total image
|
||||||
|
const visibleRange = 1.0 / this.zoomState.zoom;
|
||||||
|
const minCenter = visibleRange / 2;
|
||||||
|
const maxCenter = 1.0 - visibleRange / 2;
|
||||||
|
|
||||||
|
this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, newX));
|
||||||
|
this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, newY));
|
||||||
|
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
|
||||||
|
// Continue panning
|
||||||
|
this.edgePanState.animationId = requestAnimationFrame(() => this.runEdgePanLoop());
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopEdgePanning(): void {
|
||||||
|
if (!this.edgePanState.active) return;
|
||||||
|
|
||||||
|
this.edgePanState.active = false;
|
||||||
|
this.edgePanState.directionX = 0;
|
||||||
|
this.edgePanState.directionY = 0;
|
||||||
|
|
||||||
|
if (this.edgePanState.animationId !== null) {
|
||||||
|
cancelAnimationFrame(this.edgePanState.animationId);
|
||||||
|
this.edgePanState.animationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
if (this.zoomState.showMagnifier) {
|
||||||
|
this.zoomState.showMagnifier = false;
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
this.stopEdgePanning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle mouse button panning handlers
|
||||||
|
handleMouseDown(e: MouseEvent): void {
|
||||||
|
// Middle mouse button is button 1
|
||||||
|
if (e.button === 1 && this.zoomState.zoom > 1.0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.middleMousePanState.isDragging = true;
|
||||||
|
this.middleMousePanState.lastMousePos = { x: e.clientX, y: e.clientY };
|
||||||
|
// Hide magnifier while dragging
|
||||||
|
this.zoomState.showMagnifier = false;
|
||||||
|
// Stop edge panning while manually panning
|
||||||
|
this.stopEdgePanning();
|
||||||
|
// Change cursor to closed hand (grabbing)
|
||||||
|
this.canvas.style.cursor = 'grabbing';
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDrag(e: MouseEvent): void {
|
||||||
|
if (!this.middleMousePanState.isDragging || this.zoomState.zoom <= 1.0) return;
|
||||||
|
|
||||||
|
const curX = e.clientX;
|
||||||
|
const curY = e.clientY;
|
||||||
|
|
||||||
|
// Calculate movement delta in normalized coordinates
|
||||||
|
const dx = (curX - this.middleMousePanState.lastMousePos.x) / this.canvas.width;
|
||||||
|
const dy = (curY - this.middleMousePanState.lastMousePos.y) / this.canvas.height;
|
||||||
|
|
||||||
|
// Move zoom center opposite to drag direction
|
||||||
|
// Speed is inversely proportional to zoom (more zoom = slower pan for same mouse movement)
|
||||||
|
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
|
||||||
|
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
|
||||||
|
|
||||||
|
// Clamp zoom center to keep image visible
|
||||||
|
const visibleRange = 1.0 / this.zoomState.zoom;
|
||||||
|
const minCenter = visibleRange / 2;
|
||||||
|
const maxCenter = 1.0 - visibleRange / 2;
|
||||||
|
|
||||||
|
this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.x));
|
||||||
|
this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.y));
|
||||||
|
|
||||||
|
this.middleMousePanState.lastMousePos = { x: curX, y: curY };
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(e: MouseEvent): void {
|
||||||
|
if (e.button === 1) {
|
||||||
|
this.middleMousePanState.isDragging = false;
|
||||||
|
// Reset cursor
|
||||||
|
this.canvas.style.cursor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch Support
|
||||||
|
handleTouchStart(e: TouchEvent): void {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.lastTouchDist = this.getTouchDistance(e.touches);
|
||||||
|
this.isDragging = false;
|
||||||
|
} else if (e.touches.length === 1 && this.zoomState.zoom > 1.0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.isDragging = true;
|
||||||
|
this.lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchMove(e: TouchEvent): void {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dist = this.getTouchDistance(e.touches);
|
||||||
|
const factor = dist / this.lastTouchDist;
|
||||||
|
this.lastTouchDist = dist;
|
||||||
|
|
||||||
|
const oldZoom = this.zoomState.zoom;
|
||||||
|
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
|
||||||
|
|
||||||
|
// Center zoom between touches
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const cx = ((e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left) / rect.width;
|
||||||
|
const cy = ((e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top) / rect.height;
|
||||||
|
|
||||||
|
if (oldZoom !== this.zoomState.zoom) {
|
||||||
|
const imgX = (cx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
|
||||||
|
const imgY = (cy - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
|
||||||
|
this.zoomState.zoomCenter.x = (imgX - cx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
this.zoomState.zoomCenter.y = (imgY - cy / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
|
||||||
|
}
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
|
||||||
|
} else if (e.touches.length === 1 && this.isDragging && this.zoomState.zoom > 1.0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const curX = e.touches[0].clientX;
|
||||||
|
const curY = e.touches[0].clientY;
|
||||||
|
|
||||||
|
const dx = (curX - this.lastTouchPos.x) / this.canvas.width;
|
||||||
|
const dy = (curY - this.lastTouchPos.y) / this.canvas.height;
|
||||||
|
|
||||||
|
// Logarithmic pan speed based on zoom?
|
||||||
|
// Simple mapping: move zoomCenter opposite to drag
|
||||||
|
this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom;
|
||||||
|
this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom;
|
||||||
|
|
||||||
|
this.lastTouchPos = { x: curX, y: curY };
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchEnd(e: TouchEvent): void {
|
||||||
|
this.isDragging = false;
|
||||||
|
if (e.touches.length === 0 && this.zoomState.zoom <= 1.0) {
|
||||||
|
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTouchDistance(touches: TouchList): number {
|
||||||
|
const dx = touches[0].clientX - touches[1].clientX;
|
||||||
|
const dy = touches[0].clientY - touches[1].clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Export =============
|
||||||
|
|
||||||
|
savePNG(): void {
|
||||||
|
const wasShowing = this.zoomState.showMagnifier;
|
||||||
|
|
||||||
|
// 1. Force hide magnifier for clean export
|
||||||
|
if (wasShowing) {
|
||||||
|
this.zoomState.showMagnifier = false;
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
// Force synchronous render
|
||||||
|
this.renderFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
AsciiExporter.downloadPNG(this.canvas, `ascii-art-${timestamp}.png`);
|
||||||
|
|
||||||
|
// 2. Restore state
|
||||||
|
if (wasShowing) {
|
||||||
|
this.zoomState.showMagnifier = true;
|
||||||
|
this.requestRender('uniforms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveText(): void {
|
||||||
|
if (!this.cachedGrid.imgEl) return;
|
||||||
|
const text = AsciiExporter.generateText(
|
||||||
|
this.cachedGrid.imgEl,
|
||||||
|
this.settings,
|
||||||
|
this.cachedGrid.widthCols,
|
||||||
|
Math.floor(this.cachedGrid.heightRows)
|
||||||
|
);
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
AsciiExporter.downloadText(text, `ascii-art-${timestamp}.txt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHTML(): void {
|
||||||
|
if (!this.cachedGrid.imgEl) return;
|
||||||
|
const html = AsciiExporter.generateHTML(
|
||||||
|
this.cachedGrid.imgEl,
|
||||||
|
this.settings,
|
||||||
|
this.cachedGrid.widthCols,
|
||||||
|
Math.floor(this.cachedGrid.heightRows)
|
||||||
|
);
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
AsciiExporter.downloadText(html, `ascii-art-${timestamp}.html`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Utilities =============
|
||||||
|
|
||||||
|
private resolveImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.src = src;
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(message: string): void {
|
||||||
|
this.loadingIndicator.style.display = 'block';
|
||||||
|
this.asciiResult.textContent = message;
|
||||||
|
this.asciiResult.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoading(): void {
|
||||||
|
this.loadingIndicator.style.display = 'none';
|
||||||
|
this.asciiResult.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
getCanvas(): HTMLCanvasElement {
|
||||||
|
return this.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.animFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animFrameId);
|
||||||
|
}
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
this.stopEdgePanning();
|
||||||
|
// Clean up middle mouse panning handlers
|
||||||
|
if (this.mouseDragHandler) {
|
||||||
|
document.removeEventListener('mousemove', this.mouseDragHandler);
|
||||||
|
}
|
||||||
|
if (this.mouseUpHandler) {
|
||||||
|
document.removeEventListener('mouseup', this.mouseUpHandler);
|
||||||
|
}
|
||||||
|
this.renderer?.dispose();
|
||||||
|
this.renderer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/scripts/ascii-exporter.ts
Normal file
185
src/scripts/ascii-exporter.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { CHAR_SETS, type AsciiSettings } from './ascii-shared';
|
||||||
|
|
||||||
|
export class AsciiExporter {
|
||||||
|
static downloadPNG(canvas: HTMLCanvasElement, filename = 'ascii-art.png') {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = filename;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
static downloadText(content: string, filename: string) {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async copyToClipboard(text: string): Promise<void> {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateText(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
settings: AsciiSettings,
|
||||||
|
widthCols: number,
|
||||||
|
heightRows: number
|
||||||
|
): string {
|
||||||
|
const { pixels } = this.getPixels(img, widthCols, heightRows);
|
||||||
|
let output = "";
|
||||||
|
const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
|
||||||
|
const charCount = charSet.length;
|
||||||
|
|
||||||
|
for (let y = 0; y < heightRows; y++) {
|
||||||
|
for (let x = 0; x < widthCols; x++) {
|
||||||
|
const i = (y * widthCols + x) * 4;
|
||||||
|
const r = pixels[i];
|
||||||
|
const g = pixels[i + 1];
|
||||||
|
const b = pixels[i + 2];
|
||||||
|
// const a = pixels[i + 3]; // Ignore alpha for now
|
||||||
|
|
||||||
|
// 1. Adjust Color (Exposure, Contrast, Saturation, Gamma)
|
||||||
|
const adjColor = this.adjustColor(r, g, b, settings);
|
||||||
|
|
||||||
|
// 2. Calculate Luma from adjusted color
|
||||||
|
let luma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
|
||||||
|
|
||||||
|
// 3. Map to Char
|
||||||
|
if (settings.invert) luma = 1.0 - luma;
|
||||||
|
|
||||||
|
const charIndex = Math.floor(luma * (charCount - 1) + 0.5);
|
||||||
|
const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))];
|
||||||
|
|
||||||
|
output += char;
|
||||||
|
}
|
||||||
|
output += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateHTML(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
settings: AsciiSettings,
|
||||||
|
widthCols: number,
|
||||||
|
heightRows: number
|
||||||
|
): string {
|
||||||
|
const { pixels } = this.getPixels(img, widthCols, heightRows);
|
||||||
|
let output = `<pre style="font-family: monospace; line-height: 1; letter-spacing: 0; background-color: ${settings.backgroundColor}; color: #fff; font-size: 8px;">`;
|
||||||
|
const charSet = CHAR_SETS[settings.charSet] || CHAR_SETS.standard;
|
||||||
|
const charCount = charSet.length;
|
||||||
|
|
||||||
|
for (let y = 0; y < heightRows; y++) {
|
||||||
|
for (let x = 0; x < widthCols; x++) {
|
||||||
|
const i = (y * widthCols + x) * 4;
|
||||||
|
const r = pixels[i];
|
||||||
|
const g = pixels[i + 1];
|
||||||
|
const b = pixels[i + 2];
|
||||||
|
|
||||||
|
// 1. Calculate Luma
|
||||||
|
// 1. Calculate Luma (skipped, using adjusted color luma below)
|
||||||
|
|
||||||
|
// 2. Apply Adjustments
|
||||||
|
// Note: For color mode, we might want to keep original color
|
||||||
|
// but apply luma to alpha or just use luma for char selection.
|
||||||
|
// The shader uses `color` argument.
|
||||||
|
|
||||||
|
// Adjust color for display
|
||||||
|
const adjColor = this.adjustColor(r, g, b, settings);
|
||||||
|
const finalColor = settings.color ? adjColor : this.hexToRgb(settings.monoColor);
|
||||||
|
|
||||||
|
// Recalculate luma from adjusted color for char selection
|
||||||
|
let finalLuma = (0.2126 * adjColor.r + 0.7152 * adjColor.g + 0.0722 * adjColor.b) / 255;
|
||||||
|
|
||||||
|
if (settings.invert) finalLuma = 1.0 - finalLuma;
|
||||||
|
|
||||||
|
const charIndex = Math.floor(finalLuma * (charCount - 1) + 0.5);
|
||||||
|
const char = charSet[Math.max(0, Math.min(charCount - 1, charIndex))];
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char;
|
||||||
|
|
||||||
|
const colorStyle = `color: rgb(${Math.round(finalColor.r)}, ${Math.round(finalColor.g)}, ${Math.round(finalColor.b)})`;
|
||||||
|
output += `<span style="${colorStyle}">${safeChar}</span>`;
|
||||||
|
}
|
||||||
|
output += "<br>";
|
||||||
|
}
|
||||||
|
output += "</pre>";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getPixels(img: HTMLImageElement, width: number, height: number) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error("Canvas 2D context not available");
|
||||||
|
|
||||||
|
// High quality downscaling
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pixels: ctx.getImageData(0, 0, width, height).data,
|
||||||
|
ctx
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static adjustColor(r: number, g: number, b: number, settings: AsciiSettings): { r: number, g: number, b: number } {
|
||||||
|
let rNorm = r / 255;
|
||||||
|
let gNorm = g / 255;
|
||||||
|
let bNorm = b / 255;
|
||||||
|
|
||||||
|
// Exposure
|
||||||
|
rNorm *= settings.exposure;
|
||||||
|
gNorm *= settings.exposure;
|
||||||
|
bNorm *= settings.exposure;
|
||||||
|
|
||||||
|
// Contrast
|
||||||
|
rNorm = (rNorm - 0.5) * settings.contrast + 0.5;
|
||||||
|
gNorm = (gNorm - 0.5) * settings.contrast + 0.5;
|
||||||
|
bNorm = (bNorm - 0.5) * settings.contrast + 0.5;
|
||||||
|
|
||||||
|
// Saturation
|
||||||
|
const luma = 0.2126 * rNorm + 0.7152 * gNorm + 0.0722 * bNorm;
|
||||||
|
rNorm = luma + (rNorm - luma) * settings.saturation;
|
||||||
|
gNorm = luma + (gNorm - luma) * settings.saturation;
|
||||||
|
bNorm = luma + (bNorm - luma) * settings.saturation;
|
||||||
|
|
||||||
|
// Gamma
|
||||||
|
rNorm = Math.pow(Math.max(0, rNorm), settings.gamma);
|
||||||
|
gNorm = Math.pow(Math.max(0, gNorm), settings.gamma);
|
||||||
|
bNorm = Math.pow(Math.max(0, bNorm), settings.gamma);
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.max(0, Math.min(255, rNorm * 255)),
|
||||||
|
g: Math.max(0, Math.min(255, gNorm * 255)),
|
||||||
|
b: Math.max(0, Math.min(255, bNorm * 255))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hexToRgb(hex: string): { r: number, g: number, b: number } {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/scripts/ascii-shared.ts
Normal file
315
src/scripts/ascii-shared.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* Shared types, constants, and utilities for ASCII rendering.
|
||||||
|
* Used by both WebGL renderer and UI components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============= Types =============
|
||||||
|
|
||||||
|
export interface AsciiOptions {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
contrast?: number;
|
||||||
|
exposure?: number;
|
||||||
|
invert?: boolean;
|
||||||
|
saturation?: number;
|
||||||
|
gamma?: number;
|
||||||
|
charSet?: CharSetKey | string;
|
||||||
|
color?: boolean;
|
||||||
|
dither?: number;
|
||||||
|
edgeMode?: EdgeMode;
|
||||||
|
autoStretch?: boolean;
|
||||||
|
overlayStrength?: number;
|
||||||
|
aspectMode?: AspectMode;
|
||||||
|
denoise?: boolean;
|
||||||
|
fontAspectRatio?: number;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
sharpen?: number;
|
||||||
|
edgeThreshold?: number;
|
||||||
|
shadows?: number;
|
||||||
|
highlights?: number;
|
||||||
|
scanlines?: number;
|
||||||
|
vignette?: number;
|
||||||
|
monoColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsciiResult {
|
||||||
|
output: string;
|
||||||
|
isHtml: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EdgeMode = 'none' | 'simple' | 'sobel' | 'canny';
|
||||||
|
export type CharSetKey = 'standard' | 'extended' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'shapes';
|
||||||
|
export type AspectMode = 'fit' | 'fill' | 'stretch';
|
||||||
|
|
||||||
|
export interface ImageMetadata {
|
||||||
|
color_dominant?: [number, number, number];
|
||||||
|
color_palette?: [number, number, number][];
|
||||||
|
has_fine_detail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsciiSettings {
|
||||||
|
exposure: number;
|
||||||
|
contrast: number;
|
||||||
|
saturation: number;
|
||||||
|
gamma: number;
|
||||||
|
invert: boolean;
|
||||||
|
color: boolean;
|
||||||
|
dither: number;
|
||||||
|
denoise: boolean;
|
||||||
|
edgeMode: number;
|
||||||
|
overlayStrength: number;
|
||||||
|
resolution: number;
|
||||||
|
charSet: CharSetKey;
|
||||||
|
sharpen: number;
|
||||||
|
edgeThreshold: number;
|
||||||
|
shadows: number;
|
||||||
|
highlights: number;
|
||||||
|
scanlines: number;
|
||||||
|
vignette: number;
|
||||||
|
monoColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Constants =============
|
||||||
|
|
||||||
|
export const CHAR_SETS: Record<CharSetKey, string> = {
|
||||||
|
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+"L;|!~:,-_.\' ',
|
||||||
|
extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ ',
|
||||||
|
blocks: '█▓▒░ ',
|
||||||
|
minimal: '#+-. ',
|
||||||
|
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
|
||||||
|
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
|
||||||
|
shapes: '@%#*+=-:. '
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ASPECT_MODES: Record<string, AspectMode> = {
|
||||||
|
fit: 'fit',
|
||||||
|
fill: 'fill',
|
||||||
|
stretch: 'stretch'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EDGE_MODES: Record<string, EdgeMode> = {
|
||||||
|
none: 'none',
|
||||||
|
simple: 'simple',
|
||||||
|
sobel: 'sobel',
|
||||||
|
canny: 'canny'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Short keys for UI
|
||||||
|
export const CHARSET_SHORT_MAP: Record<string, CharSetKey> = {
|
||||||
|
STD: 'standard',
|
||||||
|
EXT: 'extended',
|
||||||
|
BLK: 'blocks',
|
||||||
|
MIN: 'minimal',
|
||||||
|
DOT: 'dots',
|
||||||
|
SHP: 'shapes'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHARSET_REVERSE_MAP: Record<CharSetKey, string> = Object.fromEntries(
|
||||||
|
Object.entries(CHARSET_SHORT_MAP).map(([k, v]) => [v, k])
|
||||||
|
) as Record<CharSetKey, string>;
|
||||||
|
|
||||||
|
// ============= Auto-Tune =============
|
||||||
|
|
||||||
|
export function autoTuneImage(img: HTMLImageElement, meta: ImageMetadata | null = null): Partial<AsciiOptions> {
|
||||||
|
if (typeof document === 'undefined') return {};
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return {};
|
||||||
|
|
||||||
|
const size = 100;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, size, size);
|
||||||
|
const pixels = imageData.data;
|
||||||
|
|
||||||
|
const histogram = new Array(256).fill(0);
|
||||||
|
let totalLum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
const lum = Math.round(0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]);
|
||||||
|
histogram[lum]++;
|
||||||
|
totalLum += lum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelCount = pixels.length / 4;
|
||||||
|
const avgLum = totalLum / pixelCount;
|
||||||
|
|
||||||
|
let p5: number | null = null, p95 = 255, count = 0;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
count += histogram[i];
|
||||||
|
if (p5 === null && count > pixelCount * 0.05) p5 = i;
|
||||||
|
if (count > pixelCount * 0.95) { p95 = i; break; }
|
||||||
|
}
|
||||||
|
p5 = p5 ?? 0;
|
||||||
|
|
||||||
|
const midPoint = (p5 + p95) / 2;
|
||||||
|
let exposure = 128 / Math.max(midPoint, 10);
|
||||||
|
exposure = Math.max(0.4, Math.min(2.8, exposure));
|
||||||
|
|
||||||
|
const activeRange = p95 - p5;
|
||||||
|
let contrast = 1.1;
|
||||||
|
if (activeRange < 50) contrast = 2.5;
|
||||||
|
else if (activeRange < 100) contrast = 1.8;
|
||||||
|
else if (activeRange < 150) contrast = 1.4;
|
||||||
|
|
||||||
|
let invert = false;
|
||||||
|
let saturation = 1.2;
|
||||||
|
let useEdgeDetection = true;
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
const { color_dominant, color_palette } = meta;
|
||||||
|
|
||||||
|
if (color_dominant) {
|
||||||
|
const [r, g, b] = color_dominant;
|
||||||
|
const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
if (domLum > 140) {
|
||||||
|
invert = true;
|
||||||
|
useEdgeDetection = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color_palette && Array.isArray(color_palette) && color_palette.length > 0) {
|
||||||
|
let totalSat = 0;
|
||||||
|
for (const [r, g, b] of color_palette) {
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const delta = max - Math.min(r, g, b);
|
||||||
|
const s = max === 0 ? 0 : delta / max;
|
||||||
|
totalSat += s;
|
||||||
|
}
|
||||||
|
const avgSat = totalSat / color_palette.length;
|
||||||
|
|
||||||
|
if (avgSat > 0.4) saturation = 1.6;
|
||||||
|
else if (avgSat < 0.1) saturation = 0.0;
|
||||||
|
else saturation = 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useEdgeDetection) {
|
||||||
|
let edgeLumSum = 0;
|
||||||
|
let edgeCount = 0;
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
if (x < 5 || x >= size - 5 || y < 5 || y >= size - 5) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
edgeLumSum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
||||||
|
edgeCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bgLum = edgeLumSum / edgeCount;
|
||||||
|
if (bgLum > 160) {
|
||||||
|
invert = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gamma = avgLum < 80 ? 0.75 : avgLum > 200 ? 1.15 : 1.0;
|
||||||
|
|
||||||
|
let recommendedCharSet: CharSetKey = 'standard';
|
||||||
|
let denoise = false;
|
||||||
|
let dither = 0;
|
||||||
|
let edgeMode: EdgeMode = 'none';
|
||||||
|
let overlayStrength = 0.3;
|
||||||
|
|
||||||
|
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
|
||||||
|
const isHighContrast = activeRange > 180;
|
||||||
|
const isLowContrast = activeRange < 80;
|
||||||
|
const noiseLevel = estimateNoiseLevel(pixels, size);
|
||||||
|
|
||||||
|
const noiseThreshold = isLowContrast ? 12 : isHighContrast ? 30 : 20;
|
||||||
|
|
||||||
|
const midToneCount = histogram.slice(64, 192).reduce((a, b) => a + b, 0);
|
||||||
|
const hasGradients = midToneCount > pixelCount * 0.6 && histogramPeaks < 5;
|
||||||
|
|
||||||
|
if (isHighContrast || (meta?.has_fine_detail)) {
|
||||||
|
recommendedCharSet = 'extended';
|
||||||
|
overlayStrength = 0.2;
|
||||||
|
if (noiseLevel < noiseThreshold * 0.5) {
|
||||||
|
edgeMode = 'canny'; // Use Canny for high quality clean images
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recommendedCharSet = 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLowContrast || noiseLevel > noiseThreshold) {
|
||||||
|
denoise = true;
|
||||||
|
overlayStrength = isLowContrast ? 0.5 : 0.3;
|
||||||
|
// Avoid complex edge detection on noisy images
|
||||||
|
edgeMode = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasGradients && !denoise) {
|
||||||
|
dither = 0.5; // Default dither strength
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noiseLevel > noiseThreshold * 1.5) {
|
||||||
|
dither = 0;
|
||||||
|
denoise = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exposure: parseFloat(exposure.toFixed(2)),
|
||||||
|
contrast,
|
||||||
|
invert,
|
||||||
|
gamma,
|
||||||
|
saturation: parseFloat(saturation.toFixed(1)),
|
||||||
|
charSet: recommendedCharSet,
|
||||||
|
denoise,
|
||||||
|
dither,
|
||||||
|
edgeMode,
|
||||||
|
overlayStrength
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHistogramPeaks(histogram: number[], pixelCount: number): number {
|
||||||
|
const threshold = pixelCount * 0.02;
|
||||||
|
let peaks = 0;
|
||||||
|
let inPeak = false;
|
||||||
|
|
||||||
|
for (let i = 1; i < 255; i++) {
|
||||||
|
const isPeak = histogram[i] > histogram[i - 1] && histogram[i] > histogram[i + 1];
|
||||||
|
const isSignificant = histogram[i] > threshold;
|
||||||
|
|
||||||
|
if (isPeak && isSignificant && !inPeak) {
|
||||||
|
peaks++;
|
||||||
|
inPeak = true;
|
||||||
|
} else if (histogram[i] < threshold / 2) {
|
||||||
|
inPeak = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return peaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateNoiseLevel(pixels: Uint8ClampedArray, size: number): number {
|
||||||
|
let totalVariance = 0;
|
||||||
|
const samples = 100;
|
||||||
|
|
||||||
|
for (let s = 0; s < samples; s++) {
|
||||||
|
const x = Math.floor(Math.random() * (size - 2)) + 1;
|
||||||
|
const y = Math.floor(Math.random() * (size - 2)) + 1;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
|
||||||
|
const center = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
||||||
|
const neighbors = [
|
||||||
|
(y - 1) * size + x,
|
||||||
|
(y + 1) * size + x,
|
||||||
|
y * size + (x - 1),
|
||||||
|
y * size + (x + 1)
|
||||||
|
].map(idx => {
|
||||||
|
const offset = idx * 4;
|
||||||
|
return 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2];
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgNeighbor = neighbors.reduce((a, b) => a + b, 0) / 4;
|
||||||
|
totalVariance += Math.abs(center - avgNeighbor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalVariance / samples;
|
||||||
|
}
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
export interface AsciiOptions {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
contrast?: number;
|
|
||||||
exposure?: number;
|
|
||||||
invert?: boolean;
|
|
||||||
saturation?: number;
|
|
||||||
gamma?: number;
|
|
||||||
charSet?: CharSetKey | string;
|
|
||||||
color?: boolean;
|
|
||||||
dither?: boolean;
|
|
||||||
enhanceEdges?: boolean;
|
|
||||||
autoStretch?: boolean;
|
|
||||||
overlayStrength?: number;
|
|
||||||
aspectMode?: 'fit' | 'fill' | 'stretch';
|
|
||||||
denoise?: boolean;
|
|
||||||
fontAspectRatio?: number;
|
|
||||||
onProgress?: (progress: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AsciiResult {
|
|
||||||
output: string;
|
|
||||||
isHtml: boolean;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CharSetKey = 'standard' | 'simple' | 'blocks' | 'minimal' | 'matrix' | 'dots' | 'ascii_extended';
|
|
||||||
export type AspectMode = 'fit' | 'fill' | 'stretch';
|
|
||||||
|
|
||||||
export const CHAR_SETS: Record<CharSetKey, string> = {
|
|
||||||
standard: '@W%$NQ08GBR&ODHKUgSMw#Xbdp5q9C26APahk3EFVesm{}o4JZcjnuy[f1xi*7zYt(l/I\\v)T?]r><+^"L;|!~:,-_.\' ',
|
|
||||||
simple: '@%#*+=-:. ',
|
|
||||||
blocks: '█▓▒░ ',
|
|
||||||
minimal: '#+-. ',
|
|
||||||
matrix: 'ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ1234567890:.=*+-<>',
|
|
||||||
dots: '⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣄⣀⡀ ',
|
|
||||||
ascii_extended: '░▒▓█▀▄▌▐│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌ '
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ASPECT_MODES: Record<string, AspectMode> = {
|
|
||||||
fit: 'fit',
|
|
||||||
fill: 'fill',
|
|
||||||
stretch: 'stretch'
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ImageMetadata {
|
|
||||||
color_dominant?: [number, number, number];
|
|
||||||
color_palette?: [number, number, number][];
|
|
||||||
has_fine_detail?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AsciiGenerator {
|
|
||||||
private ctx: CanvasRenderingContext2D | null = null;
|
|
||||||
private canvas: HTMLCanvasElement | null = null;
|
|
||||||
private sharpCanvas: HTMLCanvasElement | null = null;
|
|
||||||
private sharpCtx: CanvasRenderingContext2D | null = null;
|
|
||||||
private denoiseCanvas: HTMLCanvasElement | null = null;
|
|
||||||
private denoiseCtx: CanvasRenderingContext2D | null = null;
|
|
||||||
private colorData: Uint8Array | null = null;
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.ctx = null;
|
|
||||||
this.sharpCtx = null;
|
|
||||||
this.denoiseCtx = null;
|
|
||||||
this.colorData = null;
|
|
||||||
|
|
||||||
if (this.canvas) {
|
|
||||||
this.canvas.width = 0;
|
|
||||||
this.canvas.height = 0;
|
|
||||||
this.canvas = null;
|
|
||||||
}
|
|
||||||
if (this.sharpCanvas) {
|
|
||||||
this.sharpCanvas.width = 0;
|
|
||||||
this.sharpCanvas.height = 0;
|
|
||||||
this.sharpCanvas = null;
|
|
||||||
}
|
|
||||||
if (this.denoiseCanvas) {
|
|
||||||
this.denoiseCanvas.width = 0;
|
|
||||||
this.denoiseCanvas.height = 0;
|
|
||||||
this.denoiseCanvas = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generate(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
throw new Error('AsciiGenerator requires a browser environment.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const onProgress = options.onProgress ?? (() => { });
|
|
||||||
onProgress(0);
|
|
||||||
|
|
||||||
const img = await this.resolveImage(imageSource);
|
|
||||||
onProgress(10);
|
|
||||||
|
|
||||||
const requestedWidth = options.width ?? 100;
|
|
||||||
const fontAspectRatio = options.fontAspectRatio ?? 0.55;
|
|
||||||
const imgRatio = this.getImageRatio(img);
|
|
||||||
const aspectMode = options.aspectMode ?? 'fit';
|
|
||||||
|
|
||||||
let width: number, height: number;
|
|
||||||
if (aspectMode === 'stretch') {
|
|
||||||
width = requestedWidth;
|
|
||||||
height = options.height ?? Math.floor(requestedWidth / 2);
|
|
||||||
} else if (aspectMode === 'fill') {
|
|
||||||
width = requestedWidth;
|
|
||||||
const naturalHeight = Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
|
|
||||||
height = options.height ?? naturalHeight;
|
|
||||||
} else {
|
|
||||||
width = requestedWidth;
|
|
||||||
height = options.height ?? Math.floor(requestedWidth / (imgRatio / fontAspectRatio));
|
|
||||||
}
|
|
||||||
|
|
||||||
let charSet: string = options.charSet ?? 'standard';
|
|
||||||
if (charSet in CHAR_SETS) {
|
|
||||||
charSet = CHAR_SETS[charSet as CharSetKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.canvas) {
|
|
||||||
this.canvas = document.createElement('canvas');
|
|
||||||
}
|
|
||||||
this.canvas.width = width;
|
|
||||||
this.canvas.height = height;
|
|
||||||
this.ctx = this.canvas.getContext('2d');
|
|
||||||
|
|
||||||
if (!this.sharpCanvas) {
|
|
||||||
this.sharpCanvas = document.createElement('canvas');
|
|
||||||
}
|
|
||||||
this.sharpCanvas.width = width;
|
|
||||||
this.sharpCanvas.height = height;
|
|
||||||
this.sharpCtx = this.sharpCanvas.getContext('2d');
|
|
||||||
|
|
||||||
const exposure = options.exposure ?? 1.0;
|
|
||||||
const contrast = options.contrast ?? 1.0;
|
|
||||||
const saturation = options.saturation ?? 1.2;
|
|
||||||
const gamma = options.gamma ?? 1.0;
|
|
||||||
const dither = options.dither ?? false;
|
|
||||||
const enhanceEdges = options.enhanceEdges ?? false;
|
|
||||||
const autoStretch = options.autoStretch !== false;
|
|
||||||
const overlayStrength = options.overlayStrength ?? 0.3;
|
|
||||||
const denoise = options.denoise ?? false;
|
|
||||||
const colorOutput = options.color ?? false;
|
|
||||||
|
|
||||||
onProgress(20);
|
|
||||||
|
|
||||||
let sourceImage: HTMLImageElement | HTMLCanvasElement = img;
|
|
||||||
if (denoise) {
|
|
||||||
if (!this.denoiseCanvas) {
|
|
||||||
this.denoiseCanvas = document.createElement('canvas');
|
|
||||||
}
|
|
||||||
this.denoiseCanvas.width = width;
|
|
||||||
this.denoiseCanvas.height = height;
|
|
||||||
this.denoiseCtx = this.denoiseCanvas.getContext('2d');
|
|
||||||
if (this.denoiseCtx) {
|
|
||||||
this.denoiseCtx.filter = 'blur(0.5px)';
|
|
||||||
this.denoiseCtx.drawImage(img, 0, 0, width, height);
|
|
||||||
sourceImage = this.denoiseCanvas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sx = 0, sy = 0, sw = img.width, sh = img.height;
|
|
||||||
if (aspectMode === 'fill' && options.height) {
|
|
||||||
const targetRatio = width / (options.height * fontAspectRatio);
|
|
||||||
if (imgRatio > targetRatio) {
|
|
||||||
sw = img.height * targetRatio;
|
|
||||||
sx = (img.width - sw) / 2;
|
|
||||||
} else {
|
|
||||||
sh = img.width / targetRatio;
|
|
||||||
sy = (img.height - sh) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.sharpCtx) {
|
|
||||||
this.sharpCtx.filter = `brightness(${exposure}) contrast(${contrast}) saturate(${saturation})`;
|
|
||||||
if (denoise && sourceImage === this.denoiseCanvas) {
|
|
||||||
this.sharpCtx.drawImage(sourceImage, 0, 0, width, height);
|
|
||||||
} else {
|
|
||||||
this.sharpCtx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enhanceEdges && this.sharpCtx) {
|
|
||||||
this.sharpCtx.filter = 'none';
|
|
||||||
this.sharpCtx.globalCompositeOperation = 'source-over';
|
|
||||||
const edgeCanvas = document.createElement('canvas');
|
|
||||||
edgeCanvas.width = width;
|
|
||||||
edgeCanvas.height = height;
|
|
||||||
const edgeCtx = edgeCanvas.getContext('2d');
|
|
||||||
if (edgeCtx) {
|
|
||||||
edgeCtx.filter = 'contrast(2) brightness(0.8)';
|
|
||||||
edgeCtx.drawImage(this.sharpCanvas!, 0, 0);
|
|
||||||
this.sharpCtx.globalAlpha = 0.4;
|
|
||||||
this.sharpCtx.globalCompositeOperation = 'multiply';
|
|
||||||
this.sharpCtx.drawImage(edgeCanvas, 0, 0);
|
|
||||||
this.sharpCtx.globalCompositeOperation = 'source-over';
|
|
||||||
this.sharpCtx.globalAlpha = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(40);
|
|
||||||
|
|
||||||
if (this.ctx && this.sharpCanvas) {
|
|
||||||
this.ctx.globalAlpha = 1.0;
|
|
||||||
this.ctx.drawImage(this.sharpCanvas, 0, 0);
|
|
||||||
if (overlayStrength > 0) {
|
|
||||||
this.ctx.globalCompositeOperation = 'overlay';
|
|
||||||
this.ctx.globalAlpha = overlayStrength;
|
|
||||||
this.ctx.drawImage(this.sharpCanvas, 0, 0);
|
|
||||||
this.ctx.globalCompositeOperation = 'source-over';
|
|
||||||
this.ctx.globalAlpha = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageData = this.ctx!.getImageData(0, 0, width, height);
|
|
||||||
const pixels = imageData.data;
|
|
||||||
|
|
||||||
onProgress(50);
|
|
||||||
|
|
||||||
const lumMatrix = new Float32Array(width * height);
|
|
||||||
let minLum = 1.0, maxLum = 0.0;
|
|
||||||
|
|
||||||
if (colorOutput) {
|
|
||||||
this.colorData = new Uint8Array(width * height * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < width * height; i++) {
|
|
||||||
const offset = i * 4;
|
|
||||||
const r = pixels[offset];
|
|
||||||
const g = pixels[offset + 1];
|
|
||||||
const b = pixels[offset + 2];
|
|
||||||
let lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
||||||
|
|
||||||
if (colorOutput && this.colorData) {
|
|
||||||
this.colorData[i * 3] = r;
|
|
||||||
this.colorData[i * 3 + 1] = g;
|
|
||||||
this.colorData[i * 3 + 2] = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gamma !== 1.0) {
|
|
||||||
lum = Math.pow(lum, gamma);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.invert) {
|
|
||||||
lum = 1 - lum;
|
|
||||||
}
|
|
||||||
|
|
||||||
lumMatrix[i] = lum;
|
|
||||||
if (lum < minLum) minLum = lum;
|
|
||||||
if (lum > maxLum) maxLum = lum;
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(60);
|
|
||||||
|
|
||||||
const lumRange = maxLum - minLum;
|
|
||||||
if (autoStretch && lumRange > 0.01) {
|
|
||||||
for (let i = 0; i < lumMatrix.length; i++) {
|
|
||||||
lumMatrix[i] = (lumMatrix[i] - minLum) / lumRange;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dither) {
|
|
||||||
const levels = charSet.length;
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const i = y * width + x;
|
|
||||||
const oldVal = lumMatrix[i];
|
|
||||||
const newVal = Math.round(oldVal * (levels - 1)) / (levels - 1);
|
|
||||||
lumMatrix[i] = newVal;
|
|
||||||
const error = oldVal - newVal;
|
|
||||||
|
|
||||||
if (x + 1 < width) lumMatrix[i + 1] += error * 7 / 16;
|
|
||||||
if (y + 1 < height) {
|
|
||||||
if (x > 0) lumMatrix[(y + 1) * width + (x - 1)] += error * 3 / 16;
|
|
||||||
lumMatrix[(y + 1) * width + x] += error * 5 / 16;
|
|
||||||
if (x + 1 < width) lumMatrix[(y + 1) * width + (x + 1)] += error * 1 / 16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(80);
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
if (colorOutput && this.colorData) {
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const i = y * width + x;
|
|
||||||
const brightness = Math.max(0, Math.min(1, lumMatrix[i]));
|
|
||||||
const charIndex = Math.floor(brightness * (charSet.length - 1));
|
|
||||||
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
|
|
||||||
const char = charSet[safeIndex];
|
|
||||||
|
|
||||||
const r = this.colorData[i * 3];
|
|
||||||
const g = this.colorData[i * 3 + 1];
|
|
||||||
const b = this.colorData[i * 3 + 2];
|
|
||||||
|
|
||||||
const safeChar = char === '<' ? '<' : char === '>' ? '>' : char === '&' ? '&' : char;
|
|
||||||
output += `<span style="color:rgb(${r},${g},${b})">${safeChar}</span>`;
|
|
||||||
}
|
|
||||||
output += '\n';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const brightness = Math.max(0, Math.min(1, lumMatrix[y * width + x]));
|
|
||||||
const charIndex = Math.floor(brightness * (charSet.length - 1));
|
|
||||||
const safeIndex = Math.max(0, Math.min(charSet.length - 1, charIndex));
|
|
||||||
output += charSet[safeIndex];
|
|
||||||
}
|
|
||||||
output += '\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(100);
|
|
||||||
|
|
||||||
if (colorOutput) {
|
|
||||||
return {
|
|
||||||
output,
|
|
||||||
isHtml: true,
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getImageRatio(img: HTMLImageElement): number {
|
|
||||||
if (img.width && img.height) {
|
|
||||||
return img.width / img.height;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveImage(src: string | HTMLImageElement): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (src instanceof HTMLImageElement) {
|
|
||||||
if (src.complete) return resolve(src);
|
|
||||||
src.onload = () => resolve(src);
|
|
||||||
src.onerror = reject;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
|
||||||
img.src = src;
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(new Error('Failed to load image'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function imageToAscii(imageSource: string | HTMLImageElement, options: AsciiOptions = {}): Promise<string | AsciiResult> {
|
|
||||||
const generator = new AsciiGenerator();
|
|
||||||
return generator.generate(imageSource, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autoTuneImage(img: HTMLImageElement, meta: ImageMetadata | null = null): Partial<AsciiOptions> {
|
|
||||||
if (typeof document === 'undefined') return {};
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return {};
|
|
||||||
|
|
||||||
const size = 100;
|
|
||||||
canvas.width = size;
|
|
||||||
canvas.height = size;
|
|
||||||
ctx.drawImage(img, 0, 0, size, size);
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, size, size);
|
|
||||||
const pixels = imageData.data;
|
|
||||||
|
|
||||||
const histogram = new Array(256).fill(0);
|
|
||||||
let totalLum = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < pixels.length; i += 4) {
|
|
||||||
const lum = Math.round(0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]);
|
|
||||||
histogram[lum]++;
|
|
||||||
totalLum += lum;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pixelCount = pixels.length / 4;
|
|
||||||
const avgLum = totalLum / pixelCount;
|
|
||||||
|
|
||||||
let p5: number | null = null, p95 = 255, count = 0;
|
|
||||||
for (let i = 0; i < 256; i++) {
|
|
||||||
count += histogram[i];
|
|
||||||
if (p5 === null && count > pixelCount * 0.05) p5 = i;
|
|
||||||
if (count > pixelCount * 0.95) { p95 = i; break; }
|
|
||||||
}
|
|
||||||
p5 = p5 ?? 0;
|
|
||||||
|
|
||||||
const midPoint = (p5 + p95) / 2;
|
|
||||||
let exposure = 128 / Math.max(midPoint, 10);
|
|
||||||
exposure = Math.max(0.4, Math.min(2.8, exposure));
|
|
||||||
|
|
||||||
const activeRange = p95 - p5;
|
|
||||||
let contrast = 1.1;
|
|
||||||
if (activeRange < 50) contrast = 2.5;
|
|
||||||
else if (activeRange < 100) contrast = 1.8;
|
|
||||||
else if (activeRange < 150) contrast = 1.4;
|
|
||||||
|
|
||||||
let invert = false;
|
|
||||||
let saturation = 1.2;
|
|
||||||
let useEdgeDetection = true;
|
|
||||||
|
|
||||||
if (meta) {
|
|
||||||
const { color_dominant, color_palette } = meta;
|
|
||||||
|
|
||||||
if (color_dominant) {
|
|
||||||
const [r, g, b] = color_dominant;
|
|
||||||
const domLum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
if (domLum > 140) {
|
|
||||||
invert = true;
|
|
||||||
useEdgeDetection = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color_palette && Array.isArray(color_palette) && color_palette.length > 0) {
|
|
||||||
let totalSat = 0;
|
|
||||||
for (const [r, g, b] of color_palette) {
|
|
||||||
const max = Math.max(r, g, b);
|
|
||||||
const delta = max - Math.min(r, g, b);
|
|
||||||
const s = max === 0 ? 0 : delta / max;
|
|
||||||
totalSat += s;
|
|
||||||
}
|
|
||||||
const avgSat = totalSat / color_palette.length;
|
|
||||||
|
|
||||||
if (avgSat > 0.4) saturation = 1.6;
|
|
||||||
else if (avgSat < 0.1) saturation = 0.0;
|
|
||||||
else saturation = 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useEdgeDetection) {
|
|
||||||
let edgeLumSum = 0;
|
|
||||||
let edgeCount = 0;
|
|
||||||
for (let y = 0; y < size; y++) {
|
|
||||||
for (let x = 0; x < size; x++) {
|
|
||||||
if (x < 5 || x >= size - 5 || y < 5 || y >= size - 5) {
|
|
||||||
const i = (y * size + x) * 4;
|
|
||||||
edgeLumSum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
|
||||||
edgeCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bgLum = edgeLumSum / edgeCount;
|
|
||||||
if (bgLum > 160) {
|
|
||||||
invert = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gamma = avgLum < 80 ? 0.75 : 1.0;
|
|
||||||
|
|
||||||
let recommendedCharSet: CharSetKey = 'standard';
|
|
||||||
let denoise = false;
|
|
||||||
let enhanceEdges = false;
|
|
||||||
let overlayStrength = 0.3;
|
|
||||||
|
|
||||||
const histogramPeaks = countHistogramPeaks(histogram, pixelCount);
|
|
||||||
const isHighContrast = activeRange > 180;
|
|
||||||
const isLowContrast = activeRange < 80;
|
|
||||||
const isBimodal = histogramPeaks <= 3;
|
|
||||||
|
|
||||||
if (isBimodal && activeRange > 150) {
|
|
||||||
recommendedCharSet = 'minimal';
|
|
||||||
enhanceEdges = true;
|
|
||||||
overlayStrength = 0.1;
|
|
||||||
} else if (isHighContrast) {
|
|
||||||
recommendedCharSet = 'blocks';
|
|
||||||
overlayStrength = 0.2;
|
|
||||||
} else if (isLowContrast) {
|
|
||||||
recommendedCharSet = 'simple';
|
|
||||||
denoise = true;
|
|
||||||
overlayStrength = 0.5;
|
|
||||||
} else if (activeRange > 100 && activeRange <= 180) {
|
|
||||||
recommendedCharSet = 'standard';
|
|
||||||
const noiseLevel = estimateNoiseLevel(pixels, size);
|
|
||||||
if (noiseLevel > 20) {
|
|
||||||
denoise = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (meta?.has_fine_detail) {
|
|
||||||
recommendedCharSet = 'dots';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exposure: parseFloat(exposure.toFixed(2)),
|
|
||||||
contrast,
|
|
||||||
invert,
|
|
||||||
gamma,
|
|
||||||
saturation: parseFloat(saturation.toFixed(1)),
|
|
||||||
charSet: recommendedCharSet,
|
|
||||||
denoise,
|
|
||||||
enhanceEdges,
|
|
||||||
overlayStrength
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function countHistogramPeaks(histogram: number[], pixelCount: number): number {
|
|
||||||
const threshold = pixelCount * 0.02;
|
|
||||||
let peaks = 0;
|
|
||||||
let inPeak = false;
|
|
||||||
|
|
||||||
for (let i = 1; i < 255; i++) {
|
|
||||||
const isPeak = histogram[i] > histogram[i - 1] && histogram[i] > histogram[i + 1];
|
|
||||||
const isSignificant = histogram[i] > threshold;
|
|
||||||
|
|
||||||
if (isPeak && isSignificant && !inPeak) {
|
|
||||||
peaks++;
|
|
||||||
inPeak = true;
|
|
||||||
} else if (histogram[i] < threshold / 2) {
|
|
||||||
inPeak = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return peaks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateNoiseLevel(pixels: Uint8ClampedArray, size: number): number {
|
|
||||||
let totalVariance = 0;
|
|
||||||
const samples = 100;
|
|
||||||
|
|
||||||
for (let s = 0; s < samples; s++) {
|
|
||||||
const x = Math.floor(Math.random() * (size - 2)) + 1;
|
|
||||||
const y = Math.floor(Math.random() * (size - 2)) + 1;
|
|
||||||
const i = (y * size + x) * 4;
|
|
||||||
|
|
||||||
const center = 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
|
|
||||||
const neighbors = [
|
|
||||||
(y - 1) * size + x,
|
|
||||||
(y + 1) * size + x,
|
|
||||||
y * size + (x - 1),
|
|
||||||
y * size + (x + 1)
|
|
||||||
].map(idx => {
|
|
||||||
const offset = idx * 4;
|
|
||||||
return 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2];
|
|
||||||
});
|
|
||||||
|
|
||||||
const avgNeighbor = neighbors.reduce((a, b) => a + b, 0) / 4;
|
|
||||||
totalVariance += Math.abs(center - avgNeighbor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalVariance / samples;
|
|
||||||
}
|
|
||||||
138
src/scripts/image-queue.ts
Normal file
138
src/scripts/image-queue.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Image Queue Manager
|
||||||
|
* Handles prefetching and buffering of anime images.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchRandomAnimeImage, loadSingleImage } from './anime-api';
|
||||||
|
import { autoTuneImage, type AsciiOptions, type ImageMetadata } from './ascii-shared';
|
||||||
|
|
||||||
|
// ============= Types =============
|
||||||
|
|
||||||
|
export interface QueuedImage {
|
||||||
|
url: string;
|
||||||
|
imgElement: HTMLImageElement;
|
||||||
|
meta: ImageMetadata | null;
|
||||||
|
suggestions: Partial<AsciiOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Queue Manager =============
|
||||||
|
|
||||||
|
export class ImageQueue {
|
||||||
|
private queue: QueuedImage[] = [];
|
||||||
|
private isFetching = false;
|
||||||
|
private maxSize: number;
|
||||||
|
private onQueueUpdate?: () => void;
|
||||||
|
|
||||||
|
private isDisposed = false;
|
||||||
|
|
||||||
|
constructor(maxSize = 2) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Public API =============
|
||||||
|
|
||||||
|
getLength(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(callback: () => void): void {
|
||||||
|
this.onQueueUpdate = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop the next image from the queue.
|
||||||
|
* Returns null if queue is empty.
|
||||||
|
*/
|
||||||
|
pop(): QueuedImage | null {
|
||||||
|
if (this.isDisposed) return null;
|
||||||
|
const item = this.queue.shift() ?? null;
|
||||||
|
this.onQueueUpdate?.();
|
||||||
|
// Trigger background refill
|
||||||
|
this.ensureFilled();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a new image directly (bypasses queue).
|
||||||
|
* Used when queue is empty and we need an image immediately.
|
||||||
|
*/
|
||||||
|
async fetchDirect(): Promise<QueuedImage> {
|
||||||
|
if (this.isDisposed) throw new Error("Queue disposed");
|
||||||
|
const data = await fetchRandomAnimeImage();
|
||||||
|
const img = await loadSingleImage(data.url);
|
||||||
|
const suggestions = autoTuneImage(img, data.meta);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: data.url,
|
||||||
|
imgElement: img,
|
||||||
|
meta: data.meta,
|
||||||
|
suggestions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start filling the queue in the background.
|
||||||
|
*/
|
||||||
|
async ensureFilled(): Promise<void> {
|
||||||
|
if (this.isDisposed) return;
|
||||||
|
|
||||||
|
while (this.queue.length < this.maxSize && !this.isDisposed) {
|
||||||
|
await this.prefetchOne();
|
||||||
|
// Small delay between fetches to avoid rate limiting
|
||||||
|
if (!this.isDisposed) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch a single image and add to queue.
|
||||||
|
*/
|
||||||
|
async prefetchOne(): Promise<void> {
|
||||||
|
if (this.isFetching || this.queue.length >= this.maxSize || this.isDisposed) return;
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
|
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchRandomAnimeImage();
|
||||||
|
// Check disposal again after async op
|
||||||
|
if (this.isDisposed) return;
|
||||||
|
|
||||||
|
const img = await loadSingleImage(data.url);
|
||||||
|
if (this.isDisposed) return;
|
||||||
|
|
||||||
|
const suggestions = autoTuneImage(img, data.meta);
|
||||||
|
|
||||||
|
this.queue.push({
|
||||||
|
url: data.url,
|
||||||
|
imgElement: img,
|
||||||
|
meta: data.meta,
|
||||||
|
suggestions
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onQueueUpdate?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to prefetch image:', e);
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear and dispose the queue.
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this.isDisposed = true;
|
||||||
|
this.queue = [];
|
||||||
|
this.onQueueUpdate = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the queue (legacy support, prefers dispose).
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.queue = [];
|
||||||
|
this.onQueueUpdate?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
609
src/scripts/ui-bindings.ts
Normal file
609
src/scripts/ui-bindings.ts
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
/**
|
||||||
|
* UI Bindings
|
||||||
|
* Event listeners, keyboard shortcuts, and UI synchronization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CHARSET_SHORT_MAP, CHARSET_REVERSE_MAP, type AsciiSettings } from './ascii-shared';
|
||||||
|
import type { AsciiController } from './ascii-controller';
|
||||||
|
import type { ImageQueue } from './image-queue';
|
||||||
|
|
||||||
|
// ============= Window Extensions =============
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
updateToggleState?: (id: string, checked: boolean) => void;
|
||||||
|
updateSegmentValue?: (id: string, value: string) => void;
|
||||||
|
__ASCII_APP__?: {
|
||||||
|
controller: AsciiController;
|
||||||
|
queue: ImageQueue;
|
||||||
|
ui: UIBindings;
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= UI Manager =============
|
||||||
|
|
||||||
|
export class UIBindings {
|
||||||
|
private controller: AsciiController;
|
||||||
|
private queue: ImageQueue;
|
||||||
|
private loadNewImageFn: () => Promise<void>;
|
||||||
|
private isUpdatingUI = false;
|
||||||
|
private lastNextTime = 0;
|
||||||
|
private readonly NEXT_COOLDOWN = 1000; // 1 second cooldown
|
||||||
|
|
||||||
|
|
||||||
|
// Event Handlers implementation references
|
||||||
|
private sliderHandlers: Map<string, (e: Event) => void> = new Map();
|
||||||
|
private wheelHandlers: Map<string, (e: WheelEvent) => void> = new Map();
|
||||||
|
private toggleHandler: ((e: Event) => void) | null = null;
|
||||||
|
private segmentHandlers: Map<string, (e: Event) => void> = new Map();
|
||||||
|
private buttonHandlers: Map<string, (e: Event) => void> = new Map();
|
||||||
|
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
private zoomHandlers: {
|
||||||
|
wheel?: (e: Event) => void;
|
||||||
|
move?: (e: Event) => void;
|
||||||
|
leave?: (e: Event) => void;
|
||||||
|
} = {};
|
||||||
|
private resizeHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
private queueInterval: number | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
controller: AsciiController,
|
||||||
|
queue: ImageQueue,
|
||||||
|
loadNewImage: () => Promise<void>
|
||||||
|
) {
|
||||||
|
this.controller = controller;
|
||||||
|
this.queue = queue;
|
||||||
|
this.loadNewImageFn = loadNewImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Setup =============
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
this.setupSliders();
|
||||||
|
this.setupToggles();
|
||||||
|
this.setupColorInput();
|
||||||
|
this.setupSegments();
|
||||||
|
this.setupButtons();
|
||||||
|
this.setupKeyboard();
|
||||||
|
this.setupZoom();
|
||||||
|
this.setupResize();
|
||||||
|
this.setupImport();
|
||||||
|
|
||||||
|
// Periodic queue update
|
||||||
|
this.queueInterval = window.setInterval(() => this.updateQueueDisplay(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
// Clear interval
|
||||||
|
if (this.queueInterval !== null) {
|
||||||
|
clearInterval(this.queueInterval);
|
||||||
|
this.queueInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup Sliders
|
||||||
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights'] as const;
|
||||||
|
sliderIds.forEach(id => {
|
||||||
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
const handler = this.sliderHandlers.get(id);
|
||||||
|
const wheelHandler = this.wheelHandlers.get(id);
|
||||||
|
if (input) {
|
||||||
|
if (handler) input.removeEventListener('input', handler);
|
||||||
|
if (wheelHandler) input.removeEventListener('wheel', wheelHandler as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.sliderHandlers.clear();
|
||||||
|
this.wheelHandlers.clear();
|
||||||
|
|
||||||
|
// Cleanup Toggles
|
||||||
|
if (this.toggleHandler) {
|
||||||
|
document.body.removeEventListener('toggle-change', this.toggleHandler);
|
||||||
|
this.toggleHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup Segments
|
||||||
|
['segment-invert', 'segment-charset'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const handler = this.segmentHandlers.get(id);
|
||||||
|
if (el && handler) {
|
||||||
|
el.removeEventListener('segment-change', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.segmentHandlers.clear();
|
||||||
|
|
||||||
|
// Cleanup Buttons
|
||||||
|
['btn-reset', 'btn-next'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const handler = this.buttonHandlers.get(id);
|
||||||
|
if (el && handler) {
|
||||||
|
el.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.buttonHandlers.clear();
|
||||||
|
|
||||||
|
// Cleanup Keyboard
|
||||||
|
if (this.keydownHandler) {
|
||||||
|
document.removeEventListener('keydown', this.keydownHandler);
|
||||||
|
this.keydownHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup Zoom
|
||||||
|
const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
|
||||||
|
const canvas = document.getElementById('ascii-canvas');
|
||||||
|
if (workspace) {
|
||||||
|
if (this.zoomHandlers.wheel) workspace.removeEventListener('wheel', this.zoomHandlers.wheel);
|
||||||
|
if (this.zoomHandlers.move) workspace.removeEventListener('mousemove', this.zoomHandlers.move);
|
||||||
|
if (this.zoomHandlers.leave) workspace.removeEventListener('mouseleave', this.zoomHandlers.leave);
|
||||||
|
}
|
||||||
|
if (canvas) {
|
||||||
|
if (this.zoomHandlers.wheel) canvas.removeEventListener('wheel', this.zoomHandlers.wheel);
|
||||||
|
if (this.zoomHandlers.move) canvas.removeEventListener('mousemove', this.zoomHandlers.move);
|
||||||
|
if (this.zoomHandlers.leave) canvas.removeEventListener('mouseleave', this.zoomHandlers.leave);
|
||||||
|
}
|
||||||
|
this.zoomHandlers = {};
|
||||||
|
|
||||||
|
// Cleanup Resize
|
||||||
|
if (this.resizeHandler) {
|
||||||
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
this.resizeHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup Export Buttons
|
||||||
|
['btn-save-png', 'btn-copy-text', 'btn-copy-html', 'btn-import'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const handler = this.buttonHandlers.get(id);
|
||||||
|
if (el && handler) {
|
||||||
|
el.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup File Input
|
||||||
|
const fileInput = document.getElementById('file-upload');
|
||||||
|
const fileHandler = this.buttonHandlers.get('file-upload');
|
||||||
|
if (fileInput && fileHandler) {
|
||||||
|
fileInput.removeEventListener('change', fileHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Sliders =============
|
||||||
|
|
||||||
|
private setupSliders(): void {
|
||||||
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||||
|
|
||||||
|
sliderIds.forEach(id => {
|
||||||
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
// Change listener
|
||||||
|
const handler = () => {
|
||||||
|
if (this.isUpdatingUI) return;
|
||||||
|
const value = parseFloat(input.value);
|
||||||
|
this.controller.setSetting(id as keyof AsciiSettings, value as any);
|
||||||
|
};
|
||||||
|
this.sliderHandlers.set(id, handler);
|
||||||
|
input.addEventListener('input', handler);
|
||||||
|
|
||||||
|
// Wheel listener
|
||||||
|
const wheelHandler = (e: WheelEvent) => {
|
||||||
|
// Prevent page scroll and zoom
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const step = parseFloat(input.step) || 0.01;
|
||||||
|
// Standardize wheel delta
|
||||||
|
const direction = e.deltaY > 0 ? -1 : 1;
|
||||||
|
const currentVal = parseFloat(input.value);
|
||||||
|
const min = parseFloat(input.min) || 0;
|
||||||
|
const max = parseFloat(input.max) || 100;
|
||||||
|
|
||||||
|
const newVal = Math.min(max, Math.max(min, currentVal + direction * step));
|
||||||
|
|
||||||
|
if (Math.abs(newVal - currentVal) > 0.0001) {
|
||||||
|
input.value = newVal.toString();
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.wheelHandlers.set(id, wheelHandler);
|
||||||
|
input.addEventListener('wheel', wheelHandler as any, { passive: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Toggles =============
|
||||||
|
|
||||||
|
private setupToggles(): void {
|
||||||
|
this.toggleHandler = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const toggleId = target.id;
|
||||||
|
const checked = (e as CustomEvent).detail?.checked;
|
||||||
|
|
||||||
|
switch (toggleId) {
|
||||||
|
case 'toggle-color':
|
||||||
|
this.controller.setSetting('color', checked);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'toggle-denoise':
|
||||||
|
this.controller.setSetting('denoise', checked);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.addEventListener('toggle-change', this.toggleHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Color Input =============
|
||||||
|
|
||||||
|
private setupColorInput(): void {
|
||||||
|
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||||
|
const colorSwatch = document.getElementById('color-swatch-display');
|
||||||
|
|
||||||
|
if (colorInput) {
|
||||||
|
colorInput.addEventListener('input', () => {
|
||||||
|
if (this.isUpdatingUI) return;
|
||||||
|
this.controller.setSetting('monoColor', colorInput.value);
|
||||||
|
if (colorSwatch) {
|
||||||
|
colorSwatch.style.backgroundColor = colorInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColorInput = document.getElementById('input-bg-color') as HTMLInputElement;
|
||||||
|
const bgColorSwatch = document.getElementById('bg-color-swatch-display');
|
||||||
|
|
||||||
|
if (bgColorInput) {
|
||||||
|
bgColorInput.addEventListener('input', () => {
|
||||||
|
if (this.isUpdatingUI) return;
|
||||||
|
this.controller.setSetting('backgroundColor', bgColorInput.value);
|
||||||
|
if (bgColorSwatch) {
|
||||||
|
bgColorSwatch.style.backgroundColor = bgColorInput.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Segments =============
|
||||||
|
|
||||||
|
private setupSegments(): void {
|
||||||
|
// Invert Segment
|
||||||
|
const invertEl = document.getElementById('segment-invert');
|
||||||
|
if (invertEl) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const value = (e as CustomEvent).detail.value;
|
||||||
|
if (value === 'AUTO') {
|
||||||
|
this.controller.setInvertMode('auto');
|
||||||
|
} else if (value === 'ON') {
|
||||||
|
this.controller.setInvertMode('on');
|
||||||
|
} else {
|
||||||
|
this.controller.setInvertMode('off');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.segmentHandlers.set('segment-invert', handler);
|
||||||
|
invertEl.addEventListener('segment-change', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charset Segment
|
||||||
|
const charsetEl = document.getElementById('segment-charset');
|
||||||
|
if (charsetEl) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const shortKey = (e as CustomEvent).detail.value;
|
||||||
|
const charSet = CHARSET_SHORT_MAP[shortKey] || 'standard';
|
||||||
|
this.controller.setSetting('charSet', charSet);
|
||||||
|
};
|
||||||
|
this.segmentHandlers.set('segment-charset', handler);
|
||||||
|
charsetEl.addEventListener('segment-change', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge Mode Segment
|
||||||
|
const edgeEl = document.getElementById('segment-edge');
|
||||||
|
if (edgeEl) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const val = (e as CustomEvent).detail.value;
|
||||||
|
let mode = 0;
|
||||||
|
switch (val) {
|
||||||
|
case 'SPL': mode = 1; break;
|
||||||
|
case 'SOB': mode = 2; break;
|
||||||
|
case 'CNY': mode = 3; break;
|
||||||
|
default: mode = 0; break;
|
||||||
|
}
|
||||||
|
this.controller.setSetting('edgeMode', mode);
|
||||||
|
};
|
||||||
|
this.segmentHandlers.set('segment-edge', handler);
|
||||||
|
edgeEl.addEventListener('segment-change', handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Buttons =============
|
||||||
|
|
||||||
|
private setupButtons(): void {
|
||||||
|
// Reset
|
||||||
|
const btnReset = document.getElementById('btn-reset');
|
||||||
|
if (btnReset) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.controller.resetToAutoSettings();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-reset', handler);
|
||||||
|
btnReset.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Next
|
||||||
|
const btnNext = document.getElementById('btn-next');
|
||||||
|
if (btnNext) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastNextTime < this.NEXT_COOLDOWN) return;
|
||||||
|
this.lastNextTime = now;
|
||||||
|
|
||||||
|
this.loadNewImageFn();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-next', handler);
|
||||||
|
btnNext.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Buttons
|
||||||
|
const btnSavePng = document.getElementById('btn-save-png');
|
||||||
|
if (btnSavePng) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.controller.savePNG();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-save-png', handler);
|
||||||
|
btnSavePng.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnCopyText = document.getElementById('btn-copy-text');
|
||||||
|
if (btnCopyText) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.controller.saveText();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-copy-text', handler);
|
||||||
|
btnCopyText.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnCopyHtml = document.getElementById('btn-copy-html');
|
||||||
|
if (btnCopyHtml) {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.controller.saveHTML();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-copy-html', handler);
|
||||||
|
btnCopyHtml.addEventListener('click', handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Keyboard =============
|
||||||
|
|
||||||
|
private setupKeyboard(): void {
|
||||||
|
this.keydownHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'n':
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastNextTime < this.NEXT_COOLDOWN) break;
|
||||||
|
this.lastNextTime = now;
|
||||||
|
this.loadNewImageFn();
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
this.controller.resetToAutoSettings();
|
||||||
|
break;
|
||||||
|
case 'i':
|
||||||
|
this.controller.cycleInvertMode();
|
||||||
|
this.updateUI();
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
this.controller.setSetting('color', !this.controller.getSetting('color'));
|
||||||
|
this.updateUI();
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
// Toggle dither strength between 0 and 0.5
|
||||||
|
const currentDither = this.controller.getSetting('dither');
|
||||||
|
this.controller.setSetting('dither', currentDither > 0 ? 0 : 0.5);
|
||||||
|
this.updateUI();
|
||||||
|
break;
|
||||||
|
case 'e':
|
||||||
|
// Cycle edge modes: 0 -> 1 -> 2 -> 3 -> 0
|
||||||
|
const currentMode = this.controller.getSetting('edgeMode');
|
||||||
|
const nextMode = (currentMode + 1) % 4;
|
||||||
|
this.controller.setSetting('edgeMode', nextMode);
|
||||||
|
this.updateUI();
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
this.controller.cycleCharSet();
|
||||||
|
this.updateUI();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this.keydownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Zoom =============
|
||||||
|
|
||||||
|
private setupZoom(): void {
|
||||||
|
const workspace = document.querySelector('.ascii-workspace') || document.querySelector('.hero-wrapper');
|
||||||
|
if (!workspace) return;
|
||||||
|
|
||||||
|
// Also attach to canvas for direct interaction
|
||||||
|
const canvas = document.getElementById('ascii-canvas');
|
||||||
|
|
||||||
|
this.zoomHandlers.wheel = (e: Event) => {
|
||||||
|
const we = e as WheelEvent;
|
||||||
|
if ((we.target as HTMLElement).closest('#tui-controls')) return;
|
||||||
|
|
||||||
|
we.preventDefault();
|
||||||
|
this.controller.handleWheel(we);
|
||||||
|
};
|
||||||
|
// Use passive: false to allow preventDefault
|
||||||
|
workspace.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false });
|
||||||
|
|
||||||
|
this.zoomHandlers.move = (e: Event) => {
|
||||||
|
this.controller.handleMouseMove(e as MouseEvent);
|
||||||
|
};
|
||||||
|
workspace.addEventListener('mousemove', this.zoomHandlers.move);
|
||||||
|
|
||||||
|
this.zoomHandlers.leave = () => {
|
||||||
|
this.controller.handleMouseLeave();
|
||||||
|
};
|
||||||
|
workspace.addEventListener('mouseleave', this.zoomHandlers.leave);
|
||||||
|
|
||||||
|
// Also attach directly to canvas for better responsiveness
|
||||||
|
if (canvas) {
|
||||||
|
canvas.addEventListener('wheel', this.zoomHandlers.wheel, { passive: false });
|
||||||
|
canvas.addEventListener('mousemove', this.zoomHandlers.move);
|
||||||
|
canvas.addEventListener('mouseleave', this.zoomHandlers.leave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Resize =============
|
||||||
|
|
||||||
|
private setupResize(): void {
|
||||||
|
let resizeTimeout: number | undefined;
|
||||||
|
this.resizeHandler = () => {
|
||||||
|
window.clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = window.setTimeout(() => this.controller.generate(), 200);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', this.resizeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Import =============
|
||||||
|
|
||||||
|
private setupImport(): void {
|
||||||
|
const btnImport = document.getElementById('btn-import');
|
||||||
|
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (btnImport && fileInput) {
|
||||||
|
// Button triggers file input
|
||||||
|
const btnHandler = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
fileInput.click();
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('btn-import', btnHandler);
|
||||||
|
btnImport.addEventListener('click', btnHandler);
|
||||||
|
|
||||||
|
// File input change
|
||||||
|
const fileHandler = async (e: Event) => {
|
||||||
|
const files = (e.target as HTMLInputElement).files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
// Reset value so same file can be selected again
|
||||||
|
fileInput.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.controller.showLoading("LOADING IMPORT...");
|
||||||
|
// Use empty suggestions for user imports unless we want to auto-detect?
|
||||||
|
// For now keep existing settings or use defaults.
|
||||||
|
// Let's pass empty object to respect current user settings or controller defaults.
|
||||||
|
this.controller.setCurrentImage(url, {});
|
||||||
|
this.updateUI();
|
||||||
|
await this.controller.generate();
|
||||||
|
this.controller.hideLoading();
|
||||||
|
// Notify that an image was successfully imported
|
||||||
|
document.dispatchEvent(new CustomEvent('ascii-image-imported'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Import failed:", err);
|
||||||
|
this.controller.hideLoading();
|
||||||
|
alert("Failed to load image. Please try another file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.buttonHandlers.set('file-upload', fileHandler);
|
||||||
|
fileInput.addEventListener('change', fileHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= UI Sync =============
|
||||||
|
|
||||||
|
updateUI(): void {
|
||||||
|
if (this.isUpdatingUI) return;
|
||||||
|
this.isUpdatingUI = true;
|
||||||
|
|
||||||
|
const settings = this.controller.getSettings();
|
||||||
|
|
||||||
|
// Update sliders
|
||||||
|
const sliderIds = ['exposure', 'contrast', 'saturation', 'gamma', 'overlayStrength', 'resolution', 'dither', 'sharpen', 'edgeThreshold', 'shadows', 'highlights', 'scanlines', 'vignette'] as const;
|
||||||
|
sliderIds.forEach(id => {
|
||||||
|
const input = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
if (input && settings[id] !== undefined) {
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
// Use a small epsilon for float comparison to avoid jitter
|
||||||
|
if (Math.abs(val - settings[id]) > 0.0001) {
|
||||||
|
input.value = String(settings[id]);
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update toggles
|
||||||
|
window.updateToggleState?.('toggle-color', settings.color);
|
||||||
|
window.updateToggleState?.('toggle-denoise', settings.denoise);
|
||||||
|
|
||||||
|
// Update segments
|
||||||
|
const invertMode = this.controller.getInvertMode();
|
||||||
|
const invertValue = invertMode === 'auto' ? 'AUTO' : settings.invert ? 'ON' : 'OFF';
|
||||||
|
window.updateSegmentValue?.('segment-invert', invertValue);
|
||||||
|
|
||||||
|
const charSetShort = CHARSET_REVERSE_MAP[settings.charSet] || 'STD';
|
||||||
|
window.updateSegmentValue?.('segment-charset', charSetShort);
|
||||||
|
|
||||||
|
let edgeShort = 'OFF';
|
||||||
|
switch (settings.edgeMode) {
|
||||||
|
case 1: edgeShort = 'SPL'; break;
|
||||||
|
case 2: edgeShort = 'SOB'; break;
|
||||||
|
case 3: edgeShort = 'CNY'; break;
|
||||||
|
default: edgeShort = 'OFF'; break;
|
||||||
|
}
|
||||||
|
window.updateSegmentValue?.('segment-edge', edgeShort);
|
||||||
|
|
||||||
|
// Update color input
|
||||||
|
const colorInput = document.getElementById('input-mono-color') as HTMLInputElement;
|
||||||
|
const colorSwatch = document.getElementById('color-swatch-display');
|
||||||
|
|
||||||
|
if (colorInput && settings.monoColor) {
|
||||||
|
if (colorInput.value !== settings.monoColor) {
|
||||||
|
colorInput.value = settings.monoColor;
|
||||||
|
}
|
||||||
|
if (colorSwatch) {
|
||||||
|
colorSwatch.style.backgroundColor = settings.monoColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColorInput = document.getElementById('input-bg-color') as HTMLInputElement;
|
||||||
|
const bgColorSwatch = document.getElementById('bg-color-swatch-display');
|
||||||
|
|
||||||
|
if (bgColorInput && settings.backgroundColor) {
|
||||||
|
if (bgColorInput.value !== settings.backgroundColor) {
|
||||||
|
bgColorInput.value = settings.backgroundColor;
|
||||||
|
}
|
||||||
|
if (bgColorSwatch) {
|
||||||
|
bgColorSwatch.style.backgroundColor = settings.backgroundColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateQueueDisplay();
|
||||||
|
|
||||||
|
this.isUpdatingUI = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueueDisplay(): void {
|
||||||
|
const queueEl = document.getElementById('val-queue');
|
||||||
|
if (queueEl) {
|
||||||
|
queueEl.textContent = this.queue.getLength().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
charSetContent: string;
|
charSetContent: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
@@ -12,9 +11,17 @@ export interface RenderOptions {
|
|||||||
invert: boolean;
|
invert: boolean;
|
||||||
color: boolean;
|
color: boolean;
|
||||||
overlayStrength?: number;
|
overlayStrength?: number;
|
||||||
enhanceEdges?: boolean;
|
edgeMode?: number; // 0=none, 1=simple, 2=sobel, 3=canny
|
||||||
dither?: boolean;
|
dither?: number;
|
||||||
denoise?: boolean;
|
denoise?: boolean;
|
||||||
|
sharpen?: number;
|
||||||
|
edgeThreshold?: number;
|
||||||
|
shadows?: number;
|
||||||
|
highlights?: number;
|
||||||
|
scanlines?: number;
|
||||||
|
vignette?: number;
|
||||||
|
monoColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
zoomCenter?: { x: number; y: number };
|
zoomCenter?: { x: number; y: number };
|
||||||
mousePos?: { x: number; y: number };
|
mousePos?: { x: number; y: number };
|
||||||
@@ -36,7 +43,7 @@ export class WebGLAsciiRenderer {
|
|||||||
|
|
||||||
private gl: WebGLRenderingContext;
|
private gl: WebGLRenderingContext;
|
||||||
private program: WebGLProgram | null;
|
private program: WebGLProgram | null;
|
||||||
private textures: { image?: WebGLTexture; atlas?: WebGLTexture };
|
private textures: { image?: WebGLTexture; atlas?: WebGLTexture; blueNoise?: WebGLTexture };
|
||||||
private buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer };
|
private buffers: { position?: WebGLBuffer; texCoord?: WebGLBuffer };
|
||||||
private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null;
|
private charAtlas: { width: number; height: number; charWidth: number; charHeight: number; count: number } | null;
|
||||||
private charSet: string;
|
private charSet: string;
|
||||||
@@ -46,12 +53,16 @@ export class WebGLAsciiRenderer {
|
|||||||
private lastImage: HTMLImageElement | null;
|
private lastImage: HTMLImageElement | null;
|
||||||
|
|
||||||
constructor(_canvas: HTMLCanvasElement) {
|
constructor(_canvas: HTMLCanvasElement) {
|
||||||
const gl = _canvas.getContext('webgl', { antialias: false });
|
const gl = _canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: true });
|
||||||
if (!gl) {
|
if (!gl) {
|
||||||
throw new Error('WebGL not supported');
|
throw new Error('WebGL not supported');
|
||||||
}
|
}
|
||||||
this.gl = gl;
|
this.gl = gl;
|
||||||
|
|
||||||
|
// Enable required extensions for advanced rendering
|
||||||
|
const hasDerivatives = !!gl.getExtension('OES_standard_derivatives');
|
||||||
|
const hasLod = !!gl.getExtension('EXT_shader_texture_lod');
|
||||||
|
|
||||||
this.program = null;
|
this.program = null;
|
||||||
this.textures = {};
|
this.textures = {};
|
||||||
this.buffers = {};
|
this.buffers = {};
|
||||||
@@ -60,10 +71,11 @@ export class WebGLAsciiRenderer {
|
|||||||
this.lastImage = null;
|
this.lastImage = null;
|
||||||
this.fontFamily = "'JetBrains Mono', monospace";
|
this.fontFamily = "'JetBrains Mono', monospace";
|
||||||
|
|
||||||
this.init();
|
this.init(hasDerivatives, hasLod);
|
||||||
|
this.loadBlueNoiseTexture();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init(hasDerivatives: boolean, hasLod: boolean) {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
|
|
||||||
// Vertex Shader
|
// Vertex Shader
|
||||||
@@ -79,16 +91,19 @@ export class WebGLAsciiRenderer {
|
|||||||
|
|
||||||
// Fragment Shader
|
// Fragment Shader
|
||||||
const fsSource = `
|
const fsSource = `
|
||||||
|
${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''}
|
||||||
|
${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''}
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
varying vec2 v_texCoord;
|
varying vec2 v_texCoord;
|
||||||
|
|
||||||
uniform sampler2D u_image;
|
uniform sampler2D u_image;
|
||||||
uniform sampler2D u_atlas;
|
uniform sampler2D u_atlas;
|
||||||
|
uniform sampler2D u_blueNoise;
|
||||||
uniform float u_charCount;
|
uniform float u_charCount;
|
||||||
uniform vec2 u_gridSize; // cols, rows
|
uniform vec2 u_charSizeUV;
|
||||||
uniform vec2 u_texSize; // atlas size
|
uniform vec2 u_gridSize;
|
||||||
|
uniform vec2 u_texSize;
|
||||||
|
|
||||||
// Adjustments
|
|
||||||
uniform float u_exposure;
|
uniform float u_exposure;
|
||||||
uniform float u_contrast;
|
uniform float u_contrast;
|
||||||
uniform float u_saturation;
|
uniform float u_saturation;
|
||||||
@@ -96,9 +111,18 @@ export class WebGLAsciiRenderer {
|
|||||||
uniform bool u_invert;
|
uniform bool u_invert;
|
||||||
uniform bool u_color;
|
uniform bool u_color;
|
||||||
uniform float u_overlayStrength;
|
uniform float u_overlayStrength;
|
||||||
uniform bool u_enhanceEdges;
|
uniform int u_edgeMode;
|
||||||
|
uniform float u_dither;
|
||||||
|
uniform bool u_denoise;
|
||||||
|
uniform float u_sharpen;
|
||||||
|
uniform float u_edgeThreshold;
|
||||||
|
uniform float u_shadows;
|
||||||
|
uniform float u_highlights;
|
||||||
|
uniform float u_scanlines;
|
||||||
|
uniform float u_vignette;
|
||||||
|
uniform vec3 u_monoColor;
|
||||||
|
uniform vec3 u_backgroundColor;
|
||||||
|
|
||||||
// Zoom & Magnifier
|
|
||||||
uniform float u_zoom;
|
uniform float u_zoom;
|
||||||
uniform vec2 u_zoomCenter;
|
uniform vec2 u_zoomCenter;
|
||||||
uniform vec2 u_mousePos;
|
uniform vec2 u_mousePos;
|
||||||
@@ -107,74 +131,134 @@ export class WebGLAsciiRenderer {
|
|||||||
uniform bool u_showMagnifier;
|
uniform bool u_showMagnifier;
|
||||||
uniform float u_aspect;
|
uniform float u_aspect;
|
||||||
|
|
||||||
|
float blueNoise(vec2 pos) {
|
||||||
|
vec2 noiseUV = pos / 64.0;
|
||||||
|
float noiseVal = texture2D(u_blueNoise, noiseUV).r;
|
||||||
|
return noiseVal - 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
vec3 adjust(vec3 color) {
|
vec3 adjust(vec3 color) {
|
||||||
// Exposure
|
|
||||||
color *= u_exposure;
|
color *= u_exposure;
|
||||||
|
|
||||||
// Contrast
|
|
||||||
color = (color - 0.5) * u_contrast + 0.5;
|
|
||||||
|
|
||||||
// Saturation
|
|
||||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
if (u_shadows > 0.0) {
|
||||||
|
float shadowFactor = (1.0 - luma) * u_shadows;
|
||||||
|
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
|
||||||
|
}
|
||||||
|
if (u_highlights > 0.0) {
|
||||||
|
float highlightFactor = luma * u_highlights;
|
||||||
|
color = color * (1.0 - highlightFactor * 0.5);
|
||||||
|
}
|
||||||
|
color = (color - 0.5) * u_contrast + 0.5;
|
||||||
|
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
color = mix(vec3(luma), color, u_saturation);
|
color = mix(vec3(luma), color, u_saturation);
|
||||||
|
|
||||||
// Gamma
|
|
||||||
color = pow(max(color, 0.0), vec3(u_gamma));
|
color = pow(max(color, 0.0), vec3(u_gamma));
|
||||||
|
|
||||||
return clamp(color, 0.0, 1.0);
|
return clamp(color, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) {
|
||||||
|
vec3 sum = vec3(0.0);
|
||||||
|
vec2 halfSize = cellSize * 0.25;
|
||||||
|
sum += texture2D(u_image, cellCenterUV).rgb;
|
||||||
|
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, -halfSize.y)).rgb;
|
||||||
|
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, -halfSize.y)).rgb;
|
||||||
|
sum += texture2D(u_image, cellCenterUV + vec2(-halfSize.x, halfSize.y)).rgb;
|
||||||
|
sum += texture2D(u_image, cellCenterUV + vec2(halfSize.x, halfSize.y)).rgb;
|
||||||
|
return sum / 5.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 sobelFilter(vec2 uv, vec2 cellSize) {
|
||||||
|
vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb;
|
||||||
|
vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb;
|
||||||
|
vec3 l = texture2D(u_image, uv + vec2(-cellSize.x, 0.0)).rgb;
|
||||||
|
vec3 r = texture2D(u_image, uv + vec2(cellSize.x, 0.0)).rgb;
|
||||||
|
vec3 tl = texture2D(u_image, uv + vec2(-cellSize.x, -cellSize.y)).rgb;
|
||||||
|
vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb;
|
||||||
|
vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb;
|
||||||
|
vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb;
|
||||||
|
float lt = dot(t, vec3(0.299, 0.587, 0.114));
|
||||||
|
float lb = dot(b, vec3(0.299, 0.587, 0.114));
|
||||||
|
float ll = dot(l, vec3(0.299, 0.587, 0.114));
|
||||||
|
float lr = dot(r, vec3(0.299, 0.587, 0.114));
|
||||||
|
float ltl = dot(tl, vec3(0.299, 0.587, 0.114));
|
||||||
|
float ltr = dot(tr, vec3(0.299, 0.587, 0.114));
|
||||||
|
float lbl = dot(bl, vec3(0.299, 0.587, 0.114));
|
||||||
|
float lbr = dot(br, vec3(0.299, 0.587, 0.114));
|
||||||
|
float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl);
|
||||||
|
float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr);
|
||||||
|
float mag = sqrt(gx*gx + gy*gy);
|
||||||
|
return vec2(mag, atan(gy, gx));
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec2 uv = v_texCoord;
|
vec2 uv = v_texCoord;
|
||||||
|
|
||||||
// Apply global zoom
|
|
||||||
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
||||||
|
|
||||||
// Magnifier logic
|
|
||||||
vec2 diff = (v_texCoord - u_mousePos);
|
vec2 diff = (v_texCoord - u_mousePos);
|
||||||
diff.x *= u_aspect;
|
diff.x *= u_aspect;
|
||||||
float dist = length(diff);
|
float dist = length(diff);
|
||||||
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
|
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
|
||||||
|
|
||||||
if (inMagnifier) {
|
if (inMagnifier) {
|
||||||
// Zoom towards mouse position inside the magnifier
|
|
||||||
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
|
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
|
||||||
// Also account for the global zoom background
|
|
||||||
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate which cell we are in
|
|
||||||
vec2 cellCoords = floor(uv * u_gridSize);
|
vec2 cellCoords = floor(uv * u_gridSize);
|
||||||
vec2 uvInCell = fract(uv * u_gridSize);
|
vec2 uvInCell = fract(uv * u_gridSize);
|
||||||
|
vec2 cellSize = 1.0 / u_gridSize;
|
||||||
|
vec2 sampleUV = (cellCoords + 0.5) * cellSize;
|
||||||
|
|
||||||
// Sample image at the center of the cell
|
|
||||||
vec2 sampleUV = (cellCoords + 0.5) / u_gridSize;
|
|
||||||
|
|
||||||
// Out of bounds check for zoomed UV
|
|
||||||
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
|
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 color = texture2D(u_image, sampleUV).rgb;
|
vec3 color;
|
||||||
|
if (u_denoise) {
|
||||||
// Edge Enhancement (Simple Laplacian-like check)
|
color = getAverageColor(sampleUV, cellSize * 2.0);
|
||||||
if (u_enhanceEdges) {
|
} else {
|
||||||
vec2 texel = 1.0 / u_gridSize;
|
color = getAverageColor(sampleUV, cellSize);
|
||||||
vec3 center = texture2D(u_image, sampleUV).rgb;
|
}
|
||||||
vec3 top = texture2D(u_image, sampleUV + vec2(0.0, -texel.y)).rgb;
|
|
||||||
vec3 bottom = texture2D(u_image, sampleUV + vec2(0.0, texel.y)).rgb;
|
if (u_sharpen > 0.0) {
|
||||||
vec3 left = texture2D(u_image, sampleUV + vec2(-texel.x, 0.0)).rgb;
|
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
|
||||||
vec3 right = texture2D(u_image, sampleUV + vec2(texel.x, 0.0)).rgb;
|
color = color + (color - blurred) * u_sharpen;
|
||||||
|
}
|
||||||
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
|
|
||||||
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
|
if (u_edgeMode == 1) {
|
||||||
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
vec2 texel = cellSize;
|
||||||
|
vec3 center = color;
|
||||||
|
vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize);
|
||||||
|
vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize);
|
||||||
|
vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize);
|
||||||
|
vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize);
|
||||||
|
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
|
||||||
|
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
if (edgeLum > u_edgeThreshold * 0.1) {
|
||||||
|
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
|
||||||
|
}
|
||||||
|
} else if (u_edgeMode == 2) {
|
||||||
|
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||||
|
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
|
||||||
|
if (edgeStr > u_edgeThreshold * 0.2) {
|
||||||
|
color = mix(color, vec3(0.0), edgeStr * 0.8);
|
||||||
|
}
|
||||||
|
} else if (u_edgeMode == 3) {
|
||||||
|
vec2 sobel = sobelFilter(sampleUV, cellSize);
|
||||||
|
float mag = sobel.x;
|
||||||
|
float angle = sobel.y;
|
||||||
|
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
|
||||||
|
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
|
||||||
|
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
|
||||||
|
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) {
|
||||||
|
mag = 0.0;
|
||||||
|
} else {
|
||||||
|
mag = 1.0;
|
||||||
|
}
|
||||||
|
color = mix(color, vec3(0.0), mag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply adjustments
|
|
||||||
color = adjust(color);
|
color = adjust(color);
|
||||||
|
|
||||||
// Overlay blend-like effect (boost mid-contrast)
|
|
||||||
if (u_overlayStrength > 0.0) {
|
if (u_overlayStrength > 0.0) {
|
||||||
vec3 overlay = color;
|
vec3 overlay = color;
|
||||||
vec3 result;
|
vec3 result;
|
||||||
@@ -186,36 +270,62 @@ export class WebGLAsciiRenderer {
|
|||||||
color = mix(color, result, u_overlayStrength);
|
color = mix(color, result, u_overlayStrength);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate luminance
|
|
||||||
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
|
||||||
|
if (u_dither > 0.0) {
|
||||||
|
float noise = blueNoise(cellCoords);
|
||||||
|
luma = luma + noise * (1.0 / u_charCount) * u_dither;
|
||||||
|
luma = clamp(luma, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
if (u_invert) {
|
if (u_invert) {
|
||||||
luma = 1.0 - luma;
|
luma = 1.0 - luma;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map luma to character index
|
|
||||||
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
|
float charIndex = floor(luma * (u_charCount - 1.0) + 0.5);
|
||||||
|
|
||||||
// Sample character atlas
|
|
||||||
vec2 atlasUV = vec2(
|
vec2 atlasUV = vec2(
|
||||||
(charIndex + uvInCell.x) / u_charCount,
|
(charIndex + uvInCell.x) * u_charSizeUV.x,
|
||||||
uvInCell.y
|
uvInCell.y * u_charSizeUV.y
|
||||||
);
|
);
|
||||||
|
|
||||||
float charAlpha = texture2D(u_atlas, atlasUV).r;
|
float charAlpha;
|
||||||
|
${hasDerivatives && hasLod ? `
|
||||||
|
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||||
|
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
|
||||||
|
charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
|
||||||
|
` : `
|
||||||
|
charAlpha = texture2D(u_atlas, atlasUV).r;
|
||||||
|
`}
|
||||||
|
|
||||||
// Loup border effect
|
|
||||||
if (u_showMagnifier) {
|
if (u_showMagnifier) {
|
||||||
float edgeWidth = 0.005;
|
float edgeWidth = 0.005;
|
||||||
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
|
||||||
charAlpha = 1.0;
|
charAlpha = 1.0;
|
||||||
color = vec3(1.0, 0.4039, 0.0); // Safety Orange border for the loupe
|
color = vec3(1.0, 1.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 finalColor = u_color ? color : vec3(1.0, 0.4039, 0.0);
|
if (u_vignette > 0.0) {
|
||||||
|
float d = distance(uv, vec2(0.5));
|
||||||
|
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, d);
|
||||||
|
charAlpha *= vig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (u_scanlines > 0.0) {
|
||||||
|
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
|
||||||
|
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
|
||||||
|
charAlpha *= scanEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 finalColor;
|
||||||
|
if (u_color) {
|
||||||
|
finalColor = color;
|
||||||
|
} else {
|
||||||
|
finalColor = u_monoColor;
|
||||||
|
}
|
||||||
|
|
||||||
gl_FragColor = vec4(finalColor * charAlpha, charAlpha);
|
gl_FragColor = vec4(mix(u_backgroundColor, finalColor, charAlpha), 1.0);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -245,9 +355,12 @@ export class WebGLAsciiRenderer {
|
|||||||
if (!this.program) return;
|
if (!this.program) return;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const uniforms = [
|
const uniforms = [
|
||||||
'u_image', 'u_atlas', 'u_charCount', 'u_gridSize', 'u_texSize',
|
'u_image', 'u_atlas', 'u_blueNoise', 'u_charCount', 'u_charSizeUV', 'u_gridSize', 'u_texSize',
|
||||||
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
'u_exposure', 'u_contrast', 'u_saturation', 'u_gamma',
|
||||||
'u_invert', 'u_color', 'u_overlayStrength', 'u_enhanceEdges',
|
'u_invert', 'u_color', 'u_overlayStrength', 'u_edgeMode',
|
||||||
|
'u_dither', 'u_denoise',
|
||||||
|
'u_sharpen', 'u_edgeThreshold', 'u_shadows', 'u_highlights',
|
||||||
|
'u_scanlines', 'u_vignette', 'u_monoColor', 'u_backgroundColor',
|
||||||
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
'u_zoom', 'u_zoomCenter', 'u_mousePos',
|
||||||
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
'u_magnifierRadius', 'u_magnifierZoom', 'u_showMagnifier', 'u_aspect'
|
||||||
];
|
];
|
||||||
@@ -298,18 +411,30 @@ export class WebGLAsciiRenderer {
|
|||||||
this.fontFamily = fontName;
|
this.fontFamily = fontName;
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const fontSize = 32; // Higher resolution for atlas
|
|
||||||
|
const fontSize = 32;
|
||||||
|
const padding = 4;
|
||||||
|
|
||||||
ctx.font = `${fontSize}px ${fontName}`;
|
ctx.font = `${fontSize}px ${fontName}`;
|
||||||
|
|
||||||
// Measure first char to get dimensions
|
|
||||||
const metrics = ctx.measureText('W');
|
const metrics = ctx.measureText('W');
|
||||||
const charWidth = Math.ceil(metrics.width);
|
const charContentWidth = Math.ceil(metrics.width);
|
||||||
const charHeight = fontSize * 1.2;
|
const charContentHeight = Math.ceil(fontSize * 1.2);
|
||||||
|
|
||||||
canvas.width = charWidth * charSet.length;
|
const charWidth = charContentWidth + padding * 2;
|
||||||
canvas.height = charHeight;
|
const charHeight = charContentHeight + padding * 2;
|
||||||
|
|
||||||
|
const neededWidth = charWidth * charSet.length;
|
||||||
|
const neededHeight = charHeight;
|
||||||
|
|
||||||
|
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
|
||||||
|
const texWidth = nextPowerOfTwo(neededWidth);
|
||||||
|
const texHeight = nextPowerOfTwo(neededHeight);
|
||||||
|
|
||||||
|
canvas.width = texWidth;
|
||||||
|
canvas.height = texHeight;
|
||||||
|
|
||||||
ctx.font = `${fontSize}px ${fontName}`;
|
ctx.font = `${fontSize}px ${fontName}`;
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
@@ -317,7 +442,7 @@ export class WebGLAsciiRenderer {
|
|||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
for (let i = 0; i < charSet.length; i++) {
|
for (let i = 0; i < charSet.length; i++) {
|
||||||
ctx.fillText(charSet[i], i * charWidth, 0);
|
ctx.fillText(charSet[i], i * charWidth + padding, padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
@@ -329,14 +454,16 @@ export class WebGLAsciiRenderer {
|
|||||||
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.generateMipmap(gl.TEXTURE_2D);
|
||||||
|
|
||||||
this.charAtlas = {
|
this.charAtlas = {
|
||||||
width: canvas.width,
|
width: texWidth,
|
||||||
height: canvas.height,
|
height: texHeight,
|
||||||
charWidth,
|
charWidth,
|
||||||
charHeight,
|
charHeight,
|
||||||
count: charSet.length
|
count: charSet.length
|
||||||
@@ -354,12 +481,16 @@ export class WebGLAsciiRenderer {
|
|||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const u = this.uniformLocations;
|
const u = this.uniformLocations;
|
||||||
|
|
||||||
|
if (!this.program) return;
|
||||||
gl.useProgram(this.program);
|
gl.useProgram(this.program);
|
||||||
|
|
||||||
// Update Atlas if needed (expensive check inside)
|
|
||||||
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
|
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
|
||||||
if (this.charAtlas) {
|
if (this.charAtlas) {
|
||||||
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
|
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
|
||||||
|
gl.uniform2f(u['u_charSizeUV'],
|
||||||
|
this.charAtlas.charWidth / this.charAtlas.width,
|
||||||
|
this.charAtlas.charHeight / this.charAtlas.height
|
||||||
|
);
|
||||||
gl.uniform2f(u['u_texSize'], this.charAtlas.width, this.charAtlas.height);
|
gl.uniform2f(u['u_texSize'], this.charAtlas.width, this.charAtlas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,9 +501,28 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.uniform1i(u['u_invert'], options.invert ? 1 : 0);
|
gl.uniform1i(u['u_invert'], options.invert ? 1 : 0);
|
||||||
gl.uniform1i(u['u_color'], options.color ? 1 : 0);
|
gl.uniform1i(u['u_color'], options.color ? 1 : 0);
|
||||||
gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.0);
|
gl.uniform1f(u['u_overlayStrength'], options.overlayStrength || 0.0);
|
||||||
gl.uniform1i(u['u_enhanceEdges'], options.enhanceEdges ? 1 : 0);
|
gl.uniform1i(u['u_edgeMode'], options.edgeMode || 0);
|
||||||
|
gl.uniform1f(u['u_dither'], options.dither || 0.0);
|
||||||
|
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
|
||||||
|
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
|
||||||
|
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5);
|
||||||
|
gl.uniform1f(u['u_shadows'], options.shadows || 0.0);
|
||||||
|
gl.uniform1f(u['u_highlights'], options.highlights || 0.0);
|
||||||
|
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
|
||||||
|
gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
|
||||||
|
|
||||||
|
const hex = options.monoColor || '#ffffff';
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
|
||||||
|
gl.uniform3f(u['u_monoColor'], r, g, b);
|
||||||
|
|
||||||
|
const bgHex = options.backgroundColor || '#000000';
|
||||||
|
const br = parseInt(bgHex.slice(1, 3), 16) / 255.0;
|
||||||
|
const bg = parseInt(bgHex.slice(3, 5), 16) / 255.0;
|
||||||
|
const bb = parseInt(bgHex.slice(5, 7), 16) / 255.0;
|
||||||
|
gl.uniform3f(u['u_backgroundColor'], br, bg, bb);
|
||||||
|
|
||||||
// Zoom & Magnifier
|
|
||||||
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||||
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
||||||
gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
|
gl.uniform2f(u['u_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
|
||||||
@@ -382,16 +532,31 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.uniform1f(u['u_aspect'], gl.canvas.width / gl.canvas.height);
|
gl.uniform1f(u['u_aspect'], gl.canvas.width / gl.canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadBlueNoiseTexture() {
|
||||||
|
const gl = this.gl;
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
if (!texture) return;
|
||||||
|
this.textures.blueNoise = texture;
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.src = '/assets/blue-noise.png';
|
||||||
|
image.onload = () => {
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
updateTexture(image: HTMLImageElement) {
|
updateTexture(image: HTMLImageElement) {
|
||||||
if (this.lastImage === image && this.textures.image) return;
|
if (this.lastImage === image && this.textures.image) return;
|
||||||
|
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
|
|
||||||
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
||||||
const texture = gl.createTexture();
|
const texture = gl.createTexture();
|
||||||
if (!texture) throw new Error('Failed to create texture');
|
if (!texture) throw new Error('Failed to create texture');
|
||||||
this.textures.image = texture;
|
this.textures.image = texture;
|
||||||
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.image);
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||||
@@ -404,16 +569,13 @@ export class WebGLAsciiRenderer {
|
|||||||
draw() {
|
draw() {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const program = this.program;
|
const program = this.program;
|
||||||
|
|
||||||
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
|
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
|
||||||
|
|
||||||
gl.useProgram(program);
|
gl.useProgram(program);
|
||||||
|
|
||||||
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
|
||||||
gl.clearColor(0, 0, 0, 0);
|
gl.clearColor(0, 0, 0, 0);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
// Attributes
|
|
||||||
const posLoc = gl.getAttribLocation(program, 'a_position');
|
const posLoc = gl.getAttribLocation(program, 'a_position');
|
||||||
gl.enableVertexAttribArray(posLoc);
|
gl.enableVertexAttribArray(posLoc);
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
|
||||||
@@ -424,7 +586,6 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
|
||||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
// Bind Textures
|
|
||||||
const u = this.uniformLocations;
|
const u = this.uniformLocations;
|
||||||
gl.uniform1i(u['u_image'], 0);
|
gl.uniform1i(u['u_image'], 0);
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
@@ -434,6 +595,12 @@ export class WebGLAsciiRenderer {
|
|||||||
gl.activeTexture(gl.TEXTURE1);
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
|
||||||
|
|
||||||
|
if (this.textures.blueNoise) {
|
||||||
|
gl.uniform1i(u['u_blueNoise'], 2);
|
||||||
|
gl.activeTexture(gl.TEXTURE2);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.textures.blueNoise);
|
||||||
|
}
|
||||||
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,29 +611,36 @@ export class WebGLAsciiRenderer {
|
|||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept for backward compatibility or specialized updates
|
dispose(): void {
|
||||||
|
const gl = this.gl;
|
||||||
|
if (this.textures.image) gl.deleteTexture(this.textures.image);
|
||||||
|
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
|
||||||
|
if (this.textures.blueNoise) gl.deleteTexture(this.textures.blueNoise);
|
||||||
|
if (this.buffers.position) gl.deleteBuffer(this.buffers.position);
|
||||||
|
if (this.buffers.texCoord) gl.deleteBuffer(this.buffers.texCoord);
|
||||||
|
if (this.program) gl.deleteProgram(this.program);
|
||||||
|
this.textures = {};
|
||||||
|
this.buffers = {};
|
||||||
|
this.program = null;
|
||||||
|
this.charAtlas = null;
|
||||||
|
this.lastImage = null;
|
||||||
|
}
|
||||||
|
|
||||||
updateMagnifier(options: MagnifierOptions) {
|
updateMagnifier(options: MagnifierOptions) {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const program = this.program;
|
const program = this.program;
|
||||||
|
|
||||||
if (!program) return;
|
if (!program) return;
|
||||||
|
|
||||||
gl.useProgram(program);
|
gl.useProgram(program);
|
||||||
|
|
||||||
// Only update magnifier-related uniforms (using cached locations)
|
|
||||||
const u = this.uniformLocations;
|
const u = this.uniformLocations;
|
||||||
const mousePos = options.mousePos ?? { x: -1, y: -1 };
|
const mousePos = options.mousePos ?? { x: -1, y: -1 };
|
||||||
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
|
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
|
||||||
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
|
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
|
||||||
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
|
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
|
||||||
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
|
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
|
||||||
|
|
||||||
if (options.zoom !== undefined) {
|
if (options.zoom !== undefined) {
|
||||||
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
|
||||||
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
gl.uniform2f(u['u_zoomCenter'], options.zoomCenter?.x ?? 0.5, options.zoomCenter?.y ?? 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can just call draw here as it's lightweight
|
|
||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-color: #000000;
|
--bg-color: #000000;
|
||||||
--text-color: #FF6700;
|
--text-color: #FFFFFF;
|
||||||
|
--accent-color: #FF6700;
|
||||||
|
/* Safety Orange */
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,7 @@ button {
|
|||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 103, 0, 0.1);
|
background: color-mix(in srgb, var(--accent-color), transparent 90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["ES2020", "DOM"],
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -16,6 +19,11 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "dist"]
|
"src/**/*"
|
||||||
}
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user