import { prepareWithSegments } from '../../src/layout.ts' const COLS = 50 const ROWS = 28 const FONT_SIZE = 14 const LINE_HEIGHT = 16 const TARGET_ROW_W = 440 const PROP_FAMILY = 'Georgia, Palatino, "Times New Roman", serif' const FIELD_OVERSAMPLE = 2 const FIELD_COLS = COLS * FIELD_OVERSAMPLE const FIELD_ROWS = ROWS * FIELD_OVERSAMPLE const CANVAS_W = 220 const CANVAS_H = Math.round(CANVAS_W * ((ROWS * LINE_HEIGHT) / TARGET_ROW_W)) const FIELD_SCALE_X = FIELD_COLS / CANVAS_W const FIELD_SCALE_Y = FIELD_ROWS / CANVAS_H const PARTICLE_N = 120 const SPRITE_R = 14 const ATTRACTOR_R = 12 const LARGE_ATTRACTOR_R = 30 const ATTRACTOR_FORCE_1 = 0.22 const ATTRACTOR_FORCE_2 = 0.05 const FIELD_DECAY = 0.82 const CHARSET = ' .,:;!+-=*#@%&abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const WEIGHTS = [300, 500, 800] as const const STYLES = ['normal', 'italic'] as const type FontStyleVariant = typeof STYLES[number] type PaletteEntry = { char: string weight: number style: FontStyleVariant font: string width: number brightness: number } type BrightnessEntry = { monoChar: string propHtml: string } type Particle = { x: number y: number vx: number vy: number } type FieldStamp = { radiusX: number radiusY: number sizeX: number sizeY: number values: Float32Array } type RowNodes = { monoNode: HTMLDivElement propNode: HTMLDivElement } function getRequiredDiv(id: string): HTMLDivElement { const element = document.getElementById(id) if (!(element instanceof HTMLDivElement)) throw new Error(`#${id} not found`) return element } const brightnessCanvas = document.createElement('canvas') brightnessCanvas.width = 28 brightnessCanvas.height = 28 const brightnessContext = brightnessCanvas.getContext('2d', { willReadFrequently: true }) if (brightnessContext === null) throw new Error('brightness context not available') const bCtx = brightnessContext function estimateBrightness(ch: string, font: string): number { const size = 28 bCtx.clearRect(0, 0, size, size) bCtx.font = font bCtx.fillStyle = '#fff' bCtx.textBaseline = 'middle' bCtx.fillText(ch, 1, size / 2) const data = bCtx.getImageData(0, 0, size, size).data let sum = 0 for (let index = 3; index < data.length; index += 4) sum += data[index]! return sum / (255 * size * size) } function measureWidth(ch: string, font: string): number { const prepared = prepareWithSegments(ch, font) return prepared.widths.length > 0 ? prepared.widths[0]! : 0 } const palette: PaletteEntry[] = [] for (const style of STYLES) { for (const weight of WEIGHTS) { const font = `${style === 'italic' ? 'italic ' : ''}${weight} ${FONT_SIZE}px ${PROP_FAMILY}` for (const ch of CHARSET) { if (ch === ' ') continue const width = measureWidth(ch, font) if (width <= 0) continue const brightness = estimateBrightness(ch, font) palette.push({ char: ch, weight, style, font, width, brightness }) } } } const maxBrightness = Math.max(...palette.map(entry => entry.brightness)) if (maxBrightness > 0) { for (let index = 0; index < palette.length; index++) { palette[index]!.brightness /= maxBrightness } } palette.sort((a, b) => a.brightness - b.brightness) const targetCellW = TARGET_ROW_W / COLS function findBest(targetBrightness: number): PaletteEntry { let lo = 0 let hi = palette.length - 1 while (lo < hi) { const mid = (lo + hi) >> 1 if (palette[mid]!.brightness < targetBrightness) lo = mid + 1 else hi = mid } let bestScore = Infinity let best = palette[lo]! const start = Math.max(0, lo - 15) const end = Math.min(palette.length, lo + 15) for (let index = start; index < end; index++) { const entry = palette[index]! const brightnessError = Math.abs(entry.brightness - targetBrightness) * 2.5 const widthError = Math.abs(entry.width - targetCellW) / targetCellW const score = brightnessError + widthError if (score < bestScore) { bestScore = score best = entry } } return best } const MONO_RAMP = ' .`-_:,;^=+/|)\\!?0oOQ#%@' const brightnessLookup: BrightnessEntry[] = [] for (let brightnessByte = 0; brightnessByte < 256; brightnessByte++) { const brightness = brightnessByte / 255 const monoChar = MONO_RAMP[Math.min(MONO_RAMP.length - 1, (brightness * MONO_RAMP.length) | 0)]! if (brightness < 0.03) { brightnessLookup.push({ monoChar, propHtml: ' ' }) continue } const match = findBest(brightness) const alphaIndex = Math.max(1, Math.min(10, Math.round(brightness * 10))) brightnessLookup.push({ monoChar, propHtml: `${esc(match.char)}`, }) } const particles: Particle[] = [] for (let index = 0; index < PARTICLE_N; index++) { const angle = Math.random() * Math.PI * 2 const radius = Math.random() * 40 + 20 particles.push({ x: CANVAS_W / 2 + Math.cos(angle) * radius, y: CANVAS_H / 2 + Math.sin(angle) * radius, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, }) } const simulationCanvas = document.createElement('canvas') simulationCanvas.width = CANVAS_W simulationCanvas.height = CANVAS_H simulationCanvas.className = 'source-canvas' const simulationContext = simulationCanvas.getContext('2d', { willReadFrequently: true }) if (simulationContext === null) throw new Error('simulation context not available') const sCtx = simulationContext const brightnessField = new Float32Array(FIELD_COLS * FIELD_ROWS) const spriteCache = new Map() function getSpriteCanvas(radius: number): HTMLCanvasElement { const cached = spriteCache.get(radius) if (cached !== undefined) return cached const canvas = document.createElement('canvas') canvas.width = radius * 2 canvas.height = radius * 2 const context = canvas.getContext('2d') if (context === null) throw new Error('sprite context not available') const gradient = context.createRadialGradient(radius, radius, 0, radius, radius, radius) gradient.addColorStop(0, 'rgba(255,255,255,0.45)') gradient.addColorStop(0.35, 'rgba(255,255,255,0.15)') gradient.addColorStop(1, 'rgba(255,255,255,0)') context.fillStyle = gradient context.fillRect(0, 0, radius * 2, radius * 2) spriteCache.set(radius, canvas) return canvas } function spriteAlphaAt(normalizedDistance: number): number { if (normalizedDistance >= 1) return 0 if (normalizedDistance <= 0.35) return 0.45 + (0.15 - 0.45) * (normalizedDistance / 0.35) return 0.15 * (1 - (normalizedDistance - 0.35) / 0.65) } function createFieldStamp(radiusPx: number): FieldStamp { const fieldRadiusX = radiusPx * FIELD_SCALE_X const fieldRadiusY = radiusPx * FIELD_SCALE_Y const radiusX = Math.ceil(fieldRadiusX) const radiusY = Math.ceil(fieldRadiusY) const sizeX = radiusX * 2 + 1 const sizeY = radiusY * 2 + 1 const values = new Float32Array(sizeX * sizeY) for (let y = -radiusY; y <= radiusY; y++) { for (let x = -radiusX; x <= radiusX; x++) { const normalizedDistance = Math.sqrt((x / fieldRadiusX) ** 2 + (y / fieldRadiusY) ** 2) values[(y + radiusY) * sizeX + x + radiusX] = spriteAlphaAt(normalizedDistance) } } return { radiusX, radiusY, sizeX, sizeY, values } } function splatFieldStamp(centerX: number, centerY: number, stamp: FieldStamp): void { const gridCenterX = Math.round(centerX * FIELD_SCALE_X) const gridCenterY = Math.round(centerY * FIELD_SCALE_Y) for (let y = -stamp.radiusY; y <= stamp.radiusY; y++) { const gridY = gridCenterY + y if (gridY < 0 || gridY >= FIELD_ROWS) continue const fieldRowOffset = gridY * FIELD_COLS const stampRowOffset = (y + stamp.radiusY) * stamp.sizeX for (let x = -stamp.radiusX; x <= stamp.radiusX; x++) { const gridX = gridCenterX + x if (gridX < 0 || gridX >= FIELD_COLS) continue const stampValue = stamp.values[stampRowOffset + x + stamp.radiusX]! if (stampValue === 0) continue const fieldIndex = fieldRowOffset + gridX brightnessField[fieldIndex] = Math.min(1, brightnessField[fieldIndex]! + stampValue) } } } const particleFieldStamp = createFieldStamp(SPRITE_R) const largeAttractorFieldStamp = createFieldStamp(LARGE_ATTRACTOR_R) const smallAttractorFieldStamp = createFieldStamp(ATTRACTOR_R) const sourceBox = getRequiredDiv('source-box') sourceBox.appendChild(simulationCanvas) const propBox = getRequiredDiv('prop-box') const monoBox = getRequiredDiv('mono-box') const rows: RowNodes[] = [] for (let row = 0; row < ROWS; row++) { const proportionalRow = document.createElement('div') proportionalRow.className = 'art-row' proportionalRow.style.height = proportionalRow.style.lineHeight = `${LINE_HEIGHT}px` propBox.appendChild(proportionalRow) const monoRow = document.createElement('div') monoRow.className = 'art-row' monoRow.style.height = monoRow.style.lineHeight = `${LINE_HEIGHT}px` monoBox.appendChild(monoRow) rows.push({ monoNode: monoRow, propNode: proportionalRow, }) } function esc(ch: string): string { if (ch === '<') return '<' if (ch === '>') return '>' if (ch === '&') return '&' if (ch === '"') return '"' return ch } function wCls(weight: number, style: FontStyleVariant): string { const weightClass = weight === 300 ? 'w3' : weight === 500 ? 'w5' : 'w8' return style === 'italic' ? `${weightClass} it` : weightClass } function render(now: number): void { const attractor1X = Math.cos(now * 0.0007) * CANVAS_W * 0.25 + CANVAS_W / 2 const attractor1Y = Math.sin(now * 0.0011) * CANVAS_H * 0.3 + CANVAS_H / 2 const attractor2X = Math.cos(now * 0.0013 + Math.PI) * CANVAS_W * 0.2 + CANVAS_W / 2 const attractor2Y = Math.sin(now * 0.0009 + Math.PI) * CANVAS_H * 0.25 + CANVAS_H / 2 for (let index = 0; index < particles.length; index++) { const particle = particles[index]! const d1x = attractor1X - particle.x const d1y = attractor1Y - particle.y const d2x = attractor2X - particle.x const d2y = attractor2Y - particle.y const dist1 = d1x * d1x + d1y * d1y const dist2 = d2x * d2x + d2y * d2y const ax = dist1 < dist2 ? d1x : d2x const ay = dist1 < dist2 ? d1y : d2y const dist = Math.sqrt(Math.min(dist1, dist2)) + 1 const force = dist1 < dist2 ? ATTRACTOR_FORCE_1 : ATTRACTOR_FORCE_2 particle.vx += ax / dist * force particle.vy += ay / dist * force particle.vx += (Math.random() - 0.5) * 0.25 particle.vy += (Math.random() - 0.5) * 0.25 particle.vx *= 0.97 particle.vy *= 0.97 particle.x += particle.vx particle.y += particle.vy if (particle.x < -SPRITE_R) particle.x += CANVAS_W + SPRITE_R * 2 if (particle.x > CANVAS_W + SPRITE_R) particle.x -= CANVAS_W + SPRITE_R * 2 if (particle.y < -SPRITE_R) particle.y += CANVAS_H + SPRITE_R * 2 if (particle.y > CANVAS_H + SPRITE_R) particle.y -= CANVAS_H + SPRITE_R * 2 } sCtx.fillStyle = 'rgba(0,0,0,0.18)' sCtx.fillRect(0, 0, CANVAS_W, CANVAS_H) sCtx.globalCompositeOperation = 'lighter' const particleSprite = getSpriteCanvas(SPRITE_R) for (let index = 0; index < particles.length; index++) { const particle = particles[index]! sCtx.drawImage(particleSprite, particle.x - SPRITE_R, particle.y - SPRITE_R) } sCtx.drawImage(getSpriteCanvas(LARGE_ATTRACTOR_R), attractor1X - LARGE_ATTRACTOR_R, attractor1Y - LARGE_ATTRACTOR_R) sCtx.drawImage(getSpriteCanvas(ATTRACTOR_R), attractor2X - ATTRACTOR_R, attractor2Y - ATTRACTOR_R) sCtx.globalCompositeOperation = 'source-over' for (let index = 0; index < brightnessField.length; index++) { brightnessField[index] = brightnessField[index]! * FIELD_DECAY } for (let index = 0; index < particles.length; index++) { const particle = particles[index]! splatFieldStamp(particle.x, particle.y, particleFieldStamp) } splatFieldStamp(attractor1X, attractor1Y, largeAttractorFieldStamp) splatFieldStamp(attractor2X, attractor2Y, smallAttractorFieldStamp) for (let row = 0; row < ROWS; row++) { let propHtml = '' let monoText = '' const fieldRowStart = row * FIELD_OVERSAMPLE * FIELD_COLS for (let col = 0; col < COLS; col++) { const fieldColStart = col * FIELD_OVERSAMPLE let brightness = 0 for (let sampleY = 0; sampleY < FIELD_OVERSAMPLE; sampleY++) { const sampleRowOffset = fieldRowStart + sampleY * FIELD_COLS + fieldColStart for (let sampleX = 0; sampleX < FIELD_OVERSAMPLE; sampleX++) { brightness += brightnessField[sampleRowOffset + sampleX]! } } const brightnessByte = Math.min(255, ((brightness / (FIELD_OVERSAMPLE * FIELD_OVERSAMPLE)) * 255) | 0) const entry = brightnessLookup[brightnessByte]! propHtml += entry.propHtml monoText += entry.monoChar } const rowNodes = rows[row]! rowNodes.propNode.innerHTML = propHtml rowNodes.monoNode.textContent = monoText } requestAnimationFrame(render) } requestAnimationFrame(render)