feat: Enhance UI components with full labels and abbreviations, and add port mapping to dev docker-compose.
This commit is contained in:
@@ -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: []
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =============
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user