You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
124 lines
3.8 KiB
124 lines
3.8 KiB
-- The Mandelbrot set rendered as colored ASCII art, drawn row by row with
|
|
-- the tui positioning tools (alternate screen, moveTo, clearScreen), then a
|
|
-- short animated zoom into "seahorse valley". When output is piped, it
|
|
-- degrades to a single plain-text render.
|
|
--
|
|
-- Run with: cargo run -- lua/demo/mandelbrot.lua
|
|
|
|
-- One character + color per escape-speed bucket, slowest to fastest.
|
|
local CHARS = { ".", ":", "-", "=", "+", "*", "#", "%", "@" }
|
|
local COLORS = {
|
|
"#1e3a8a", "#1d4ed8", "#0284c7", "#0d9488", "#16a34a",
|
|
"#84cc16", "#eab308", "#f97316", "#ef4444",
|
|
}
|
|
|
|
--- How many iterations until the point escapes, or nil if it stays bounded
|
|
--- (i.e. belongs to the set).
|
|
local function escapeTime(cx, cy, maxIter)
|
|
local x, y = 0, 0
|
|
for i = 1, maxIter do
|
|
local x2, y2 = x * x, y * y
|
|
if x2 + y2 > 4 then
|
|
return i
|
|
end
|
|
y = 2 * x * y + cy
|
|
x = x2 - y2 + cx
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Render one row as a styled string, batching same-colored runs into a
|
|
--- single tag so the escape-code overhead stays small.
|
|
local function renderRow(cy, xMin, dx, cols, maxIter)
|
|
local parts, run, runColor = {}, {}, nil
|
|
local function flush()
|
|
if #run == 0 then
|
|
return
|
|
end
|
|
local text = table.concat(run)
|
|
parts[#parts + 1] = runColor and ("<%s>%s</>"):format(runColor, text) or text
|
|
run = {}
|
|
end
|
|
for col = 0, cols - 1 do
|
|
local i = escapeTime(xMin + col * dx, cy, maxIter)
|
|
local ch, color = " ", nil -- inside the set: leave the cell empty
|
|
if i then
|
|
local idx = math.clamp(math.floor(math.sqrt(i / maxIter) * #CHARS) + 1, 1, #CHARS)
|
|
ch, color = CHARS[idx], COLORS[idx]
|
|
end
|
|
if color ~= runColor then
|
|
flush()
|
|
runColor = color
|
|
end
|
|
run[#run + 1] = ch
|
|
end
|
|
flush()
|
|
return tui.styled(table.concat(parts))
|
|
end
|
|
|
|
--- Draw one full frame. Interactively each row is placed with tui.moveTo
|
|
--- (row 1 is a header line); piped, rows are just printed in order.
|
|
local function drawFrame(cx, cy, scale, maxIter, cols, rows, interactive)
|
|
-- Terminal cells are roughly twice as tall as they are wide, so step
|
|
-- twice as far in y to keep the set round.
|
|
local dx = scale / cols
|
|
local dy = dx * 2
|
|
local xMin = cx - dx * cols / 2
|
|
local yMin = cy - dy * rows / 2
|
|
|
|
if interactive then
|
|
tui.moveTo(1, 1)
|
|
tui.raw(tui.styled(
|
|
("<clear=line><b>Mandelbrot</b> center %.9f%+.9fi width %.2e max iter %d")
|
|
:format(cx, cy, scale, maxIter)
|
|
))
|
|
end
|
|
for row = 1, rows do
|
|
local line = renderRow(yMin + (row - 1) * dy, xMin, dx, cols, maxIter)
|
|
if interactive then
|
|
tui.moveTo(row + 1, 1)
|
|
tui.raw(line)
|
|
else
|
|
print(line)
|
|
end
|
|
end
|
|
end
|
|
|
|
tui.setTitle("mandelbrot")
|
|
|
|
local info = tui.screenInfo()
|
|
local cols = info.width or 100
|
|
local rows = (info.height or 32) - 2 -- header + one spare line for the cursor
|
|
|
|
-- The classic full view: x roughly [-2.1, 1.1].
|
|
local fullCx, fullCy, fullScale = -0.5, 0, 3.2
|
|
|
|
if not info.tty then
|
|
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, false)
|
|
return
|
|
end
|
|
|
|
tui.altScreen(true)
|
|
tui.clearScreen()
|
|
tui.showCursor(false)
|
|
|
|
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, true)
|
|
os.sleep(2)
|
|
|
|
-- Zoom into "seahorse valley" between the main cardioid and the period-2
|
|
-- bulb, cranking up the iteration budget as detail gets finer.
|
|
local cx, cy = -0.743643887, 0.131825904
|
|
local scale = fullScale
|
|
for frame = 1, 40 do
|
|
scale = scale * 0.85
|
|
drawFrame(cx, cy, scale, 60 + frame * 8, cols, rows, true)
|
|
os.sleep(0.03)
|
|
end
|
|
os.sleep(2)
|
|
|
|
tui.altScreen(false)
|
|
tui.showCursor(true)
|
|
tui.success(("zoomed to a window %.2e units wide — that's about %dx magnification"):format(
|
|
scale,
|
|
math.round(fullScale / scale)
|
|
))
|
|
|