feat: Enhance UI components with full labels and abbreviations, and add port mapping to dev docker-compose.

This commit is contained in:
syntaxbullet
2026-02-10 20:07:00 +01:00
parent a9d2c43bfd
commit 5cd52f2785
11 changed files with 332 additions and 252 deletions

View File

@@ -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: []

View File

@@ -20,7 +20,8 @@ import { ChevronDown } from "@lucide/astro";
<div class="sliders-grid"> <div class="sliders-grid">
<TuiSlider <TuiSlider
id="exposure" id="exposure"
label="EXP" label="Exposure"
abbr="EXP"
min={0} min={0}
max={3} max={3}
step={0.01} step={0.01}
@@ -30,7 +31,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="contrast" id="contrast"
label="CON" label="Contrast"
abbr="CON"
min={0} min={0}
max={3} max={3}
step={0.01} step={0.01}
@@ -40,7 +42,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="shadows" id="shadows"
label="SHD" label="Shadows"
abbr="SHD"
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
@@ -50,7 +53,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="highlights" id="highlights"
label="HLT" label="Highlights"
abbr="HLT"
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
@@ -60,7 +64,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="saturation" id="saturation"
label="SAT" label="Saturation"
abbr="SAT"
min={0} min={0}
max={3} max={3}
step={0.01} step={0.01}
@@ -70,7 +75,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="gamma" id="gamma"
label="GAM" label="Gamma"
abbr="GAM"
min={0} min={0}
max={3} max={3}
step={0.01} step={0.01}
@@ -80,7 +86,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="sharpen" id="sharpen"
label="SHP" label="Sharpen"
abbr="SHP"
min={0} min={0}
max={10} max={10}
step={0.01} step={0.01}
@@ -90,7 +97,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="overlayStrength" id="overlayStrength"
label="OVL" label="Overlay"
abbr="OVL"
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
@@ -100,7 +108,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="resolution" id="resolution"
label="RES" label="Resolution"
abbr="RES"
min={0.1} min={0.1}
max={2} max={2}
step={0.01} step={0.01}
@@ -110,7 +119,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="dither" id="dither"
label="DTH" label="Dither"
abbr="DTH"
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
@@ -120,7 +130,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="edgeThreshold" id="edgeThreshold"
label="THR" label="Threshold"
abbr="THR"
min={0} min={0}
max={20} max={20}
step={0.1} step={0.1}
@@ -130,7 +141,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="scanlines" id="scanlines"
label="SCN" label="Scanlines"
abbr="SCN"
min={0} min={0}
max={2} max={2}
step={0.01} step={0.01}
@@ -140,7 +152,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSlider <TuiSlider
id="vignette" id="vignette"
label="VIG" label="Vignette"
abbr="VIG"
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
@@ -160,7 +173,8 @@ import { ChevronDown } from "@lucide/astro";
<div class="toggles-row"> <div class="toggles-row">
<TuiToggle <TuiToggle
id="toggle-color" id="toggle-color"
label="CLR" label="Color"
abbr="CLR"
title="Color Output (HTML)" title="Color Output (HTML)"
description="Toggles between monochrome text and colored HTML spans." description="Toggles between monochrome text and colored HTML spans."
/> />
@@ -182,7 +196,8 @@ import { ChevronDown } from "@lucide/astro";
<TuiToggle <TuiToggle
id="toggle-denoise" id="toggle-denoise"
label="DNZ" label="Denoise"
abbr="DNZ"
title="Denoise Pre-processing" title="Denoise Pre-processing"
description="Applies a bilateral filter to reduce image noise while preserving edges." description="Applies a bilateral filter to reduce image noise while preserving edges."
/> />
@@ -194,7 +209,8 @@ import { ChevronDown } from "@lucide/astro";
<div class="segments-col"> <div class="segments-col">
<TuiSegment <TuiSegment
id="segment-invert" id="segment-invert"
label="INV" label="Invert"
abbr="INV"
options={["AUTO", "ON", "OFF"]} options={["AUTO", "ON", "OFF"]}
value="AUTO" value="AUTO"
title="Invert Colors" title="Invert Colors"
@@ -202,7 +218,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSegment <TuiSegment
id="segment-edge" id="segment-edge"
label="EDG" label="Edges"
abbr="EDG"
options={["OFF", "SPL", "SOB", "CNY"]} options={["OFF", "SPL", "SOB", "CNY"]}
value="OFF" value="OFF"
title="Edge Detection Mode" title="Edge Detection Mode"
@@ -210,7 +227,8 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiSegment <TuiSegment
id="segment-charset" id="segment-charset"
label="SET" label="Charset"
abbr="SET"
options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]} options={["STD", "EXT", "BLK", "MIN", "DOT", "SHP"]}
value="STD" value="STD"
title="Character Set" title="Character Set"
@@ -230,6 +248,7 @@ import { ChevronDown } from "@lucide/astro";
<TuiButton <TuiButton
id="btn-reset" id="btn-reset"
label="RESET" label="RESET"
abbr="RST"
shortcut="R" shortcut="R"
title="Reset to Auto-detected Settings" title="Reset to Auto-detected Settings"
description="Resets all sliders and toggles to their default values." description="Resets all sliders and toggles to their default values."
@@ -238,6 +257,7 @@ import { ChevronDown } from "@lucide/astro";
<TuiButton <TuiButton
id="btn-next" id="btn-next"
label="NEXT" label="NEXT"
abbr="NXT"
shortcut="N" shortcut="N"
variant="primary" variant="primary"
title="Load Next Image" title="Load Next Image"
@@ -269,25 +289,29 @@ import { ChevronDown } from "@lucide/astro";
/> />
<TuiButton <TuiButton
id="btn-import" id="btn-import"
label="IMP" label="Import"
abbr="IMP"
title="Import Image" title="Import Image"
description="Upload your own image from your device." description="Upload your own image from your device."
/> />
<TuiButton <TuiButton
id="btn-save-png" id="btn-save-png"
label="PNG" label="Save PNG"
abbr="PNG"
title="Save as Image" title="Save as Image"
description="Download high-res PNG capture of the current view." description="Download high-res PNG capture of the current view."
/> />
<TuiButton <TuiButton
id="btn-copy-text" id="btn-copy-text"
label="TXT" label="Save TXT"
abbr="TXT"
title="Save as Text" title="Save as Text"
description="Download raw ASCII text file." description="Download raw ASCII text file."
/> />
<TuiButton <TuiButton
id="btn-copy-html" id="btn-copy-html"
label="HTML" label="Save HTML"
abbr="HTML"
title="Save as HTML" title="Save as HTML"
description="Download colored HTML file." description="Download colored HTML file."
/> />

