refactor(page): migrate to new modular architecture

This commit is contained in:
syntaxbullet
2026-02-09 22:34:14 +01:00
parent 9b9976c70a
commit a137a98377
2 changed files with 102 additions and 1251 deletions

View File

@@ -4,13 +4,14 @@ import TuiSlider from "../components/TuiSlider.astro";
import TuiSegment from "../components/TuiSegment.astro";
import TuiToggle from "../components/TuiToggle.astro";
import TuiButton from "../components/TuiButton.astro";
import Tooltip from "../components/Tooltip.astro";
---
<Layout title="Neko ASCII Auto-Generator">
<Layout title="Syntaxbullet - Digital Wizard">
<div class="hero-wrapper">
<!-- Background Layer: ASCII Art -->
<div class="ascii-layer">
<div id="loading">GENERATING...</div>
<div id="loading">Loading...</div>
<pre id="ascii-result">Preparing art...</pre>
<canvas id="ascii-canvas"></canvas>
</div>
@@ -20,9 +21,10 @@ import TuiButton from "../components/TuiButton.astro";
<div class="max-w-container">
<main class="hero-content">
<div class="hero-text">
<h2>AUTOMATED<br />ASCII<br />SYNTHESIS</h2>
<h2>SYNTAXBULLET</h2>
<p class="tagline">
Real-time image-to-text conversion engine.
Self-taught Munich-based software engineer
passionate about Generative AI, Linux, and the Web.
</p>
</div>
</main>
@@ -38,54 +40,70 @@ import TuiButton from "../components/TuiButton.astro";
label="EXP"
min={0}
max={3}
step={0.1}
step={0.01}
value={1.0}
title="Exposure / Brightness"
description="Adjusts the overall brightness level of the input image before processing."
/>
<TuiSlider
id="contrast"
label="CON"
min={0}
max={3}
step={0.1}
step={0.01}
value={1.0}
title="Contrast"
description="Increases or decreases the difference between light and dark areas."
/>
<TuiSlider
id="saturation"
label="SAT"
min={0}
max={3}
step={0.1}
step={0.01}
value={1.2}
title="Saturation"
description="Controls color intensity. Higher values make colors more vibrant in Color Mode."
/>
<TuiSlider
id="gamma"
label="GAM"
min={0}
max={3}
step={0.1}
step={0.01}
value={1.0}
title="Gamma Correction"
description="Non-linear brightness adjustment. useful for correcting washed-out or too dark images."
/>
<TuiSlider
id="overlayStrength"
label="OVL"
min={0}
max={1}
step={0.1}
step={0.01}
value={0.3}
title="Overlay Blend Strength"
description="Blends the original image over the ASCII output. 0 is pure ASCII, 1 is original image."
/>
<TuiSlider
id="resolution"
label="RES"
min={0.5}
min={0.1}
max={2}
step={0.1}
step={0.01}
value={1.0}
title="Resolution Scale"
description="Adjusts the density of characters. Higher values give more detail but may reduce performance."
/>
<TuiSlider
id="dither"
label="DTH"
min={0}
max={1}
step={0.01}
value={0}
title="Dither Strength"
description="Applies ordered dithering to simulate shading. Useful for low-contrast areas."
/>
</div>
</div>
@@ -101,21 +119,14 @@ import TuiButton from "../components/TuiButton.astro";
id="toggle-color"
label="CLR"
title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans."
/>
<TuiToggle
id="toggle-dither"
label="DTH"
title="Floyd-Steinberg Dithering"
/>
<TuiToggle
id="toggle-denoise"
label="DNZ"
title="Denoise Pre-processing"
/>
<TuiToggle
id="toggle-edges"
label="EDG"
title="Edge Enhancement"
description="Applies a bilateral filter to reduce image noise while preserving edges."
/>
</div>
@@ -132,6 +143,15 @@ import TuiButton from "../components/TuiButton.astro";
options={["AUTO", "ON", "OFF"]}
value="AUTO"
title="Invert Colors"
description="Inverts brightness mapping. AUTO detects dark/light mode."
/>
<TuiSegment
id="segment-edge"
label="EDG"
options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF"
title="Edge Detection Mode"
description="Algorithm used to detect edges. SPL: Simple, SOB: Sobel, CNY: Canny."
/>
<TuiSegment
id="segment-charset"
@@ -146,6 +166,7 @@ import TuiButton from "../components/TuiButton.astro";
]}
value="STD"
title="Character Set"
description="The set of characters used for mapping brightness levels."
/>
</div>
</div>
@@ -162,17 +183,21 @@ import TuiButton from "../components/TuiButton.astro";
label="RESET"
shortcut="R"
title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values."
/>
<TuiButton
id="btn-next"
label="NEXT"
shortcut="N"
variant="primary"
title="Load Next Image"
description="Discards current image and loads a new one from the queue."
/>
<div
class="queue-display"
title="Buffered Images"
data-tooltip-title="Buffered Images"
data-tooltip-desc="Number of images pre-loaded in background queue."
>
<span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value"
@@ -199,76 +224,25 @@ import TuiButton from "../components/TuiButton.astro";
</div>
<script>
import {
AsciiGenerator,
autoTuneImage,
CHAR_SETS,
} from "../scripts/ascii.js";
import {
fetchRandomAnimeImage,
loadSingleImage,
} from "../scripts/anime-api.js";
import { WebGLAsciiRenderer } from "../scripts/webgl-ascii.js";
import { AsciiController } from "../scripts/ascii-controller";
import { ImageQueue } from "../scripts/image-queue";
import { UIBindings } from "../scripts/ui-bindings";
const generator = new AsciiGenerator();
// ============= Global Cleanup Protocol =============
// Fix for accumulating event listeners and render loops during HMR/Navigation
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);
}
}
// ============= DOM Elements =============
const canvas = document.getElementById(
"ascii-canvas",
) as HTMLCanvasElement;
let webglRenderer: WebGLAsciiRenderer | null = null;
let isWebGLAvailable = false;
try {
webglRenderer = new WebGLAsciiRenderer(canvas);
isWebGLAvailable = true;
} catch (e) {
console.warn(
"WebGL renderer failed to initialize, falling back to CPU",
e,
);
isWebGLAvailable = false;
}
// State
let currentImgUrl: string | null = null;
let currentSettings: Record<string, any> = {
exposure: 1.0,
contrast: 1.0,
saturation: 1.2,
gamma: 1.0,
invert: false,
color: false,
dither: false,
denoise: false,
enhanceEdges: false,
overlayStrength: 0.3,
resolution: 1.0,
charSet: "standard",
};
let invertMode = "auto"; // 'auto', 'on', 'off'
let detectedInvert = false;
let detectedSettings: any = {}; // Store auto-detected settings
// Render Loop State
let dirtyTexture = false;
let dirtyGrid = false;
let dirtyUniforms = false;
// Cache for grid calculations
let cachedGrid: {
widthCols: number;
heightRows: number;
imgEl: HTMLImageElement | null;
} = {
widthCols: 0,
heightRows: 0,
imgEl: null,
};
// Debounce for CPU render
let cpuRenderTimeout: number | undefined;
// DOM Elements
const asciiResult = document.getElementById(
"ascii-result",
) as HTMLPreElement;
@@ -276,464 +250,57 @@ import TuiButton from "../components/TuiButton.astro";
"loading",
) as HTMLDivElement;
if (!asciiResult || !loadingIndicator || !canvas) {
if (!canvas || !asciiResult || !loadingIndicator) {
throw new Error("Critical UI elements missing");
}
// Charset key mapping (short to full)
const charSetKeyMap: Record<string, string> = {
STD: "standard",
EXT: "extended",
BLK: "blocks",
MIN: "minimal",
DOT: "dots",
SHP: "shapes",
};
const charSetReverseMap: Record<string, string> = Object.fromEntries(
Object.entries(charSetKeyMap).map(([k, v]) => [v, k]),
// ============= Initialize =============
const controller = new AsciiController(
canvas,
asciiResult,
loadingIndicator,
);
const queue = new ImageQueue(2);
const ui = new UIBindings(controller, queue, loadNewImage);
// Update UI to reflect current settings using new components
function updateUI() {
// Update sliders
const sliderIds = [
"exposure",
"contrast",
"saturation",
"gamma",
"overlayStrength",
"resolution",
];
sliderIds.forEach((id) => {
const input = document.getElementById(id) as HTMLInputElement;
// removed unused valueDisplay
if (input && currentSettings[id] !== undefined) {
input.value = String(currentSettings[id]);
input.dispatchEvent(new Event("input"));
}
});
// Store instances globally for cleanup
window.__ASCII_APP__ = {
controller,
queue,
ui,
dispose: () => {
controller.dispose();
ui.dispose();
queue.dispose();
window.__ASCII_APP__ = undefined;
},
};
// Update toggles
(window as any).updateToggleState?.(
"toggle-color",
currentSettings.color,
);
(window as any).updateToggleState?.(
"toggle-dither",
currentSettings.dither,
);
(window as any).updateToggleState?.(
"toggle-denoise",
currentSettings.denoise,
);
(window as any).updateToggleState?.(
"toggle-edges",
currentSettings.enhanceEdges,
);
// Update segments
const invertValue =
invertMode === "auto"
? "AUTO"
: currentSettings.invert
? "ON"
: "OFF";
(window as any).updateSegmentValue?.("segment-invert", invertValue);
const charSetShort =
charSetReverseMap[currentSettings.charSet] || "STD";
(window as any).updateSegmentValue?.(
"segment-charset",
charSetShort,
);
// Update queue status
updateQueueStatus();
}
function updateQueueStatus() {
const queueEl = document.getElementById("val-queue");
if (queueEl) {
queueEl.textContent = imageQueue.length.toString();
}
}
function resetToAutoSettings() {
if (Object.keys(detectedSettings).length > 0) {
invertMode = "auto";
detectedInvert = detectedSettings.invert ?? false;
currentSettings = {
...currentSettings,
...detectedSettings,
resolution: currentSettings.resolution, // Keep resolution
color: false, // Reset color to off
};
currentSettings.invert = detectedInvert;
updateUI();
// Full update
calculateGrid().then(() => {
requestRender("all");
});
}
}
function requestRender(type: "texture" | "grid" | "uniforms" | "all") {
if (!isWebGLAvailable) {
// For CPU, we just debounce a full render
clearTimeout(cpuRenderTimeout);
cpuRenderTimeout = window.setTimeout(() => generateCPU(), 50);
return;
}
if (type === "all") {
dirtyTexture = true;
dirtyGrid = true;
dirtyUniforms = true;
} else if (type === "texture") dirtyTexture = true;
else if (type === "grid") dirtyGrid = true;
else if (type === "uniforms") dirtyUniforms = true;
}
async function calculateGrid() {
if (!currentImgUrl) return;
// Dynamic sizing logic to fit screen
const fontAspectRatio = 0.55;
const marginRatio = 0.2;
const screenW = window.innerWidth;
// Available space
const availW = screenW * (1 - marginRatio);
let widthCols = Math.floor(availW / 6); // Assuming ~6px char width
// Apply resolution scaling
widthCols = Math.floor(widthCols * currentSettings.resolution);
if (widthCols > 300) widthCols = 300; // Cap to prevent crashing
if (widthCols < 40) widthCols = 40;
const imgEl = await resolveImage(currentImgUrl);
const imgRatio = imgEl.width / imgEl.height;
const heightRows = widthCols / (imgRatio / fontAspectRatio);
cachedGrid = {
widthCols,
heightRows,
imgEl,
};
return cachedGrid;
}
function renderLoop() {
if (isWebGLAvailable && webglRenderer && cachedGrid.imgEl) {
const charSetContent =
CHAR_SETS[
currentSettings.charSet as keyof typeof CHAR_SETS
] || CHAR_SETS.standard;
// Only act if dirty
if (dirtyTexture || dirtyGrid || dirtyUniforms) {
if (dirtyTexture) {
webglRenderer.updateTexture(cachedGrid.imgEl);
}
if (dirtyGrid) {
// Recalculate canvas size for WebGL
const fontAspectRatio = 0.55;
const gridAspect =
(cachedGrid.widthCols * fontAspectRatio) /
cachedGrid.heightRows;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const maxW = screenW * 0.95;
const maxH = screenH * 0.95;
let finalW, finalH;
if (gridAspect > maxW / maxH) {
finalW = maxW;
finalH = maxW / gridAspect;
} else {
finalH = maxH;
finalW = maxH * gridAspect;
}
canvas.style.width = `${finalW}px`;
canvas.style.height = `${finalH}px`;
const dpr = window.devicePixelRatio || 1;
canvas.width = finalW * dpr;
canvas.height = finalH * dpr;
webglRenderer.updateGrid(
cachedGrid.widthCols,
Math.floor(cachedGrid.heightRows),
);
}
if (dirtyUniforms || dirtyGrid) {
// Uniforms often depend on grid/atlas state
webglRenderer.updateUniforms({
width: cachedGrid.widthCols,
height: Math.floor(cachedGrid.heightRows),
charSetContent: charSetContent,
...currentSettings,
dither: currentSettings.dither,
denoise: currentSettings.denoise,
// WebGLAsciiRenderer handles Defaults for zoom/magnifier if undefined
zoom: zoom,
zoomCenter: zoomCenter,
mousePos: mousePos,
showMagnifier: showMagnifier,
magnifierRadius: 0.15,
magnifierZoom: 2.5,
} as any);
}
webglRenderer.draw();
dirtyTexture = false;
dirtyGrid = false;
dirtyUniforms = false;
}
}
requestAnimationFrame(renderLoop);
}
// Start the loop
requestAnimationFrame(renderLoop);
async function generateCPU() {
if (!cachedGrid.imgEl) await calculateGrid();
if (!cachedGrid.imgEl) return;
canvas.style.display = "none";
asciiResult.style.display = "block";
try {
const result = await generator.generate(cachedGrid.imgEl, {
width: cachedGrid.widthCols,
height: Math.floor(cachedGrid.heightRows),
...currentSettings,
});
// Handle color output (returns object) vs plain text (returns string)
if (typeof result === "object" && result.isHtml) {
asciiResult.innerHTML = result.output;
} else {
asciiResult.textContent = result as string;
}
// Auto-fit font size
const fontAspectRatio = 0.55;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const sizeW =
(screenW * 0.9) / (cachedGrid.widthCols * fontAspectRatio);
const sizeH = (screenH * 0.9) / cachedGrid.heightRows;
const bestSize = Math.min(sizeW, sizeH);
asciiResult.style.fontSize = `${Math.max(4, bestSize).toFixed(2)}px`;
asciiResult.style.opacity = "1";
} catch (e) {
console.error("Render error", e);
}
}
async function generate() {
// Legacy wrapper to kick off a render (used by resize listener/init)
await calculateGrid();
if (isWebGLAvailable) {
asciiResult.style.display = "none";
canvas.style.display = "block";
canvas.style.opacity = "1";
requestRender("all");
} else {
generateCPU();
}
}
// Zoom & Magnifier State
let zoom = 1.0;
let zoomCenter = { x: 0.5, y: 0.5 };
let mousePos = { x: -1, y: -1 };
let showMagnifier = false;
const heroWrapper = document.querySelector(".hero-wrapper");
if (heroWrapper) {
heroWrapper.addEventListener(
"wheel",
(e: any) => {
// If over controls, don't zoom
if (e.target.closest("#tui-controls")) return;
// Only zoom if using WebGL (as CPU version doesn't support it yet)
if (webglRenderer) {
e.preventDefault();
const delta = -e.deltaY;
const factor = delta > 0 ? 1.1 : 0.9;
const oldZoom = zoom;
zoom *= factor;
// Cap zoom
zoom = Math.min(Math.max(zoom, 1.0), 10.0);
if (zoom === 1.0) {
zoomCenter = { x: 0.5, y: 0.5 };
} else if (oldZoom !== zoom) {
// Calculate where the mouse is relative to the canvas
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
// To zoom into the mouse, we want the image coordinate under the mouse to stay fixed.
// Shader formula: uv = (v_texCoord - C) / Z + C
// We want: (mx - C1) / Z1 + C1 == (mx - C2) / Z2 + C2
const imgX =
(mx - zoomCenter.x) / oldZoom + zoomCenter.x;
const imgY =
(my - zoomCenter.y) / oldZoom + zoomCenter.y;
// Solve for C2: K = (mx - C2) / Z2 + C2 => C2 = (K - mx/Z2) / (1 - 1/Z2)
zoomCenter.x = (imgX - mx / zoom) / (1 - 1 / zoom);
zoomCenter.y = (imgY - my / zoom) / (1 - 1 / zoom);
}
requestRender("uniforms");
}
},
{ passive: false },
);
let magnifierTimeout: ReturnType<typeof setTimeout> | undefined;
heroWrapper.addEventListener("mousemove", (e: any) => {
if (webglRenderer) {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
mousePos = { x: mx, y: my };
// Show magnifier if mouse is over canvas
const wasShowing = showMagnifier;
showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
if (showMagnifier || wasShowing) {
requestRender("uniforms");
}
}
});
heroWrapper.addEventListener("mouseleave", () => {
if (showMagnifier) {
showMagnifier = false;
requestRender("uniforms");
}
});
}
// Queue System
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
let isFetchingNext = false;
const MAX_QUEUE_SIZE = 2;
async function prefetchNext() {
if (isFetchingNext || imageQueue.length >= MAX_QUEUE_SIZE) return;
if (document.hidden) return;
isFetchingNext = true;
try {
const data = await fetchRandomAnimeImage();
loadingIndicator.style.display = "block";
asciiResult.textContent = `FETCHING... (${imageQueue.length + 1}/${MAX_QUEUE_SIZE})`;
asciiResult.style.opacity = "0.5";
const img = await loadSingleImage(data.url);
imageQueue.push({ data, imgElement: img });
updateQueueStatus();
loadingIndicator.style.display = "none";
} catch (e) {
console.error("Failed to prefetch image:", e);
loadingIndicator.style.display = "none";
} finally {
isFetchingNext = false;
}
}
async function ensureQueueFilled() {
while (imageQueue.length < MAX_QUEUE_SIZE) {
await prefetchNext();
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
// Link settings updates to UI sync
controller.onSettingsChanged(() => ui.updateUI());
let retryCount = 0;
const MAX_RETRIES = 3;
async function loadNewImage() {
// ============= Image Loading =============
async function loadNewImage(): Promise<void> {
try {
let suggestions: any;
let item;
// If queue is empty, show loading and wait for fetch
if (imageQueue.length === 0) {
loadingIndicator.style.display = "block";
asciiResult.textContent = "FETCHING...";
asciiResult.style.opacity = "0.5";
const data = await fetchRandomAnimeImage();
const img = await loadSingleImage(data.url);
currentImgUrl = data.url;
suggestions = autoTuneImage(img, data.meta);
loadingIndicator.style.display = "none";
if (queue.getLength() === 0) {
controller.showLoading("FETCHING...");
item = await queue.fetchDirect();
} else {
// Pop from queue
const nextItem = imageQueue.shift()!;
currentImgUrl = nextItem.data.url;
suggestions = autoTuneImage(
nextItem.imgElement as HTMLImageElement,
nextItem.data.meta,
);
// Trigger refill in background
ensureQueueFilled();
item = queue.pop()!;
queue.ensureFilled(); // Background refill
}
// Reset zoom on new image
zoom = 1.0;
zoomCenter = { x: 0.5, y: 0.5 };
controller.setCurrentImage(item.url, item.suggestions);
retryCount = 0;
// Reset auto mode and apply auto-detected settings
invertMode = "auto";
detectedInvert = suggestions.invert;
detectedSettings = suggestions;
currentSettings = {
...currentSettings,
...suggestions,
// Keep resolution as is
resolution: currentSettings.resolution,
// Keep manual toggles if they were set
color: currentSettings.color,
};
currentSettings.invert = detectedInvert;
retryCount = 0; // Reset retries on success
updateUI();
await generate();
loadingIndicator.style.display = "none";
asciiResult.style.opacity = "1";
ui.updateUI();
await controller.generate();
controller.hideLoading();
} catch (e) {
console.error(e);
if (retryCount < MAX_RETRIES) {
@@ -742,188 +309,18 @@ import TuiButton from "../components/TuiButton.astro";
setTimeout(loadNewImage, 2000);
} else {
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
loadingIndicator.style.display = "none";
controller.hideLoading();
}
}
}
function resolveImage(src: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
}
// ============= NEW COMPONENT EVENT LISTENERS =============
// Slider change events
const sliderIds = [
"exposure",
"contrast",
"saturation",
"gamma",
"overlayStrength",
"resolution",
];
sliderIds.forEach((id) => {
const input = document.getElementById(id) as HTMLInputElement;
if (input) {
input.addEventListener("input", () => {
currentSettings[id] = parseFloat(input.value);
if (id === "resolution") {
calculateGrid().then(() => requestRender("grid"));
} else {
requestRender("uniforms");
}
});
}
});
// Toggle change events - use event delegation to catch events from dynamically initialized toggles
document.body.addEventListener("toggle-change", (e: any) => {
const target = e.target as HTMLElement;
if (!target) return;
const toggleId = target.id;
const checked = e.detail?.checked;
switch (toggleId) {
case "toggle-color":
currentSettings.color = checked;
requestRender("uniforms");
break;
case "toggle-dither":
currentSettings.dither = checked;
requestRender("uniforms");
break;
case "toggle-denoise":
currentSettings.denoise = checked;
requestRender("uniforms");
break;
case "toggle-edges":
currentSettings.enhanceEdges = checked;
requestRender("uniforms");
break;
}
});
// Segment change events
document
.getElementById("segment-invert")
?.addEventListener("segment-change", (e: any) => {
const value = e.detail.value;
if (value === "AUTO") {
invertMode = "auto";
currentSettings.invert = detectedInvert;
} else if (value === "ON") {
invertMode = "on";
currentSettings.invert = true;
} else {
invertMode = "off";
currentSettings.invert = false;
}
requestRender("uniforms");
});
document
.getElementById("segment-charset")
?.addEventListener("segment-change", (e: any) => {
const shortKey = e.detail.value;
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
requestRender("uniforms"); // Charset update uses updateUniforms -> updateAtlas
});
// Action button events
document.getElementById("btn-reset")?.addEventListener("click", (e) => {
e.stopPropagation();
resetToAutoSettings();
});
document.getElementById("btn-next")?.addEventListener("click", (e) => {
e.stopPropagation();
loadNewImage();
});
// Keyboard shortcuts
document.addEventListener("keydown", (e: KeyboardEvent) => {
// Ignore if user is typing in an input
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key.toLowerCase()) {
case "n": // Next image
loadNewImage();
break;
case "r": // Reset
resetToAutoSettings();
break;
case "i": // Cycle invert (AUTO -> ON -> OFF -> AUTO)
{
// removed unused invertSegment declaration
if (invertMode === "auto") {
invertMode = "on";
currentSettings.invert = true;
} else if (invertMode === "on") {
invertMode = "off";
currentSettings.invert = false;
} else {
invertMode = "auto";
currentSettings.invert = detectedInvert;
}
updateUI();
requestRender("uniforms");
}
break;
case "c": // Toggle color
currentSettings.color = !currentSettings.color;
updateUI();
requestRender("uniforms");
break;
case "d": // Toggle dither
currentSettings.dither = !currentSettings.dither;
updateUI();
requestRender("uniforms");
break;
case "e": // Toggle edges
currentSettings.enhanceEdges =
!currentSettings.enhanceEdges;
updateUI();
requestRender("uniforms");
break;
case "s": // Cycle charset
{
const keys = Object.keys(CHAR_SETS);
const idx = keys.indexOf(currentSettings.charSet);
const nextIdx = (idx + 1) % keys.length;
currentSettings.charSet = keys[nextIdx];
updateUI();
requestRender("uniforms");
}
break;
}
});
// Resize handler
let resizeTimeout: ReturnType<typeof setTimeout> | undefined;
window.addEventListener("resize", () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(generate, 200);
});
// Periodic queue status update
setInterval(updateQueueStatus, 1000);
// Init
// ============= Initialize UI and Load First Image =============
ui.init();
loadNewImage().then(() => {
ensureQueueFilled(); // Start filling queue after first load
queue.ensureFilled();
});
</script>
<Tooltip />
</Layout>
<style>