refactor(core): modularize ASCII logic and add dither/edge detection

This commit is contained in:
syntaxbullet
2026-02-09 22:33:58 +01:00
parent 961383b402
commit 9b9976c70a
8 changed files with 1566 additions and 27 deletions

View File

@@ -0,0 +1,463 @@
/**
* ASCII Renderer Controller
* Manages state, render loop, and grid calculations.
*/
import { CHAR_SETS, type CharSetKey, type AsciiOptions } from './ascii-shared';
import { WebGLAsciiRenderer, type RenderOptions } from './webgl-ascii';
// ============= Types =============
export interface AsciiSettings {
exposure: number;
contrast: number;
saturation: number;
gamma: number;
invert: boolean;
color: boolean;
dither: number;
denoise: boolean;
edgeMode: number;
overlayStrength: number;
resolution: number;
charSet: CharSetKey;
}
export interface GridCache {
widthCols: number;
heightRows: number;
imgEl: HTMLImageElement | null;
}
export interface ZoomState {
zoom: number;
zoomCenter: { x: number; y: number };
mousePos: { x: number; y: number };
showMagnifier: boolean;
}
export type RenderDirtyFlags = 'texture' | 'grid' | 'uniforms' | 'all';
// ============= Controller =============
export class AsciiController {
// DOM
private canvas: HTMLCanvasElement;
private asciiResult: HTMLPreElement;
private loadingIndicator: HTMLDivElement;
// Renderer
private renderer: WebGLAsciiRenderer | null = null;
// State
private settings: AsciiSettings;
private detectedSettings: Partial<AsciiSettings> = {};
private invertMode: 'auto' | 'on' | 'off' = 'auto';
private detectedInvert = false;
private currentImgUrl: string | null = null;
// Render loop
private dirtyTexture = false;
private dirtyGrid = false;
private dirtyUniforms = false;
private cachedGrid: GridCache = { widthCols: 0, heightRows: 0, imgEl: null };
private animFrameId: number | null = null;
// Zoom
private zoomState: ZoomState = {
zoom: 1.0,
zoomCenter: { x: 0.5, y: 0.5 },
mousePos: { x: -1, y: -1 },
showMagnifier: false
};
// Callbacks
private onSettingsChange?: () => void;
constructor(canvas: HTMLCanvasElement, asciiResult: HTMLPreElement, loadingIndicator: HTMLDivElement) {
this.canvas = canvas;
this.asciiResult = asciiResult;
this.loadingIndicator = loadingIndicator;
this.settings = this.getDefaultSettings();
try {
this.renderer = new WebGLAsciiRenderer(canvas);
} catch (e) {
console.error('WebGL not available:', e);
throw new Error('WebGL is required for this application');
}
this.startRenderLoop();
}
private getDefaultSettings(): AsciiSettings {
return {
exposure: 1.0,
contrast: 1.0,
saturation: 1.2,
gamma: 1.0,
invert: false,
color: false,
dither: 0,
denoise: false,
edgeMode: 0,
overlayStrength: 0.3,
resolution: 1.0,
charSet: 'standard'
};
}
// ============= Getters/Setters =============
getSettings(): AsciiSettings {
return { ...this.settings };
}
getSetting<K extends keyof AsciiSettings>(key: K): AsciiSettings[K] {
return this.settings[key];
}
setSetting<K extends keyof AsciiSettings>(key: K, value: AsciiSettings[K]): void {
if (this.settings[key] === value) return; // Prevent redundant updates and recursion
this.settings[key] = value;
if (key === 'resolution') {
this.calculateGrid().then(() => this.requestRender('grid'));
} else {
this.requestRender('uniforms');
}
this.onSettingsChange?.();
}
getInvertMode(): 'auto' | 'on' | 'off' {
return this.invertMode;
}
setInvertMode(mode: 'auto' | 'on' | 'off'): void {
this.invertMode = mode;
if (mode === 'auto') {
this.settings.invert = this.detectedInvert;
} else {
this.settings.invert = mode === 'on';
}
this.requestRender('uniforms');
this.onSettingsChange?.();
}
cycleInvertMode(): void {
if (this.invertMode === 'auto') {
this.setInvertMode('on');
} else if (this.invertMode === 'on') {
this.setInvertMode('off');
} else {
this.setInvertMode('auto');
}
}
cycleCharSet(): void {
const keys = Object.keys(CHAR_SETS) as CharSetKey[];
const idx = keys.indexOf(this.settings.charSet);
const nextIdx = (idx + 1) % keys.length;
this.setSetting('charSet', keys[nextIdx]);
}
getZoomState(): ZoomState {
return { ...this.zoomState };
}
getDetectedInvert(): boolean {
return this.detectedInvert;
}
getCachedGrid(): GridCache {
return { ...this.cachedGrid };
}
getCurrentImageUrl(): string | null {
return this.currentImgUrl;
}
// ============= Callbacks =============
onSettingsChanged(callback: () => void): void {
this.onSettingsChange = callback;
}
// ============= Image Loading =============
setCurrentImage(url: string, suggestions: Partial<AsciiOptions>): void {
this.currentImgUrl = url;
// Reset zoom
this.zoomState = {
zoom: 1.0,
zoomCenter: { x: 0.5, y: 0.5 },
mousePos: { x: -1, y: -1 },
showMagnifier: false
};
// Apply auto-detected settings
this.invertMode = 'auto';
this.detectedInvert = suggestions.invert ?? false;
// Validate charSet - ensure it's a valid CharSetKey
const validCharSet = this.isValidCharSet(suggestions.charSet)
? suggestions.charSet
: this.settings.charSet;
this.detectedSettings = {
...suggestions,
charSet: validCharSet,
edgeMode: this.mapEdgeMode(suggestions.edgeMode)
} as Partial<AsciiSettings>;
this.settings = {
...this.settings,
exposure: suggestions.exposure ?? this.settings.exposure,
contrast: suggestions.contrast ?? this.settings.contrast,
saturation: suggestions.saturation ?? this.settings.saturation,
gamma: suggestions.gamma ?? this.settings.gamma,
invert: this.detectedInvert,
dither: suggestions.dither ?? this.settings.dither,
denoise: suggestions.denoise ?? this.settings.denoise,
edgeMode: suggestions.edgeMode ? this.mapEdgeMode(suggestions.edgeMode) : this.settings.edgeMode,
overlayStrength: suggestions.overlayStrength ?? this.settings.overlayStrength,
charSet: validCharSet,
resolution: this.settings.resolution,
color: this.settings.color
};
this.onSettingsChange?.();
}
private mapEdgeMode(mode: string | undefined): number {
if (!mode) return 0;
switch (mode) {
case 'simple': return 1;
case 'sobel': return 2;
case 'canny': return 3;
default: return 0;
}
}
private isValidCharSet(value: string | CharSetKey | undefined): value is CharSetKey {
if (!value) return false;
return Object.keys(CHAR_SETS).includes(value);
}
resetToAutoSettings(): void {
if (Object.keys(this.detectedSettings).length > 0) {
this.invertMode = 'auto';
this.detectedInvert = this.detectedSettings.invert ?? false;
this.settings = {
...this.settings,
...this.detectedSettings,
resolution: this.settings.resolution,
color: false
};
this.settings.invert = this.detectedInvert;
this.calculateGrid().then(() => this.requestRender('all'));
this.onSettingsChange?.();
}
}
// ============= Rendering =============
requestRender(type: RenderDirtyFlags): void {
if (type === 'all') {
this.dirtyTexture = true;
this.dirtyGrid = true;
this.dirtyUniforms = true;
} else if (type === 'texture') {
this.dirtyTexture = true;
} else if (type === 'grid') {
this.dirtyGrid = true;
} else if (type === 'uniforms') {
this.dirtyUniforms = true;
}
}
async calculateGrid(): Promise<GridCache | undefined> {
if (!this.currentImgUrl) return;
const fontAspectRatio = 0.55;
const marginRatio = 0.2;
const screenW = window.innerWidth;
const availW = screenW * (1 - marginRatio);
let widthCols = Math.floor(availW / 6);
widthCols = Math.floor(widthCols * this.settings.resolution);
widthCols = Math.max(10, Math.min(1000, widthCols));
const imgEl = await this.resolveImage(this.currentImgUrl);
const imgRatio = imgEl.width / imgEl.height;
const heightRows = widthCols / (imgRatio / fontAspectRatio);
this.cachedGrid = { widthCols, heightRows, imgEl };
return this.cachedGrid;
}
private startRenderLoop(): void {
const loop = () => {
this.renderFrame();
this.animFrameId = requestAnimationFrame(loop);
};
this.animFrameId = requestAnimationFrame(loop);
}
private renderFrame(): void {
if (!this.renderer || !this.cachedGrid.imgEl) return;
const charSetContent = CHAR_SETS[this.settings.charSet] || CHAR_SETS.standard;
if (this.dirtyTexture || this.dirtyGrid || this.dirtyUniforms) {
if (this.dirtyTexture) {
this.renderer.updateTexture(this.cachedGrid.imgEl);
}
if (this.dirtyGrid) {
this.updateCanvasSize();
this.renderer.updateGrid(
this.cachedGrid.widthCols,
Math.floor(this.cachedGrid.heightRows)
);
}
if (this.dirtyUniforms || this.dirtyGrid) {
this.renderer.updateUniforms({
width: this.cachedGrid.widthCols,
height: Math.floor(this.cachedGrid.heightRows),
charSetContent,
...this.settings,
zoom: this.zoomState.zoom,
zoomCenter: this.zoomState.zoomCenter,
mousePos: this.zoomState.mousePos,
showMagnifier: this.zoomState.showMagnifier,
magnifierRadius: 0.15,
magnifierZoom: 2.5
} as RenderOptions);
}
this.renderer.draw();
this.dirtyTexture = false;
this.dirtyGrid = false;
this.dirtyUniforms = false;
}
}
private updateCanvasSize(): void {
const fontAspectRatio = 0.55;
const gridAspect = (this.cachedGrid.widthCols * fontAspectRatio) / this.cachedGrid.heightRows;
const screenW = window.innerWidth;
const screenH = window.innerHeight;
const maxW = screenW * 0.95;
const maxH = screenH * 0.95;
let finalW: number, finalH: number;
if (gridAspect > maxW / maxH) {
finalW = maxW;
finalH = maxW / gridAspect;
} else {
finalH = maxH;
finalW = maxH * gridAspect;
}
this.canvas.style.width = `${finalW}px`;
this.canvas.style.height = `${finalH}px`;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = finalW * dpr;
this.canvas.height = finalH * dpr;
}
async generate(): Promise<void> {
await this.calculateGrid();
this.asciiResult.style.display = 'none';
this.canvas.style.display = 'block';
this.canvas.style.opacity = '1';
this.requestRender('all');
}
// ============= Zoom =============
handleWheel(e: WheelEvent): void {
const rect = this.canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
const delta = -e.deltaY;
const factor = delta > 0 ? 1.1 : 0.9;
const oldZoom = this.zoomState.zoom;
this.zoomState.zoom = Math.min(Math.max(this.zoomState.zoom * factor, 1.0), 10.0);
if (this.zoomState.zoom === 1.0) {
this.zoomState.zoomCenter = { x: 0.5, y: 0.5 };
} else if (oldZoom !== this.zoomState.zoom) {
const imgX = (mx - this.zoomState.zoomCenter.x) / oldZoom + this.zoomState.zoomCenter.x;
const imgY = (my - this.zoomState.zoomCenter.y) / oldZoom + this.zoomState.zoomCenter.y;
this.zoomState.zoomCenter.x = (imgX - mx / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
this.zoomState.zoomCenter.y = (imgY - my / this.zoomState.zoom) / (1 - 1 / this.zoomState.zoom);
}
this.requestRender('uniforms');
}
handleMouseMove(e: MouseEvent): void {
const rect = this.canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
this.zoomState.mousePos = { x: mx, y: my };
const wasShowing = this.zoomState.showMagnifier;
this.zoomState.showMagnifier = mx >= 0 && mx <= 1 && my >= 0 && my <= 1;
if (this.zoomState.showMagnifier || wasShowing) {
this.requestRender('uniforms');
}
}
handleMouseLeave(): void {
if (this.zoomState.showMagnifier) {
this.zoomState.showMagnifier = false;
this.requestRender('uniforms');
}
}
// ============= Utilities =============
private resolveImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
}
showLoading(message: string): void {
this.loadingIndicator.style.display = 'block';
this.asciiResult.textContent = message;
this.asciiResult.style.opacity = '0.5';
}
hideLoading(): void {
this.loadingIndicator.style.display = 'none';
this.asciiResult.style.opacity = '1';
}
getCanvas(): HTMLCanvasElement {
return this.canvas;
}
dispose(): void {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
}
this.renderer?.dispose();
this.renderer = null;
}
}