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

View File

@@ -2,6 +2,7 @@
interface Props {
id: string;
label: string;
abbr?: string;
shortcut?: string;
variant?: "default" | "primary" | "subtle";
title?: string;
@@ -11,6 +12,7 @@ interface Props {
const {
id,
label,
abbr,
shortcut,
variant = "default",
title = "",
@@ -26,7 +28,10 @@ const {
data-tooltip-desc={description}
>
{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>
<style>
@@ -36,7 +41,8 @@ const {
gap: 6px;
background: rgba(255, 255, 255, 0.03);
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-size: 11px;
padding: 4px 10px;
@@ -47,9 +53,10 @@ const {
}
.tui-button:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.08);
color: var(--accent-color);
opacity: 1;
border-color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color), transparent 95%);
transform: translateY(-1px);
}
@@ -59,14 +66,21 @@ const {
}
.tui-button--primary {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
background: var(--accent-color);
border-color: var(--accent-color);
color: #fff;
opacity: 1;
font-weight: 700;
box-shadow: 0 0 15px
color-mix(in srgb, var(--accent-color), transparent 80%);
}
.tui-button--primary:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
background: color-mix(in srgb, var(--accent-color), black 10%);
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 {
@@ -98,5 +112,19 @@ const {
.tui-button-label {
font-weight: 500;
letter-spacing: 0.5px;
display: flex;
}
.tui-button-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-button-label.has-abbr .full {
display: none;
}
.tui-button-label.has-abbr .abbr {
display: inline;
}
}
</style>

View File

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

View File

@@ -2,6 +2,7 @@
interface Props {
id: string;
label: string;
abbr?: string;
min?: number;
max?: number;
step?: number;
@@ -13,6 +14,7 @@ interface Props {
const {
id,
label,
abbr,
min = 0,
max = 5,
step = 0.1,
@@ -28,10 +30,18 @@ const segments = 12;
<div
class="tui-slider"
data-slider-id={id}
data-default-value={value}
data-tooltip-title={title}
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-visual">
<span class="tui-slider-track" data-for={id}>
@@ -56,26 +66,50 @@ const segments = 12;
value={value}
/>
</div>
<span class="tui-slider-value" id={`val-${id}`}>{value.toFixed(2)}</span>
</div>
<style>
.tui-slider {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
gap: 4px;
font-size: 11px;
user-select: none;
color: rgba(255, 255, 255, 0.6);
}
.tui-slider-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.tui-slider-label {
min-width: 3ch;
font-weight: 700;
opacity: 0.5;
opacity: 0.7;
font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.4);
transition: opacity 0.2s;
color: #fff;
transition: all 0.2s;
display: flex;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tui-slider-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-slider-label.has-abbr .full {
display: none;
}
.tui-slider-label.has-abbr .abbr {
display: inline;
}
}
.tui-slider-track-wrapper {
@@ -113,6 +147,36 @@ const segments = 12;
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 {
position: absolute;
top: 0;
@@ -126,30 +190,27 @@ const segments = 12;
}
.tui-slider-value {
min-width: 4ch;
text-align: right;
font-weight: 400;
opacity: 0.8;
font-family: var(--font-mono);
color: rgba(255, 255, 255, 0.8);
color: #fff;
font-size: 10px;
transition: all 0.2s;
}
/* Hover effect */
.tui-slider:hover .tui-slider-label {
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 {
color: rgba(255, 255, 255, 0.2);
}
.tui-slider:hover .tui-slider-segment.filled {
color: rgba(255, 255, 255, 0.8);
}
.tui-slider:hover .tui-slider-segment.thumb {
color: #fff;
color: rgba(255, 255, 255, 0.3);
}
</style>
@@ -166,6 +227,9 @@ const segments = 12;
const valueDisplay = sliderContainer.querySelector(
".tui-slider-value",
) as HTMLElement;
const defaultValue = parseFloat(
sliderContainer.getAttribute("data-default-value") || "0",
);
if (!input || !track || !valueDisplay) return;
@@ -196,6 +260,10 @@ const segments = 12;
});
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);

View File

@@ -2,6 +2,7 @@
interface Props {
id: string;
label: string;
abbr?: string;
checked?: boolean;
title?: string;
description?: string;
@@ -10,6 +11,7 @@ interface Props {
const {
id,
label,
abbr,
checked = false,
title = "",
description = "",
@@ -24,7 +26,10 @@ const {
data-tooltip-title={title}
data-tooltip-desc={description}
>
<span class="tui-toggle-label">{label}</span>
<span class:list={["tui-toggle-label", { "has-abbr": !!abbr }]}>
<span class="full">{label}</span>
{abbr && <span class="abbr">{abbr}</span>}
</span>
</button>
<style>
@@ -34,7 +39,8 @@ const {
justify-content: center;
background: rgba(255, 255, 255, 0.03);
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-size: 11px;
padding: 4px 12px;
@@ -47,21 +53,39 @@ const {
}
.tui-toggle:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.08);
color: var(--accent-color);
opacity: 1;
border-color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color), transparent 95%);
}
.tui-toggle.active {
background: rgba(255, 255, 255, 0.15);
background: var(--accent-color);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.05);
opacity: 1;
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 {
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
}
.tui-toggle-label .abbr {
display: none;
}
@media (max-width: 1400px) {
.tui-toggle-label.has-abbr .full {
display: none;
}
.tui-toggle-label.has-abbr .abbr {
display: inline;
}
}
</style>

View File

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

View File

@@ -322,7 +322,8 @@ export class AsciiController {
const fontAspectRatio = 0.55;
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);
let widthCols = Math.floor(availW / 6);
@@ -394,10 +395,18 @@ export class AsciiController {
if (!parent) return;
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 maxH = screenH * 0.98;
@@ -410,6 +419,12 @@ export class AsciiController {
finalW = maxH * gridAspect;
}
// Final safeguard against zero or NaN
if (!finalW || !finalH || isNaN(finalW) || isNaN(finalH)) {
finalW = 300;
finalH = 300 / gridAspect;
}
this.canvas.style.width = `${finalW}px`;
this.canvas.style.height = `${finalH}px`;
@@ -425,6 +440,12 @@ export class AsciiController {
this.canvas.style.display = 'block';
this.canvas.style.opacity = '1';
this.requestRender('all');
// Insurance for mobile: trigger a second sizing/render after a short delay
// to catch cases where the layout might still be shifting (keyboard, address bar)
setTimeout(() => {
this.handleResize();
}, 100);
}
// ============= Zoom & Touch =============

View File

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

View File

@@ -1,5 +1,4 @@
export interface RenderOptions {
charSetContent: string;
fontFamily?: string;
@@ -60,8 +59,8 @@ export class WebGLAsciiRenderer {
this.gl = gl;
// Enable required extensions for advanced rendering
gl.getExtension('OES_standard_derivatives');
gl.getExtension('EXT_shader_texture_lod');
const hasDerivatives = !!gl.getExtension('OES_standard_derivatives');
const hasLod = !!gl.getExtension('EXT_shader_texture_lod');
this.program = null;
this.textures = {};
@@ -71,11 +70,11 @@ export class WebGLAsciiRenderer {
this.lastImage = null;
this.fontFamily = "'JetBrains Mono', monospace";
this.init();
this.init(hasDerivatives, hasLod);
this.loadBlueNoiseTexture();
}
init() {
init(hasDerivatives: boolean, hasLod: boolean) {
const gl = this.gl;
// Vertex Shader
@@ -91,8 +90,8 @@ export class WebGLAsciiRenderer {
// Fragment Shader
const fsSource = `
#extension GL_OES_standard_derivatives : enable
#extension GL_EXT_shader_texture_lod : enable
${hasDerivatives ? '#extension GL_OES_standard_derivatives : enable' : ''}
${hasLod ? '#extension GL_EXT_shader_texture_lod : enable' : ''}
precision mediump float;
varying vec2 v_texCoord;
@@ -100,11 +99,10 @@ export class WebGLAsciiRenderer {
uniform sampler2D u_atlas;
uniform sampler2D u_blueNoise;
uniform float u_charCount;
uniform vec2 u_charSizeUV; // Size of one char in UV space (width/texWidth, height/texHeight)
uniform vec2 u_gridSize; // cols, rows
uniform vec2 u_texSize; // atlas size
uniform vec2 u_charSizeUV;
uniform vec2 u_gridSize;
uniform vec2 u_texSize;
// Adjustments
uniform float u_exposure;
uniform float u_contrast;
uniform float u_saturation;
@@ -112,8 +110,8 @@ export class WebGLAsciiRenderer {
uniform bool u_invert;
uniform bool u_color;
uniform float u_overlayStrength;
uniform int u_edgeMode; // 0=none, 1=simple, 2=sobel, 3=canny
uniform float u_dither; // Dither strength 0.0 - 1.0
uniform int u_edgeMode;
uniform float u_dither;
uniform bool u_denoise;
uniform float u_sharpen;
uniform float u_edgeThreshold;
@@ -123,7 +121,6 @@ export class WebGLAsciiRenderer {
uniform float u_vignette;
uniform vec3 u_monoColor;
// Zoom & Magnifier
uniform float u_zoom;
uniform vec2 u_zoomCenter;
uniform vec2 u_mousePos;
@@ -132,65 +129,41 @@ export class WebGLAsciiRenderer {
uniform bool u_showMagnifier;
uniform float u_aspect;
// Blue Noise Dithering
float blueNoise(vec2 pos) {
// Map screen coordinates to texture coordinates (64x64 texture)
vec2 noiseUV = pos / 64.0;
float noiseVal = texture2D(u_blueNoise, noiseUV).r;
// Shift range to -0.5 to 0.5 for dither offset
return noiseVal - 0.5;
}
vec3 adjust(vec3 color) {
// Exposure
color *= u_exposure;
// Shadows / Highlights
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Shadows: lift dark areas
if (u_shadows > 0.0) {
float shadowFactor = (1.0 - luma) * u_shadows;
color = color + (vec3(1.0) - color) * shadowFactor * 0.5;
}
// Highlights: dim bright areas
if (u_highlights > 0.0) {
float highlightFactor = luma * u_highlights;
color = color * (1.0 - highlightFactor * 0.5);
}
// Contrast
color = (color - 0.5) * u_contrast + 0.5;
// Saturation
luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luma), color, u_saturation);
// Gamma
color = pow(max(color, 0.0), vec3(u_gamma));
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 sum = vec3(0.0);
vec2 halfSize = cellSize * 0.25; // Sample halfway to the edge
// Center
vec2 halfSize = cellSize * 0.25;
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;
return sum / 5.0;
}
// Sobel Filter - returns gradient magnitude and direction (approx)
vec2 sobelFilter(vec2 uv, vec2 cellSize) {
vec3 t = texture2D(u_image, uv + vec2(0.0, -cellSize.y)).rgb;
vec3 b = texture2D(u_image, uv + vec2(0.0, cellSize.y)).rgb;
@@ -200,8 +173,6 @@ export class WebGLAsciiRenderer {
vec3 tr = texture2D(u_image, uv + vec2(cellSize.x, -cellSize.y)).rgb;
vec3 bl = texture2D(u_image, uv + vec2(-cellSize.x, cellSize.y)).rgb;
vec3 br = texture2D(u_image, uv + vec2(cellSize.x, cellSize.y)).rgb;
// Convert to luma
float lt = dot(t, vec3(0.299, 0.587, 0.114));
float lb = dot(b, vec3(0.299, 0.587, 0.114));
float ll = dot(l, vec3(0.299, 0.587, 0.114));
@@ -210,122 +181,82 @@ export class WebGLAsciiRenderer {
float ltr = dot(tr, vec3(0.299, 0.587, 0.114));
float lbl = dot(bl, vec3(0.299, 0.587, 0.114));
float lbr = dot(br, vec3(0.299, 0.587, 0.114));
// Sobel kernels
// Gx: -1 0 1
// -2 0 2
// -1 0 1
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 mag = sqrt(gx*gx + gy*gy);
return vec2(mag, atan(gy, gx));
}
void main() {
vec2 uv = v_texCoord;
// Apply global zoom
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
// Magnifier logic
vec2 diff = (v_texCoord - u_mousePos);
diff.x *= u_aspect;
float dist = length(diff);
bool inMagnifier = u_showMagnifier && dist < u_magnifierRadius;
if (inMagnifier) {
// Zoom towards mouse position inside the magnifier
uv = (v_texCoord - u_mousePos) / u_magnifierZoom + u_mousePos;
// Also account for the global zoom background
uv = (uv - u_zoomCenter) / u_zoom + u_zoomCenter;
}
// Calculate which cell we are in
vec2 cellCoords = floor(uv * u_gridSize);
vec2 uvInCell = fract(uv * u_gridSize);
// Sample image at the center of the cell
vec2 cellSize = 1.0 / u_gridSize;
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) {
discard;
}
vec3 color;
// Denoise: 3x3 box blur (applied to the base sampling if enabled)
if (u_denoise) {
color = getAverageColor(sampleUV, cellSize * 2.0);
} else {
color = getAverageColor(sampleUV, cellSize);
}
// Sharpening
if (u_sharpen > 0.0) {
vec3 blurred = getAverageColor(sampleUV, cellSize * 2.0);
color = color + (color - blurred) * u_sharpen;
}
// Edge Detection Logic
if (u_edgeMode == 1) {
// Simple Laplacian-like
vec2 texel = cellSize;
vec3 center = color;
vec3 top = getAverageColor(sampleUV + vec2(0.0, -texel.y), cellSize);
vec3 bottom = getAverageColor(sampleUV + vec2(0.0, texel.y), cellSize);
vec3 left = getAverageColor(sampleUV + vec2(-texel.x, 0.0), cellSize);
vec3 right = getAverageColor(sampleUV + vec2(texel.x, 0.0), cellSize);
vec3 edges = abs(center - top) + abs(center - bottom) + abs(center - left) + abs(center - right);
float edgeLum = dot(edges, vec3(0.2126, 0.7152, 0.0722));
// Use Threshold
if (edgeLum > u_edgeThreshold * 0.1) {
color = mix(color, color * (1.0 - edgeLum * 2.0), 0.5);
}
} else if (u_edgeMode == 2) {
// Sobel Gradient
vec2 sobel = sobelFilter(sampleUV, cellSize);
float edgeStr = clamp(sobel.x * 2.0, 0.0, 1.0);
if (edgeStr > u_edgeThreshold * 0.2) {
color = mix(color, vec3(0.0), edgeStr * 0.8);
}
} else if (u_edgeMode == 3) {
// "Canny-like" (Sobel + gradient suppression)
vec2 sobel = sobelFilter(sampleUV, cellSize);
float mag = sobel.x;
float angle = sobel.y;
// Non-maximum suppression (simplified)
vec2 dir = vec2(cos(angle), sin(angle)) * cellSize;
vec2 s1 = sobelFilter(sampleUV + dir, cellSize);
vec2 s2 = sobelFilter(sampleUV - dir, cellSize);
// Use edge threshold
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) { // scaled threshold
if (mag < s1.x || mag < s2.x || mag < u_edgeThreshold * 0.3) {
mag = 0.0;
} else {
mag = 1.0; // Strong edge
mag = 1.0;
}
// Apply strong crisp edges
color = mix(color, vec3(0.0), mag);
}
// Apply adjustments (Contrast, etc.)
color = adjust(color);
// Overlay blend-like effect (boost mid-contrast)
if (u_overlayStrength > 0.0) {
vec3 overlay = color;
vec3 result;
@@ -337,15 +268,10 @@ export class WebGLAsciiRenderer {
color = mix(color, result, u_overlayStrength);
}
// Calculate luminance
float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
// Apply Blue Noise dithering before character mapping
if (u_dither > 0.0) {
// Use cell coordinates for stable dithering patterns
float noise = blueNoise(cellCoords);
// Scale noise by dither strength and 1/charCount
luma = luma + noise * (1.0 / u_charCount) * u_dither;
luma = clamp(luma, 0.0, 1.0);
}
@@ -354,53 +280,42 @@ export class WebGLAsciiRenderer {
luma = 1.0 - luma;
}
// Map luma to character index
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(
(charIndex + uvInCell.x) * u_charSizeUV.x,
uvInCell.y * u_charSizeUV.y
);
// --- MIPMAP FIX ---
// Use zoomed 'uv' for derivatives to handle zoom correctly.
// Multiply by 0.5 to bias towards sharper mipmaps (LOD bias).
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
float charAlpha;
${hasDerivatives && hasLod ? `
vec2 gradX = dFdx(uv) * u_gridSize * u_charSizeUV * 0.5;
vec2 gradY = dFdy(uv) * u_gridSize * u_charSizeUV * 0.5;
charAlpha = texture2DGradEXT(u_atlas, atlasUV, gradX, gradY).r;
` : `
charAlpha = texture2D(u_atlas, atlasUV).r;
`}
// 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) {
float edgeWidth = 0.005;
if (dist > u_magnifierRadius - edgeWidth && dist < u_magnifierRadius) {
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) {
float dist = distance(uv, vec2(0.5));
// Smoothsoft vignette
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, dist);
float d = distance(uv, vec2(0.5));
float vig = smoothstep(0.8 + (1.0 - u_vignette) * 0.5, 0.2, d);
charAlpha *= vig;
}
// Scanlines
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);
// Blend scanline effect based on strength
float scanEffect = mix(1.0, scan, u_scanlines * 0.5);
charAlpha *= scanEffect;
}
// Mono Color
vec3 finalColor;
if (u_color) {
finalColor = color;
@@ -494,28 +409,24 @@ export class WebGLAsciiRenderer {
this.fontFamily = fontName;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const fontSize = 32; // Higher resolution for atlas
// Add padding to prevent bleeding
const fontSize = 32;
const padding = 4;
ctx.font = `${fontSize}px ${fontName}`;
// Measure first char to get dimensions
const metrics = ctx.measureText('W');
const charContentWidth = Math.ceil(metrics.width);
const charContentHeight = Math.ceil(fontSize * 1.2);
// Full cell size including padding
const charWidth = charContentWidth + padding * 2;
const charHeight = charContentHeight + padding * 2;
const neededWidth = charWidth * charSet.length;
const neededHeight = charHeight;
// Calculate Next Power of Two
const nextPowerOfTwo = (v: number) => Math.pow(2, Math.ceil(Math.log(v) / Math.log(2)));
const texWidth = nextPowerOfTwo(neededWidth);
const texHeight = nextPowerOfTwo(neededHeight);
@@ -529,9 +440,6 @@ export class WebGLAsciiRenderer {
ctx.clearRect(0, 0, canvas.width, canvas.height);
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);
}
@@ -545,7 +453,6 @@ export class WebGLAsciiRenderer {
gl.bindTexture(gl.TEXTURE_2D, this.textures.atlas);
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_MAG_FILTER, gl.LINEAR);
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 u = this.uniformLocations;
if (!this.program) return;
gl.useProgram(this.program);
// Update Atlas if needed (expensive check inside)
this.updateAtlas(options.charSetContent, options.fontFamily || 'monospace');
if (this.charAtlas) {
gl.uniform1f(u['u_charCount'], this.charAtlas.count);
// Pass the normalized size of one character cell for UV mapping
gl.uniform2f(u['u_charSizeUV'],
this.charAtlas.charWidth / this.charAtlas.width,
this.charAtlas.charHeight / this.charAtlas.height
@@ -597,20 +503,18 @@ export class WebGLAsciiRenderer {
gl.uniform1f(u['u_dither'], options.dither || 0.0);
gl.uniform1i(u['u_denoise'], options.denoise ? 1 : 0);
gl.uniform1f(u['u_sharpen'], options.sharpen || 0.0);
gl.uniform1f(u['u_edgeThreshold'], options.edgeThreshold || 0.5); // 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_highlights'], options.highlights || 0.0);
gl.uniform1f(u['u_scanlines'], options.scanlines || 0.0);
gl.uniform1f(u['u_vignette'], options.vignette || 0.0);
// Parse hex color
const hex = options.monoColor || '#ffffff';
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
gl.uniform3f(u['u_monoColor'], r, g, b);
// Zoom & Magnifier
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_mousePos'], options.mousePos?.x ?? -1.0, options.mousePos?.y ?? -1.0);
@@ -624,7 +528,6 @@ export class WebGLAsciiRenderer {
const gl = this.gl;
const texture = gl.createTexture();
if (!texture) return;
this.textures.blueNoise = texture;
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_WRAP_S, 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) {
if (this.lastImage === image && this.textures.image) return;
const gl = this.gl;
if (this.textures.image) gl.deleteTexture(this.textures.image);
const texture = gl.createTexture();
if (!texture) throw new Error('Failed to create texture');
this.textures.image = texture;
gl.bindTexture(gl.TEXTURE_2D, this.textures.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);
@@ -669,16 +561,13 @@ export class WebGLAsciiRenderer {
draw() {
const gl = this.gl;
const program = this.program;
if (!program || !this.textures.image || !this.textures.atlas || !this.buffers.position || !this.buffers.texCoord) return;
gl.useProgram(program);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Attributes
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.position);
@@ -689,7 +578,6 @@ export class WebGLAsciiRenderer {
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.texCoord);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
// Bind Textures
const u = this.uniformLocations;
gl.uniform1i(u['u_image'], 0);
gl.activeTexture(gl.TEXTURE0);
@@ -715,32 +603,14 @@ export class WebGLAsciiRenderer {
this.draw();
}
/**
* Dispose of all WebGL resources.
* Call this when the renderer is no longer needed.
*/
dispose(): void {
const gl = this.gl;
if (this.textures.image) {
gl.deleteTexture(this.textures.image);
}
if (this.textures.atlas) {
gl.deleteTexture(this.textures.atlas);
}
if (this.textures.blueNoise) {
gl.deleteTexture(this.textures.blueNoise);
}
if (this.buffers.position) {
gl.deleteBuffer(this.buffers.position);
}
if (this.buffers.texCoord) {
gl.deleteBuffer(this.buffers.texCoord);
}
if (this.program) {
gl.deleteProgram(this.program);
}
if (this.textures.image) gl.deleteTexture(this.textures.image);
if (this.textures.atlas) gl.deleteTexture(this.textures.atlas);
if (this.textures.blueNoise) gl.deleteTexture(this.textures.blueNoise);
if (this.buffers.position) gl.deleteBuffer(this.buffers.position);
if (this.buffers.texCoord) gl.deleteBuffer(this.buffers.texCoord);
if (this.program) gl.deleteProgram(this.program);
this.textures = {};
this.buffers = {};
this.program = null;
@@ -748,29 +618,21 @@ export class WebGLAsciiRenderer {
this.lastImage = null;
}
// Kept for backward compatibility or specialized updates
updateMagnifier(options: MagnifierOptions) {
const gl = this.gl;
const program = this.program;
if (!program) return;
gl.useProgram(program);
// Only update magnifier-related uniforms (using cached locations)
const u = this.uniformLocations;
const mousePos = options.mousePos ?? { x: -1, y: -1 };
gl.uniform2f(u['u_mousePos'], mousePos.x, mousePos.y);
gl.uniform1f(u['u_magnifierRadius'], options.magnifierRadius || 0.03);
gl.uniform1f(u['u_magnifierZoom'], options.magnifierZoom || 2.0);
gl.uniform1i(u['u_showMagnifier'], options.showMagnifier ? 1 : 0);
if (options.zoom !== undefined) {
gl.uniform1f(u['u_zoom'], options.zoom || 1.0);
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();
}
}

View File

@@ -1,6 +1,8 @@
:root {
--bg-color: #000000;
--text-color: #FFFFFF;
--accent-color: #FF6700;
/* Safety Orange */
--font-mono: 'JetBrains Mono', monospace;
}
@@ -28,7 +30,7 @@ button {
button:hover {
opacity: 1;
background: rgba(255, 103, 0, 0.1);
background: color-mix(in srgb, var(--accent-color), transparent 90%);
}
a {