Files
personal-website/src/pages/index.astro
syntaxbullet bfefaa0055 Initial commit
2026-02-09 12:54:10 +01:00

1005 lines
33 KiB
Plaintext

---
import Layout from "../layouts/Layout.astro";
import TuiSlider from "../components/TuiSlider.astro";
import TuiSegment from "../components/TuiSegment.astro";
import TuiToggle from "../components/TuiToggle.astro";
import TuiButton from "../components/TuiButton.astro";
---
<Layout title="Neko ASCII Auto-Generator">
<div class="hero-wrapper">
<!-- Background Layer: ASCII Art -->
<div class="ascii-layer">
<div id="loading">GENERATING...</div>
<pre id="ascii-result">Preparing art...</pre>
</div>
<!-- Foreground Layer: Content -->
<div class="content-layer">
<div class="max-w-container">
<main class="hero-content">
<div class="hero-text">
<h2>AUTOMATED<br />ASCII<br />SYNTHESIS</h2>
<p class="tagline">
Real-time image-to-text conversion engine.
</p>
</div>
</main>
<footer class="controls-footer">
<div id="tui-controls">
<!-- Sliders Section -->
<div class="control-panel-section sliders-section">
<div class="section-header">ADJUSTMENTS</div>
<div class="sliders-grid">
<TuiSlider
id="exposure"
label="EXP"
min={0}
max={3}
step={0.1}
value={1.0}
title="Exposure / Brightness"
/>
<TuiSlider
id="contrast"
label="CON"
min={0}
max={3}
step={0.1}
value={1.0}
title="Contrast"
/>
<TuiSlider
id="saturation"
label="SAT"
min={0}
max={3}
step={0.1}
value={1.2}
title="Saturation"
/>
<TuiSlider
id="gamma"
label="GAM"
min={0}
max={3}
step={0.1}
value={1.0}
title="Gamma Correction"
/>
<TuiSlider
id="overlayStrength"
label="OVL"
min={0}
max={1}
step={0.1}
value={0.3}
title="Overlay Blend Strength"
/>
<TuiSlider
id="resolution"
label="RES"
min={0.5}
max={2}
step={0.1}
value={1.0}
title="Resolution Scale"
/>
</div>
</div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Toggles & Segments Section -->
<div class="control-panel-section toggles-section">
<div class="section-header">EFFECTS</div>
<div class="toggles-row">
<TuiToggle
id="toggle-color"
label="CLR"
title="Color Output (HTML)"
/>
<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"
/>
</div>
<div
class="section-header"
style="margin-top: 8px;"
>
OUTPUT
</div>
<div class="segments-row">
<TuiSegment
id="segment-invert"
label="INV"
options={["AUTO", "ON", "OFF"]}
value="AUTO"
title="Invert Colors"
/>
<TuiSegment
id="segment-charset"
label="SET"
options={[
"STD",
"EXT",
"BLK",
"MIN",
"DOT",
"SHP",
]}
value="STD"
title="Character Set"
/>
</div>
</div>
<!-- Divider -->
<div class="control-panel-divider"></div>
<!-- Actions Section -->
<div class="control-panel-section actions-section">
<div class="section-header">ACTIONS</div>
<div class="actions-row">
<TuiButton
id="btn-reset"
label="RESET"
shortcut="R"
title="Reset to Auto-detected Settings"
/>
<TuiButton
id="btn-next"
label="NEXT"
shortcut="N"
variant="primary"
title="Load Next Image"
/>
<div
class="queue-display"
title="Buffered Images"
>
<span class="queue-label">Q:</span>
<span id="val-queue" class="queue-value"
>0</span
>
</div>
</div>
</div>
</div>
<!-- Keyboard shortcuts hint -->
<div class="shortcuts-hint">
<span><kbd>N</kbd> Next</span>
<span><kbd>R</kbd> Reset</span>
<span><kbd>I</kbd> Invert</span>
<span><kbd>C</kbd> Color</span>
<span><kbd>D</kbd> Dither</span>
<span><kbd>E</kbd> Edges</span>
<span><kbd>S</kbd> Charset</span>
</div>
</footer>
</div>
</div>
</div>
<script>
import {
AsciiGenerator,
autoTuneImage,
CHAR_SETS,
} from "../scripts/ascii.js";
import { fetchRandomAnimeImage } from "../scripts/anime-api.js";
const generator = new AsciiGenerator();
// State
let currentImgUrl: string | null = null;
let currentSettings = {
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
// DOM Elements
const asciiResult = document.getElementById(
"ascii-result",
) as HTMLPreElement;
const loadingIndicator = document.getElementById(
"loading",
) as HTMLDivElement;
if (!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]),
);
// 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;
const valueDisplay = document.getElementById(`val-${id}`);
if (input && currentSettings[id] !== undefined) {
input.value = String(currentSettings[id]);
input.dispatchEvent(new Event("input"));
}
});
// 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();
generate();
}
}
async function generate() {
if (!currentImgUrl) return;
// Dynamic sizing logic to fit screen
const fontAspectRatio = 0.55;
const marginRatio = 0.2;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
// Available space
const availW = screenW * (1 - marginRatio);
// We need to determine optimal Width (cols) and Font Size (px)
let widthCols = screenW > 1000 ? 200 : 100; // Base resolution
// Calculate resulting height
const imgEl = await resolveImage(currentImgUrl);
const imgRatio = imgEl.width / imgEl.height;
let heightRows = widthCols / (imgRatio / fontAspectRatio);
// Refined sizing logic (legacy-inspired)
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;
heightRows = widthCols / (imgRatio / fontAspectRatio);
try {
const result = await generator.generate(imgEl, {
width: widthCols,
height: Math.floor(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 sizeW = (screenW * 0.9) / (widthCols * fontAspectRatio);
const sizeH = (screenH * 0.9) / 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);
}
}
// Queue System
const imageQueue: { data: any; imgElement: HTMLImageElement }[] = [];
const TARGET_QUEUE_SIZE = 3;
let isFetchingQueue = false;
async function fillQueue() {
if (isFetchingQueue || imageQueue.length >= TARGET_QUEUE_SIZE)
return;
// Prevent filling if document is hidden to save bandwidth
if (document.hidden) return;
isFetchingQueue = true;
try {
// Fetch one by one until full
while (imageQueue.length < TARGET_QUEUE_SIZE) {
const data = await fetchRandomAnimeImage();
// Preload
const img = await resolveImage(data.url);
// Store
imageQueue.push({
data: data,
imgElement: img,
});
// Brief pause to be nice to API
await new Promise((r) => setTimeout(r, 500));
}
} catch (e) {
console.error("Queue fill error", e);
} finally {
isFetchingQueue = false;
}
}
let retryCount = 0;
const MAX_RETRIES = 3;
async function loadNewImage() {
try {
let suggestions: any;
// If queue is empty, show loading and wait for fetch
if (imageQueue.length === 0) {
loadingIndicator.style.display = "block";
asciiResult.style.opacity = "0.3";
const data = await fetchRandomAnimeImage();
const img = await resolveImage(data.url);
currentImgUrl = data.url;
suggestions = autoTuneImage(img, data.meta);
} 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
fillQueue();
}
// 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";
} catch (e) {
console.error(e);
if (retryCount < MAX_RETRIES) {
retryCount++;
asciiResult.textContent = `SIGNAL LOST. RETRYING (${retryCount}/${MAX_RETRIES})...`;
setTimeout(loadNewImage, 2000);
} else {
asciiResult.textContent = "SIGNAL LOST. PLEASE REFRESH.";
loadingIndicator.style.display = "none";
}
}
}
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);
generate();
});
}
});
// 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;
generate();
break;
case "toggle-dither":
currentSettings.dither = checked;
generate();
break;
case "toggle-denoise":
currentSettings.denoise = checked;
generate();
break;
case "toggle-edges":
currentSettings.enhanceEdges = checked;
generate();
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;
}
generate();
});
document
.getElementById("segment-charset")
?.addEventListener("segment-change", (e: any) => {
const shortKey = e.detail.value;
currentSettings.charSet = charSetKeyMap[shortKey] || "standard";
generate();
});
// 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)
{
const invertSegment =
document.getElementById("segment-invert");
if (invertMode === "auto") {
invertMode = "on";
currentSettings.invert = true;
} else if (invertMode === "on") {
invertMode = "off";
currentSettings.invert = false;
} else {
invertMode = "auto";
currentSettings.invert = detectedInvert;
}
updateUI();
generate();
}
break;
case "c": // Toggle color
currentSettings.color = !currentSettings.color;
updateUI();
generate();
break;
case "d": // Toggle dither
currentSettings.dither = !currentSettings.dither;
updateUI();
generate();
break;
case "e": // Toggle edges
currentSettings.enhanceEdges =
!currentSettings.enhanceEdges;
updateUI();
generate();
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();
generate();
}
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
loadNewImage().then(() => {
fillQueue(); // Start filling queue after first load
});
</script>
</Layout>
<style>
/* Layout Wrapper */
.hero-wrapper {
position: relative;
width: 100vw;
height: 100%;
overflow: hidden;
}
/* BACKGROUND LAYER */
.ascii-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0; /* Behind everything */
display: flex; /* Original centering logic */
justify-content: center;
align-items: center;
pointer-events: none; /* Let clicks pass through to body/script */
opacity: 0.6; /* Slight fade to let text pop */
transition: opacity 0.5s ease;
}
#ascii-result {
font-size: 8px; /* Dynamic but starts here */
line-height: 1;
white-space: pre;
color: var(--text-color);
/* If specific alignment needed for hero: */
transform-origin: center;
}
#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;
}
/* FOREGROUND LAYER */
.content-layer {
position: relative;
z-index: 1; /* Above ASCII */
width: 100%;
height: 100%;
background: radial-gradient(
circle at center,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.4) 80%
); /* Subtle vignette */
pointer-events: none; /* Let clicks pass for image regen */
}
.max-w-container {
width: 100%;
max-width: 1440px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Make interactive elements clickable again */
.site-header,
.controls-footer,
.hero-text {
pointer-events: auto;
}
/* Don't block clicks on pure layout areas if they are empty,
but hero-text might block image regen click if big.
Let's keep hero-text pointer-events auto only on text selection?
Actually user wants click anywhere to regen.
Let's make sure 'a' and 'button' are clickable, anything else triggers regen via body listener. */
.hero-text {
pointer-events: none; /* Let clicks through text area */
}
/* Center Hero Text */
.hero-content {
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
}
.hero-text h2 {
font-size: clamp(3rem, 8vw, 6rem);
line-height: 0.9;
margin: 0;
font-weight: 900;
opacity: 0.9;
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
mix-blend-mode: difference; /* Cool effect over ASCII */
color: #fff; /* Make it pop against the blend */
}
.tagline {
margin-top: 1rem;
font-size: 1.2rem;
opacity: 0.8;
max-width: 400px;
background: rgba(0, 0, 0, 0.6);
padding: 5px;
}
/* Footer / Controls */
.controls-footer {
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
position: relative;
}
#tui-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 0;
background: rgba(0, 0, 0, 0.95);
border: 1px solid var(--text-color);
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
max-width: 100%;
}
.control-panel-section {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.control-panel-divider {
width: 1px;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(255, 103, 0, 0.3) 20%,
rgba(255, 103, 0, 0.3) 80%,
transparent 100%
);
align-self: stretch;
}
.section-header {
font-size: 9px;
font-weight: bold;
opacity: 0.5;
letter-spacing: 2px;
margin-bottom: 4px;
text-transform: uppercase;
}
.sliders-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 16px;
}
.toggles-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.segments-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.actions-row {
display: flex;
align-items: center;
gap: 10px;
}
.queue-display {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
opacity: 0.6;
padding: 0 8px;
border-left: 1px solid rgba(255, 103, 0, 0.2);
}
.queue-label {
opacity: 0.7;
}
.queue-value {
font-weight: bold;
min-width: 1.5ch;
text-align: center;
}
/* Shortcuts hint */
.shortcuts-hint {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
font-size: 10px;
opacity: 0.4;
transition: opacity 0.2s;
}
.shortcuts-hint:hover {
opacity: 0.7;
}
.shortcuts-hint span {
display: flex;
align-items: center;
gap: 4px;
}
.shortcuts-hint kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 9px;
font-family: inherit;
border: 1px solid rgba(255, 103, 0, 0.4);
border-radius: 2px;
background: rgba(255, 103, 0, 0.05);
}
/* Legacy styles kept for compatibility */
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.tui-btn {
background: none;
border: 1px solid #333;
color: var(--text-color);
font-family: inherit;
cursor: pointer;
padding: 2px 6px;
font-weight: bold;
opacity: 0.7;
transition: all 0.2s;
}
.tui-btn:hover {
opacity: 1;
border-color: var(--text-color);
background: rgba(255, 103, 0, 0.1);
}
.tui-val {
min-width: 3ch;
text-align: center;
display: inline-block;
font-weight: bold;
}
/* Responsive Adjustments */
@media (max-width: 900px) {
.sliders-grid {
grid-template-columns: 1fr;
}
#tui-controls {
flex-direction: column;
align-items: stretch;
}
.control-panel-divider {
width: 100%;
height: 1px;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 103, 0, 0.3) 20%,
rgba(255, 103, 0, 0.3) 80%,
transparent 100%
);
}
.control-panel-section {
padding: 10px 14px;
}
}
@media (max-width: 768px) {
.hero-text h2 {
font-size: 3rem;
}
.controls-footer {
padding: 1rem;
}
.shortcuts-hint {
display: none; /* Hide on mobile - too cramped */
}
.toggles-row,
.segments-row {
flex-wrap: wrap;
}
.actions-row {
flex-wrap: wrap;
justify-content: center;
}
}
@media (max-width: 480px) {
.control-panel-section {
padding: 8px 10px;
}
.section-header {
font-size: 8px;
}
}
</style>