View File

@@ -2,6 +2,7 @@
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;
@@ -11,6 +12,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
shortcut, shortcut,
variant = "default", variant = "default",
title = "", title = "",
@@ -26,7 +28,10 @@ const {
data-tooltip-desc={description} 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>
@@ -36,7 +41,8 @@ const {
gap: 6px; gap: 6px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7); color: #fff;
opacity: 0.8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
padding: 4px 10px; padding: 4px 10px;
@@ -47,9 +53,10 @@ const {
} }
.tui-button:hover { .tui-button:hover {
color: #fff; color: var(--accent-color);
border-color: rgba(255, 255, 255, 0.3); opacity: 1;
background: rgba(255, 255, 255, 0.08); border-color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color), transparent 95%);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -59,14 +66,21 @@ const {
} }
.tui-button--primary { .tui-button--primary {
background: rgba(255, 255, 255, 0.1); background: var(--accent-color);
border-color: rgba(255, 255, 255, 0.2); border-color: var(--accent-color);
color: #fff; 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: rgba(255, 255, 255, 0.2); background: color-mix(in srgb, var(--accent-color), black 10%);
border-color: rgba(255, 255, 255, 0.4); 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 {
@@ -98,5 +112,19 @@ const {
.tui-button-label { .tui-button-label {
font-weight: 500; font-weight: 500;
letter-spacing: 0.5px; 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>

View File

@@ -2,6 +2,7 @@
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;
@@ -11,6 +12,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
options, options,
value = options[0], value = options[0],
title = "", title = "",
@@ -24,7 +26,10 @@ const {
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} data-tooltip-desc={description}
> >
<span class="tui-segment-label">{label}</span> <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) => (
@@ -56,8 +61,23 @@ const {
min-width: 3ch; min-width: 3ch;
font-weight: 700; font-weight: 700;
font-family: var(--font-mono); font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.4); 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 {
@@ -72,7 +92,8 @@ const {
background: transparent; background: transparent;
border: none; border: none;
border-right: 1px solid rgba(255, 255, 255, 0.05); border-right: 1px solid rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5); color: #fff;
opacity: 0.6;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
padding: 4px 10px; padding: 4px 10px;
@@ -87,24 +108,26 @@ const {
} }
.tui-segment-option:hover { .tui-segment-option:hover {
color: #fff; color: var(--accent-color);
background: rgba(255, 255, 255, 0.05); opacity: 1;
background: color-mix(in srgb, var(--accent-color), transparent 95%);
} }
.tui-segment-option.active { .tui-segment-option.active {
background: rgba(255, 255, 255, 0.15); background: var(--accent-color);
color: #fff; color: #fff;
font-weight: 600; font-weight: 700;
opacity: 1;
} }
/* 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: rgba(255, 255, 255, 0.8); color: var(--accent-color);
} }
.tui-segment:hover .tui-segment-options { .tui-segment:hover .tui-segment-options {
border-color: rgba(255, 255, 255, 0.2); border-color: var(--accent-color);
} }
</style> </style>

View File

@@ -2,6 +2,7 @@
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;
@@ -13,6 +14,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
min = 0, min = 0,
max = 5, max = 5,
step = 0.1, step = 0.1,
@@ -28,10 +30,18 @@ const segments = 12;
<div <div
class="tui-slider" class="tui-slider"
data-slider-id={id} data-slider-id={id}
data-default-value={value}
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} data-tooltip-desc={description}
> >
<span class="tui-slider-label">{label}</span> <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}>
@@ -56,26 +66,50 @@ const segments = 12;
value={value} value={value}
/> />
</div> </div>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span>
</div> </div>
<style> <style>
.tui-slider { .tui-slider {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; gap: 4px;
font-size: 11px; font-size: 11px;
user-select: none; user-select: none;
color: rgba(255, 255, 255, 0.6); 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: 700;
opacity: 0.5; opacity: 0.7;
font-family: var(--font-mono); font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.4); color: #fff;
transition: opacity 0.2s; 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 {
@@ -113,6 +147,36 @@ const segments = 12;
scale: 1.2; 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 {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -126,30 +190,27 @@ const segments = 12;
} }
.tui-slider-value { .tui-slider-value {
min-width: 4ch;
text-align: right;
font-weight: 400; font-weight: 400;
opacity: 0.8; opacity: 0.8;
font-family: var(--font-mono); font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.8); 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: #fff; 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, 255, 255, 0.2); color: rgba(255, 255, 255, 0.3);
}
.tui-slider:hover .tui-slider-segment.filled {
color: rgba(255, 255, 255, 0.8);
}
.tui-slider:hover .tui-slider-segment.thumb {
color: #fff;
} }
</style> </style>
@@ -166,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;
@@ -196,6 +260,10 @@ const segments = 12;
}); });
valueDisplay.textContent = val.toFixed(2); 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);

View File

@@ -2,6 +2,7 @@
interface Props { interface Props {
id: string; id: string;
label: string; label: string;
abbr?: string;
checked?: boolean; checked?: boolean;
title?: string; title?: string;
description?: string; description?: string;
@@ -10,6 +11,7 @@ interface Props {
const { const {
id, id,
label, label,
abbr,
checked = false, checked = false,
title = "", title = "",
description = "", description = "",
@@ -24,7 +26,10 @@ const {
data-tooltip-title={title} data-tooltip-title={title}
data-tooltip-desc={description} 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>
@@ -34,7 +39,8 @@ const {
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5); color: #fff;
opacity: 0.8;
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
padding: 4px 12px; padding: 4px 12px;
@@ -47,21 +53,39 @@ const {
} }
.tui-toggle:hover { .tui-toggle:hover {
color: #fff; color: var(--accent-color);
border-color: rgba(255, 255, 255, 0.3); opacity: 1;
background: rgba(255, 255, 255, 0.08); border-color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color), transparent 95%);
} }
.tui-toggle.active { .tui-toggle.active {
background: rgba(255, 255, 255, 0.15); background: var(--accent-color);
color: #fff; color: #fff;
border-color: rgba(255, 255, 255, 0.4); opacity: 1;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.05); border-color: var(--accent-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: 600; font-weight: 600;
letter-spacing: 0.5px; 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>

View File

@@ -351,14 +351,22 @@ import ControlPanel from "../components/ControlPanel.astro";
@media (max-width: 1024px) { @media (max-width: 1024px) {
.split-layout { .split-layout {
flex-direction: column; flex-direction: column;
overflow: hidden; /* Prevent body scroll, use inner scrolling */ overflow: hidden;
height: 100dvh; /* Dynamic viewport height */ height: 100dvh;
} }
.ascii-workspace { .ascii-workspace {
height: auto; height: 0; /* Important for flex-grow to work reliably on all browsers */
flex-grow: 1; /* Fill remaining space */ flex-grow: 1;
overflow-y: auto; /* Allow scrolling inside workspace if needed */ display: flex;
flex-direction: column;
min-height: 0; /* Allow shrinking */
}
.canvas-layer {
flex-grow: 1;
min-height: 0;
height: 0; /* Force flex-grow to determine height */
} }
} }
</style> </style>

View File

@@ -322,7 +322,8 @@ export class AsciiController {
const fontAspectRatio = 0.55; const fontAspectRatio = 0.55;
const marginRatio = 0.05; // Reduced margin for container fit const marginRatio = 0.05; // Reduced margin for container fit
const screenW = parent.clientWidth; let screenW = parent.clientWidth;
if (screenW <= 0) screenW = window.innerWidth || 1000;
const availW = screenW * (1 - marginRatio); const availW = screenW * (1 - marginRatio);
let widthCols = Math.floor(availW / 6); let widthCols = Math.floor(availW / 6);
@@ -394,10 +395,18 @@ export class AsciiController {
if (!parent) return; if (!parent) return;
const fontAspectRatio = 0.55; const fontAspectRatio = 0.55;
const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows; // 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 screenW = parent.clientWidth;
const screenH = parent.clientHeight;
const maxW = screenW * 0.98; const maxW = screenW * 0.98;
const maxH = screenH * 0.98; const maxH = screenH * 0.98;
@@ -410,6 +419,12 @@ export class AsciiController {
finalW = maxH * gridAspect; 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.width = `${finalW}px`;
this.canvas.style.height = `${finalH}px`; this.canvas.style.height = `${finalH}px`;
@@ -425,6 +440,12 @@ export class AsciiController {
this.canvas.style.display = 'block'; this.canvas.style.display = 'block';
this.canvas.style.opacity = '1'; this.canvas.style.opacity = '1';
this.requestRender('all'); 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 ============= // ============= Zoom & Touch =============

View File

@@ -29,6 +29,8 @@ export class UIBindings {
private queue: ImageQueue; private queue: ImageQueue;
private loadNewImageFn: () => Promise<void>; private loadNewImageFn: () => Promise<void>;
private isUpdatingUI = false; private isUpdatingUI = false;
private lastNextTime = 0;
private readonly NEXT_COOLDOWN = 1000; // 1 second cooldown
// Event Handlers implementation references // Event Handlers implementation references
@@ -315,6 +317,11 @@ export class UIBindings {
if (btnNext) { if (btnNext) {
const handler = (e: Event) => { const handler = (e: Event) => {
e.stopPropagation(); e.stopPropagation();
const now = Date.now();
if (now - this.lastNextTime < this.NEXT_COOLDOWN) return;
this.lastNextTime = now;
this.loadNewImageFn(); this.loadNewImageFn();
}; };
this.buttonHandlers.set('btn-next', handler); this.buttonHandlers.set('btn-next', handler);
@@ -365,6 +372,9 @@ export class UIBindings {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case 'n': case 'n':
const now = Date.now();
if (now - this.lastNextTime < this.NEXT_COOLDOWN) break;
this.lastNextTime = now;
this.loadNewImageFn(); this.loadNewImageFn();
break; break;
case 'r': case 'r':

View File

@@ -1,5 +1,4 @@
export interface RenderOptions { export interface RenderOptions {
charSetContent: string; charSetContent: string;
fontFamily?: string; fontFamily?: string;
@@ -60,8 +59,8 @@ export class WebGLAsciiRenderer {
this.gl = gl; this.gl = gl;
// Enable required extensions for advanced rendering // Enable required extensions for advanced rendering
gl.getExtension('OES_standard_derivatives'); const hasDerivatives = !!gl.getExtension('OES_standard_derivatives');
gl.getExtension('EXT_shader_texture_lod'); const hasLod = !!gl.getExtension('EXT_shader_texture_lod');
this.program = null; this.program = null;
this.textures = {}; this.textures = {};
@@ -71,11 +70,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(); this.loadBlueNoiseTexture();
} }
init() { init(hasDerivatives: boolean, hasLod: boolean) {
const gl = this.gl; const gl = this.gl;
// Vertex Shader // Vertex Shader
@@ -91,8 +90,8 @@ export class WebGLAsciiRenderer {
// Fragment Shader // Fragment Shader
const fsSource = ` const fsSource = `
#extension GL_OES_standard_derivatives : enable ${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''}
#extension GL_EXT_shader_texture_lod : enable ${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''}
precision mediump float; precision mediump float;
varying vec2 v_texCoord; varying vec2 v_texCoord;
@@ -100,11 +99,10 @@ export class WebGLAsciiRenderer {
uniform sampler2D u_atlas; uniform sampler2D u_atlas;
uniform sampler2D u_blueNoise; uniform sampler2D u_blueNoise;
uniform float u_charCount; uniform float u_charCount;
uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight) uniform vec2 u_charSizeUV;
uniform vec2 u_gridSize; // cols, rows uniform vec2 u_gridSize;
uniform vec2 u_texSize; // atlas size 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;
@@ -112,8 +110,8 @@ 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 int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny uniform int u_edgeMode;
uniform float u_dither; // Dither strength 0.0 - 1.0 uniform float u_dither;
uniform bool u_denoise; uniform bool u_denoise;
uniform float u_sharpen; uniform float u_sharpen;
uniform float u_edgeThreshold; uniform float u_edgeThreshold;
@@ -123,7 +121,6 @@ export class WebGLAsciiRenderer {
uniform float u_vignette; uniform float u_vignette;
uniform vec3 u_monoColor; uniform vec3 u_monoColor;
// 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;
@@ -132,65 +129,41 @@ export class WebGLAsciiRenderer {
uniform bool u_showMagnifier; uniform bool u_showMagnifier;
uniform float u_aspect; uniform float u_aspect;
// Blue Noise Dithering
float blueNoise(vec2 pos) { float blueNoise(vec2 pos) {
// Map screen coordinates to texture coordinates (64x64 texture)
vec2 noiseUV = pos / 64.0; vec2 noiseUV = pos / 64.0;
float noiseVal = texture2D(u_blueNoise, noiseUV).r; float noiseVal = texture2D(u_blueNoise, noiseUV).r;
// Shift range to -0.5 to 0.5 for dither offset
return noiseVal - 0.5; return noiseVal - 0.5;
} }
vec3 adjust(vec3 color) { vec3 adjust(vec3 color) {
// Exposure
color *= u_exposure; color *= u_exposure;
// Shadows / Highlights
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Shadows: lift dark areas
if (u_shadows > 0.0) { if (u_shadows > 0.0) {
float shadowFactor = (1.0 - luma) * u_shadows; float shadowFactor = (1.0 - luma) * u_shadows;
color = color + (vec3(1.0) - color) * shadowFactor * 0.5; color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
} }
// Highlights: dim bright areas
if (u_highlights > 0.0) { if (u_highlights > 0.0) {
float highlightFactor = luma * u_highlights; float highlightFactor = luma * u_highlights;
color = color * (1.0 - highlightFactor * 0.5); color = color * (1.0 - highlightFactor * 0.5);
} }
// Contrast
color = (color - 0.5) * u_contrast + 0.5; color = (color - 0.5) * u_contrast + 0.5;
// Saturation
luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); 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);
} }
// Function to get average color from a cell using 5 samples (center + corners)
vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) { vec3 getAverageColor(vec2 cellCenterUV, vec2 cellSize) {
vec3 sum = vec3(0.0); vec3 sum = vec3(0.0);
vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge vec2 halfSize = cellSize * 0.25;
// Center
sum += texture2D(u_image, cellCenterUV).rgb; sum += texture2D(u_image, cellCenterUV).rgb;
// Corners
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;
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; return sum / 5.0;
} }
// Sobel Filter - returns gradient magnitude and direction (approx)
vec2 sobelFilter(vec2 uv, vec2 cellSize) { vec2 sobelFilter(vec2 uv, vec2 cellSize) {
vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb; 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 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb;
@@ -200,8 +173,6 @@ export class WebGLAsciiRenderer {
vec3 tr = 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 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb;
vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb; vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb;
// Convert to luma
float lt = dot(t, vec3(0.299, 0.587, 0.114)); float lt = dot(t, vec3(0.299, 0.587, 0.114));
float lb = dot(b, 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 ll = dot(l, vec3(0.299, 0.587, 0.114));
@@ -210,122 +181,82 @@ export class WebGLAsciiRenderer {
float ltr = dot(tr, 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 lbl = dot(bl, vec3(0.299, 0.587, 0.114));
float lbr = dot(br, vec3(0.299, 0.587, 0.114)); float lbr = dot(br, vec3(0.299, 0.587, 0.114));
// Sobel kernels
// Gx: -1 0 1
// -2 0 2
// -1 0 1
float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl); float gx = (ltr + 2.0*lr + lbr) - (ltl + 2.0*ll + lbl);
// Gy: -1 -2 -1
// 0 0 0
// 1 2 1
float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr); float gy = (lbl + 2.0*lb + lbr) - (ltl + 2.0*lt + ltr);
float mag = sqrt(gx*gx + gy*gy); float mag = sqrt(gx*gx + gy*gy);
return vec2(mag, atan(gy, gx)); 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);
// Sample image at the center of the cell
vec2 cellSize = 1.0 / u_gridSize; vec2 cellSize = 1.0 / u_gridSize;
vec2 sampleUV = (cellCoords + 0.5) * cellSize; vec2 sampleUV = (cellCoords + 0.5) * cellSize;
// 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; vec3 color;
// Denoise: 3x3 box blur (applied to the base sampling if enabled)
if (u_denoise) { if (u_denoise) {
color = getAverageColor(sampleUV, cellSize * 2.0); color = getAverageColor(sampleUV, cellSize * 2.0);
} else { } else {
color = getAverageColor(sampleUV, cellSize); color = getAverageColor(sampleUV, cellSize);
} }
// Sharpening
if (u_sharpen > 0.0) { if (u_sharpen > 0.0) {
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0); vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
color = color + (color - blurred) * u_sharpen; color = color + (color - blurred) * u_sharpen;
} }
// Edge Detection Logic
if (u_edgeMode == 1) { if (u_edgeMode == 1) {
// Simple Laplacian-like
vec2 texel = cellSize; vec2 texel = cellSize;
vec3 center = color; vec3 center = color;
vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize); vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize);
vec3 bottom = 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 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize);
vec3 right = 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); 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)); float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
// Use Threshold
if (edgeLum > u_edgeThreshold * 0.1) { if (edgeLum > u_edgeThreshold * 0.1) {
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5); color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
} }
} else if (u_edgeMode == 2) { } else if (u_edgeMode == 2) {
// Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize); vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0); float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
if (edgeStr > u_edgeThreshold * 0.2) { if (edgeStr > u_edgeThreshold * 0.2) {
color = mix(color, vec3(0.0), edgeStr * 0.8); color = mix(color, vec3(0.0), edgeStr * 0.8);
} }
} else if (u_edgeMode == 3) { } else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression)
vec2 sobel = sobelFilter(sampleUV, cellSize); vec2 sobel = sobelFilter(sampleUV, cellSize);
float mag = sobel.x; float mag = sobel.x;
float angle = sobel.y; float angle = sobel.y;
// Non-maximum suppression (simplified)
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize; vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize); vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = sobelFilter(sampleUV - dir, cellSize); vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) {
// Use edge threshold
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold
mag = 0.0; mag = 0.0;
} else { } else {
mag = 1.0; // Strong edge mag = 1.0;
} }
// Apply strong crisp edges
color = mix(color, vec3(0.0), mag); color = mix(color, vec3(0.0), mag);
} }
// Apply adjustments (Contrast, etc.)
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;
@@ -337,15 +268,10 @@ 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));
// Apply Blue Noise dithering before character mapping
if (u_dither > 0.0) { if (u_dither > 0.0) {
// Use cell coordinates for stable dithering patterns
float noise = blueNoise(cellCoords); float noise = blueNoise(cellCoords);
// Scale noise by dither strength and 1/charCount
luma = luma + noise * (1.0 / u_charCount) * u_dither; luma = luma + noise * (1.0 / u_charCount) * u_dither;
luma = clamp(luma, 0.0, 1.0); luma = clamp(luma, 0.0, 1.0);
} }
@@ -354,53 +280,42 @@ export class WebGLAsciiRenderer {
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
// Use u_charSizeUV to scale, instead of just 1.0/u_charCount
// x = charIndex * charWidthUV + uvInCell.x * charWidthUV
vec2 atlasUV = vec2( vec2 atlasUV = vec2(
(charIndex + uvInCell.x) * u_charSizeUV.x, (charIndex + uvInCell.x) * u_charSizeUV.x,
uvInCell.y * u_charSizeUV.y uvInCell.y * u_charSizeUV.y
); );
// --- MIPMAP FIX --- float charAlpha;
// Use zoomed 'uv' for derivatives to handle zoom correctly. ${hasDerivatives && hasLod ? `
// Multiply by 0.5 to bias towards sharper mipmaps (LOD bias). vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5; vec2 gradY = dFdy(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;
`}
// Use texture2DGradEXT to sample with explicit gradients, resolving cell boundary artifacts
float charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).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, 1.0, 1.0); // White border for the loupe color = vec3(1.0, 1.0, 1.0);
} }
} }
// Vignette
if (u_vignette > 0.0) { if (u_vignette > 0.0) {
float dist = distance(uv, vec2(0.5)); float d = distance(uv, vec2(0.5));
// Smoothsoft vignette float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, d);
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist);
charAlpha *= vig; charAlpha *= vig;
} }
// Scanlines
if (u_scanlines > 0.0) { if (u_scanlines > 0.0) {
// Sine wave based on grid rows
float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0); float scan = 0.5 + 0.5 * sin(uv.y * u_gridSize.y * 3.14159 * 2.0);
// Blend scanline effect based on strength
float scanEffect = mix(1.0, scan, u_scanlines * 0.5); float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
charAlpha *= scanEffect; charAlpha *= scanEffect;
} }
// Mono Color
vec3 finalColor; vec3 finalColor;
if (u_color) { if (u_color) {
finalColor = color; finalColor = color;
@@ -494,28 +409,24 @@ 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;
// Add padding to prevent bleeding
const padding = 4; 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 charContentWidth = Math.ceil(metrics.width); const charContentWidth = Math.ceil(metrics.width);
const charContentHeight = Math.ceil(fontSize * 1.2); const charContentHeight = Math.ceil(fontSize * 1.2);
// Full cell size including padding
const charWidth = charContentWidth + padding * 2; const charWidth = charContentWidth + padding * 2;
const charHeight = charContentHeight + padding * 2; const charHeight = charContentHeight + padding * 2;
const neededWidth = charWidth * charSet.length; const neededWidth = charWidth * charSet.length;
const neededHeight = charHeight; const neededHeight = charHeight;
// Calculate Next Power of Two
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2))); const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
const texWidth = nextPowerOfTwo(neededWidth); const texWidth = nextPowerOfTwo(neededWidth);
const texHeight = nextPowerOfTwo(neededHeight); const texHeight = nextPowerOfTwo(neededHeight);
@@ -529,9 +440,6 @@ 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++) {
// Draw character centered in its padded cell
// x position: start of cell (i * charWidth) + padding
// y position: padding
ctx.fillText(charSet[i], i * charWidth + padding, padding); ctx.fillText(charSet[i], i * charWidth + padding, padding);
} }
@@ -545,7 +453,6 @@ 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);
// Use Mipmaps for smoother downscaling (fixes shimmering/aliasing)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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);
@@ -572,13 +479,12 @@ 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);
// Pass the normalized size of one character cell for UV mapping
gl.uniform2f(u['u_charSizeUV'], gl.uniform2f(u['u_charSizeUV'],
this.charAtlas.charWidth / this.charAtlas.width, this.charAtlas.charWidth / this.charAtlas.width,
this.charAtlas.charHeight / this.charAtlas.height this.charAtlas.charHeight / this.charAtlas.height
@@ -597,20 +503,18 @@ export class WebGLAsciiRenderer {
gl.uniform1f(u['u_dither'], options.dither || 0.0); gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0); gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0); gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // Default to mid gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5);
gl.uniform1f(u['u_shadows'], options.shadows || 0.0); gl.uniform1f(u['u_shadows'], options.shadows || 0.0);
gl.uniform1f(u['u_highlights'], options.highlights || 0.0); gl.uniform1f(u['u_highlights'], options.highlights || 0.0);
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0); gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
gl.uniform1f(u['u_vignette'], options.vignette || 0.0); gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
// Parse hex color
const hex = options.monoColor || '#ffffff'; const hex = options.monoColor || '#ffffff';
const r = parseInt(hex.slice(1, 3), 16) / 255.0; const r = parseInt(hex.slice(1, 3), 16) / 255.0;
const g = parseInt(hex.slice(3, 5), 16) / 255.0; const g = parseInt(hex.slice(3, 5), 16) / 255.0;
const b = parseInt(hex.slice(5, 7), 16) / 255.0; const b = parseInt(hex.slice(5, 7), 16) / 255.0;
gl.uniform3f(u['u_monoColor'], r, g, b); gl.uniform3f(u['u_monoColor'], r, g, b);
// 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);
@@ -624,7 +528,6 @@ export class WebGLAsciiRenderer {
const gl = this.gl; const gl = this.gl;
const texture = gl.createTexture(); const texture = gl.createTexture();
if (!texture) return; if (!texture) return;
this.textures.blueNoise = texture; this.textures.blueNoise = texture;
const image = new Image(); const image = new Image();
@@ -636,27 +539,16 @@ export class WebGLAsciiRenderer {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_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_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
this.requestRender();
}; };
} }
// Helper to trigger a redraw if we have a controller reference, otherwise just rely on next loop
private requestRender() {
// Since we don't have a direct reference to the controller here,
// and we are in a render loop managed by the controller,
// the texture will just appear on the next frame.
}
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);
@@ -669,16 +561,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);
@@ -689,7 +578,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);
@@ -715,32 +603,14 @@ export class WebGLAsciiRenderer {
this.draw(); this.draw();
} }
/**
* Dispose of all WebGL resources.
* Call this when the renderer is no longer needed.
*/
dispose(): void { dispose(): void {
const gl = this.gl; const gl = this.gl;
if (this.textures.image) gl.deleteTexture(this.textures.image);
if (this.textures.image) { if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
gl.deleteTexture(this.textures.image); if (this.textures.blueNoise) gl.deleteTexture(this.textures.blueNoise);
} if (this.buffers.position) gl.deleteBuffer(this.buffers.position);
if (this.textures.atlas) { if (this.buffers.texCoord) gl.deleteBuffer(this.buffers.texCoord);
gl.deleteTexture(this.textures.atlas); if (this.program) gl.deleteProgram(this.program);
}
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.textures = {};
this.buffers = {}; this.buffers = {};
this.program = null; this.program = null;
@@ -748,29 +618,21 @@ export class WebGLAsciiRenderer {
this.lastImage = null; this.lastImage = null;
} }
// Kept for backward compatibility or specialized updates
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();
} }
} }

View File

@@ -1,6 +1,8 @@
:root { :root {
--bg-color: #000000; --bg-color: #000000;
--text-color: #FFFFFF; --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 {