Add exotic palettes, pattern generator, and layout improvements
- 17 new exotic palettes: hieroglyph, cuneiform, thai, arabic, devanagari, ethiopic, georgian, tibetan, alchemical, dominos, mahjong, dingbats, playing cards, yijing, box drawing, math symbols, flora, weather - Pattern generator with 10 types: plasma, mandelbrot, spiral, waves, nebula, kaleidoscope, aurora, lava, crystals, fractal_tree - Random pattern button with rainbow gradient - Mode tabs (Image / Patterns) in controls bar - Preview area now fills full viewport (header/footer hidden) - Slimmer controls bar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ebd4b12628
commit
b1874eace7
30
app.py
30
app.py
|
|
@ -11,7 +11,8 @@ from fastapi.staticfiles import StaticFiles
|
|||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from render import (
|
||||
PALETTES, image_to_art, gif_to_art, is_animated_gif,
|
||||
PALETTES, PATTERN_TYPES, image_to_art, gif_to_art, is_animated_gif,
|
||||
generate_pattern,
|
||||
)
|
||||
|
||||
app = FastAPI(title="ASCII Art Generator", docs_url=None, redoc_url=None)
|
||||
|
|
@ -31,6 +32,7 @@ async def index(request: Request):
|
|||
"request": request,
|
||||
"palettes": list(PALETTES.keys()),
|
||||
"palette_previews": {k: v[:12] for k, v in PALETTES.items()},
|
||||
"patterns": PATTERN_TYPES,
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -47,6 +49,32 @@ async def provision_space(request: Request):
|
|||
return {"status": "ok", "space": space, "message": f"rcreate space '{space}' ready"}
|
||||
|
||||
|
||||
@app.post("/api/pattern")
|
||||
async def render_pattern(
|
||||
pattern: str = Form("plasma"),
|
||||
width: int = Form(200),
|
||||
height: int = Form(80),
|
||||
palette: str = Form("mystic"),
|
||||
seed: str = Form(""),
|
||||
output_format: str = Form("html"),
|
||||
):
|
||||
width = max(20, min(500, width))
|
||||
height = max(10, min(250, height))
|
||||
if pattern not in PATTERN_TYPES:
|
||||
pattern = "plasma"
|
||||
if palette not in PALETTES:
|
||||
palette = "mystic"
|
||||
seed_val = int(seed) if seed.isdigit() else None
|
||||
|
||||
art = generate_pattern(
|
||||
pattern_type=pattern, width=width, height=height,
|
||||
palette_name=palette, output_format=output_format, seed=seed_val,
|
||||
)
|
||||
if output_format == "plain":
|
||||
return PlainTextResponse(art)
|
||||
return HTMLResponse(art)
|
||||
|
||||
|
||||
@app.get("/api/palettes")
|
||||
async def get_palettes():
|
||||
return {
|
||||
|
|
|
|||
243
render.py
243
render.py
|
|
@ -7,6 +7,8 @@ with true-color ANSI output, Floyd-Steinberg dithering, and multiple output form
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import random
|
||||
from html import escape as html_escape
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
|
|
@ -32,6 +34,25 @@ PALETTES = {
|
|||
"shades": " ░░▒▒▓▓██",
|
||||
"geometric": " .·˙∙•●○◌◦◯⊙⊚◐◑◒◓◔◕◖◗◍◎◉⦿⊛⊜⊝✦✧★✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋",
|
||||
"kanji": " 一二三四五六七八九十百千万丈与世中丸主乃久乗乙九了事二人仁今仏仕他付代令以仮仰仲件任企伏伐休会伝似位低住佐体何余作佳使例侍供依価侮侯侵便係促俊俗保信修俳俵俸倉個倍倒候借値倫倹偉偏停健側偵偶傍傑傘備催債傷傾僅働像僕僚僧儀億儒元兄充兆先光克免児入全",
|
||||
# ── Exotic palettes ──
|
||||
"thai": " กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ",
|
||||
"arabic": " ﺎﺏﺕﺙﺝﺡﺥﺩﺫﺭﺯﺱﺵﺹﺽﻁﻅﻉﻍﻑﻕﻙﻝﻡﻥﻩﻭﻱ",
|
||||
"devanagari": " ँंःअआइईउऊऋएऐओऔकखगघचछजझटठडढणतथदधनपफबभमयरलवशषसह",
|
||||
"ethiopic": " ሀሁሂሃሄህሆለሉሊላሌልሎሐሑሒሓሔሕሖመሙሚማሜምሞሰሱሲሳሴስሶረሩሪራሬርሮሸሹሺሻሼሽሾቀቁቂቃቄቅቆበቡቢባቤብቦተቱቲታቴትቶ",
|
||||
"georgian": " აბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰ",
|
||||
"tibetan": " ༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗༘༙༚༛༜༝༞༟༠༡༢༣༤༥༦༧༨༩",
|
||||
"hieroglyph": " 𓀀𓀁𓀂𓀃𓀄𓀅𓀆𓀇𓀈𓀉𓀊𓀋𓀌𓀍𓀎𓀏𓀐𓀑𓀒𓀓𓀔𓀕𓀖𓀗𓀘𓀙𓀚𓀛𓀜𓀝𓀞𓀟𓀠𓀡𓀢𓀣𓀤𓀥𓀦𓀧𓀨𓀩𓀪𓀫𓀬𓀭𓀮𓀯𓀰",
|
||||
"cuneiform": " 𒀀𒀁𒀂𒀃𒀄𒀅𒀆𒀇𒀈𒀉𒀊𒀋𒀌𒀍𒀎𒀏𒀐𒀑𒀒𒀓𒀔𒀕𒀖𒀗𒀘𒀙𒀚𒀛𒀜𒀝𒀞𒀟𒀠𒀡𒀢𒀣𒀤𒀥𒀦𒀧𒀨𒀩𒀪𒀫𒀬𒀭",
|
||||
"alchemical": " 🜀🜁🜂🜃🜄🜅🜆🜇🜈🜉🜊🜋🜌🜍🜎🜏🜐🜑🜒🜓🜔🜕🜖🜗🜘🜙🜚🜛🜜🜝🜞🜟🜠🜡🜢🜣🜤🜥🜦🜧🜨🜩🜪🜫🜬🜭🜮🜯🜰🜱🜲🜳🜴🜵🜶🜷🜸🜹🜺🜻🜼🜽🜾🜿",
|
||||
"dominos": " 🁣🁤🁥🁦🁧🁨🁩🁪🁫🁬🁭🁮🁯🁰🁱🁲🁳🁴🁵🁶🁷🁸🁹🁺🁻🁼🁽🁾🁿🂀🂁🂂🂃🂄🂅🂆🂇🂈🂉🂊🂋🂌🂍🂎🂏🂐🂑🂒🂓",
|
||||
"mahjong": " 🀀🀁🀂🀃🀄🀅🀆🀇🀈🀉🀊🀋🀌🀍🀎🀏🀐🀑🀒🀓🀔🀕🀖🀗🀘🀙🀚🀛🀜🀝🀞🀟🀠🀡",
|
||||
"dingbats": " ✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❘❙❚❛❜❝❞",
|
||||
"playing": " 🂡🂢🂣🂤🂥🂦🂧🂨🂩🂪🂫🂬🂭🂮🂱🂲🂳🂴🂵🂶🂷🂸🂹🂺🂻🂼🂽🂾🃁🃂🃃🃄🃅🃆🃇🃈🃉🃊🃋🃌🃍🃎🃑🃒🃓🃔🃕🃖🃗🃘🃙🃚🃛🃜🃝🃞",
|
||||
"yijing": " ☰☱☲☳☴☵☶☷䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿",
|
||||
"box": " ─│┌┐└┘├┤┬┴┼╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳",
|
||||
"math": " ∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅",
|
||||
"flora": " 🌱🌲🌳🌴🌵🌷🌸🌹🌺🌻🌼🌽🌾🌿🍀🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓",
|
||||
"weather": " 🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞🌟🌠",
|
||||
}
|
||||
|
||||
MAX_GIF_FRAMES = 60
|
||||
|
|
@ -191,6 +212,228 @@ def is_animated_gif(image_path: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# ── Pattern Generators ──────────────────────────────────────────────────
|
||||
|
||||
PATTERN_TYPES = [
|
||||
"plasma", "mandelbrot", "spiral", "waves", "nebula",
|
||||
"kaleidoscope", "aurora", "lava", "crystals", "fractal_tree",
|
||||
]
|
||||
|
||||
|
||||
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
|
||||
"""HSV (0-1 range) to RGB (0-255)."""
|
||||
if s == 0:
|
||||
c = int(v * 255)
|
||||
return c, c, c
|
||||
h6 = h * 6.0
|
||||
i = int(h6)
|
||||
f = h6 - i
|
||||
p = int(v * (1 - s) * 255)
|
||||
q = int(v * (1 - s * f) * 255)
|
||||
t = int(v * (1 - s * (1 - f)) * 255)
|
||||
vi = int(v * 255)
|
||||
if i == 0: return vi, t, p
|
||||
if i == 1: return q, vi, p
|
||||
if i == 2: return p, vi, t
|
||||
if i == 3: return p, q, vi
|
||||
if i == 4: return t, p, vi
|
||||
return vi, p, q
|
||||
|
||||
|
||||
def generate_pattern(
|
||||
pattern_type: str = "plasma",
|
||||
width: int = 200,
|
||||
height: int = 80,
|
||||
palette_name: str = "wingdings",
|
||||
output_format: str = "html",
|
||||
seed: int | None = None,
|
||||
) -> str:
|
||||
"""Generate a colorful pattern as Unicode art."""
|
||||
if seed is None:
|
||||
seed = random.randint(0, 2**31)
|
||||
rng = random.Random(seed)
|
||||
|
||||
chars = PALETTES.get(palette_name, PALETTES["wingdings"])
|
||||
num_chars = len(chars)
|
||||
|
||||
# Random parameters for variety
|
||||
freq1 = rng.uniform(0.02, 0.12)
|
||||
freq2 = rng.uniform(0.01, 0.08)
|
||||
freq3 = rng.uniform(0.03, 0.15)
|
||||
phase1 = rng.uniform(0, math.tau)
|
||||
phase2 = rng.uniform(0, math.tau)
|
||||
phase3 = rng.uniform(0, math.tau)
|
||||
hue_offset = rng.uniform(0, 1)
|
||||
hue_speed = rng.uniform(0.002, 0.02)
|
||||
cx = width * rng.uniform(0.3, 0.7)
|
||||
cy = height * rng.uniform(0.3, 0.7)
|
||||
spirals = rng.randint(2, 8)
|
||||
zoom = rng.uniform(0.5, 3.0)
|
||||
|
||||
lines = []
|
||||
for y in range(height):
|
||||
line = []
|
||||
for x in range(width):
|
||||
# Compute value (0-1) and hue based on pattern type
|
||||
if pattern_type == "plasma":
|
||||
v1 = math.sin(x * freq1 + phase1)
|
||||
v2 = math.sin(y * freq2 + phase2)
|
||||
v3 = math.sin((x + y) * freq3 + phase3)
|
||||
v4 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq1)
|
||||
val = (v1 + v2 + v3 + v4) / 4.0
|
||||
val = (val + 1) / 2 # normalize to 0-1
|
||||
hue = (val * 3 + hue_offset + x * hue_speed) % 1.0
|
||||
sat = 0.7 + 0.3 * math.sin(val * math.pi)
|
||||
bri = 0.4 + 0.6 * val
|
||||
|
||||
elif pattern_type == "mandelbrot":
|
||||
# Map to complex plane
|
||||
re = (x - width * 0.65) / (width * 0.3) * zoom
|
||||
im = (y - height * 0.5) / (height * 0.5) * zoom
|
||||
c = complex(re, im)
|
||||
z = complex(0, 0)
|
||||
iteration = 0
|
||||
max_iter = 80
|
||||
while abs(z) < 4 and iteration < max_iter:
|
||||
z = z * z + c
|
||||
iteration += 1
|
||||
if iteration == max_iter:
|
||||
val = 0
|
||||
hue = 0
|
||||
sat = 0
|
||||
bri = 0.05
|
||||
else:
|
||||
val = iteration / max_iter
|
||||
hue = (val * 5 + hue_offset) % 1.0
|
||||
sat = 0.9
|
||||
bri = 0.3 + 0.7 * val
|
||||
|
||||
elif pattern_type == "spiral":
|
||||
dx = x - cx
|
||||
dy = (y - cy) * 2.2 # aspect correction
|
||||
dist = math.sqrt(dx**2 + dy**2)
|
||||
angle = math.atan2(dy, dx)
|
||||
val = (math.sin(dist * freq1 * 3 - angle * spirals + phase1) + 1) / 2
|
||||
hue = (angle / math.tau + dist * hue_speed + hue_offset) % 1.0
|
||||
sat = 0.8 + 0.2 * math.sin(dist * 0.1)
|
||||
bri = 0.3 + 0.7 * val
|
||||
|
||||
elif pattern_type == "waves":
|
||||
v1 = math.sin(x * freq1 + y * freq2 * 0.5 + phase1)
|
||||
v2 = math.sin(y * freq2 * 2 + x * freq1 * 0.3 + phase2)
|
||||
v3 = math.cos(x * freq3 + phase3) * math.sin(y * freq1)
|
||||
val = (v1 + v2 + v3 + 3) / 6
|
||||
hue = (val * 2 + y * hue_speed * 2 + hue_offset) % 1.0
|
||||
sat = 0.6 + 0.4 * abs(math.sin(val * math.pi * 2))
|
||||
bri = 0.3 + 0.7 * val
|
||||
|
||||
elif pattern_type == "nebula":
|
||||
# Layered noise-like patterns
|
||||
v1 = math.sin(x * freq1 + phase1) * math.cos(y * freq2 + phase2)
|
||||
v2 = math.sin((x + y) * freq3) * math.cos((x - y) * freq1 * 0.7)
|
||||
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy)**2) * freq2 * 0.5 + phase3)
|
||||
v4 = math.sin(x * freq2 * 1.3 + y * freq3 * 0.7 + phase1 * 2)
|
||||
val = (v1 + v2 + v3 + v4 + 4) / 8
|
||||
hue = (val * 4 + hue_offset + math.sin(x * 0.01) * 0.2) % 1.0
|
||||
sat = 0.5 + 0.5 * val
|
||||
bri = 0.1 + 0.9 * val ** 0.7
|
||||
|
||||
elif pattern_type == "kaleidoscope":
|
||||
# Mirror and rotate
|
||||
dx = x - cx
|
||||
dy = (y - cy) * 2.2
|
||||
angle = math.atan2(dy, dx)
|
||||
dist = math.sqrt(dx**2 + dy**2)
|
||||
# Fold angle into segments
|
||||
segments = spirals
|
||||
angle = abs(((angle % (math.tau / segments)) - math.pi / segments))
|
||||
nx = dist * math.cos(angle)
|
||||
ny = dist * math.sin(angle)
|
||||
v1 = math.sin(nx * freq1 + phase1) * math.cos(ny * freq2 + phase2)
|
||||
v2 = math.sin(dist * freq3 + phase3)
|
||||
val = (v1 + v2 + 2) / 4
|
||||
hue = (dist * hue_speed + val + hue_offset) % 1.0
|
||||
sat = 0.7 + 0.3 * math.sin(val * math.pi)
|
||||
bri = 0.2 + 0.8 * val
|
||||
|
||||
elif pattern_type == "aurora":
|
||||
wave = math.sin(x * freq1 + phase1) * 15 + math.sin(x * freq3 + phase3) * 8
|
||||
dist_from_wave = abs(y - height * 0.4 - wave)
|
||||
val = max(0, 1 - dist_from_wave / (height * 0.4))
|
||||
val = val ** 0.5
|
||||
hue = (x * hue_speed + hue_offset + val * 0.3) % 1.0
|
||||
sat = 0.6 + 0.4 * val
|
||||
bri = 0.05 + 0.95 * val
|
||||
|
||||
elif pattern_type == "lava":
|
||||
v1 = math.sin(x * freq1 + y * freq2 * 0.3 + phase1)
|
||||
v2 = math.cos(y * freq2 + x * freq1 * 0.5 + phase2)
|
||||
v3 = math.sin(math.sqrt((x - cx)**2 + (y - cy * 0.8)**2) * freq3)
|
||||
val = (v1 * v2 + v3 + 2) / 4
|
||||
val = val ** 1.5 # push towards extremes
|
||||
hue = (0.0 + val * 0.12 + hue_offset * 0.1) % 1.0 # reds to oranges
|
||||
sat = 0.8 + 0.2 * val
|
||||
bri = 0.15 + 0.85 * val
|
||||
|
||||
elif pattern_type == "crystals":
|
||||
# Voronoi-like
|
||||
min_dist = float('inf')
|
||||
min_dist2 = float('inf')
|
||||
# Use fixed seed points
|
||||
for i in range(12):
|
||||
px = rng.uniform(0, width) if i == 0 else (seed * (i + 1) * 7919) % width
|
||||
py = rng.uniform(0, height) if i == 0 else (seed * (i + 1) * 6271) % height
|
||||
d = math.sqrt((x - px)**2 + ((y - py) * 2.2)**2)
|
||||
if d < min_dist:
|
||||
min_dist2 = min_dist
|
||||
min_dist = d
|
||||
elif d < min_dist2:
|
||||
min_dist2 = d
|
||||
# Reset rng state
|
||||
rng = random.Random(seed)
|
||||
_ = [rng.uniform(0, 1) for _ in range(20)] # burn values to resync
|
||||
edge = min_dist2 - min_dist
|
||||
val = min(1, edge / 20)
|
||||
hue = (min_dist * hue_speed + hue_offset) % 1.0
|
||||
sat = 0.5 + 0.5 * val
|
||||
bri = 0.2 + 0.8 * val
|
||||
|
||||
elif pattern_type == "fractal_tree":
|
||||
# Barnsley fern inspired
|
||||
nx = (x - cx) / (width * 0.3)
|
||||
ny = (height - y) / (height * 0.8)
|
||||
v1 = math.sin(nx * 8 + phase1) * math.cos(ny * 12 + phase2)
|
||||
v2 = math.sin((nx * ny) * 5 + phase3)
|
||||
v3 = math.cos(nx * freq1 * 50) * math.sin(ny * freq2 * 50)
|
||||
val = (v1 + v2 + v3 + 3) / 6
|
||||
hue = (0.25 + val * 0.2 + ny * 0.1 + hue_offset * 0.3) % 1.0 # greens
|
||||
sat = 0.5 + 0.5 * val
|
||||
bri = 0.1 + 0.7 * val * (0.3 + 0.7 * max(0, 1 - abs(nx) * 0.8))
|
||||
|
||||
else:
|
||||
val = 0.5
|
||||
hue = 0.5
|
||||
sat = 0.5
|
||||
bri = 0.5
|
||||
|
||||
r, g, b = _hsv_to_rgb(hue, sat, bri)
|
||||
lum = get_luminance(r, g, b)
|
||||
idx = int(lum / 256 * num_chars)
|
||||
idx = min(idx, num_chars - 1)
|
||||
char = chars[idx]
|
||||
|
||||
if output_format == "html":
|
||||
line.append(f'<span style="color:rgb({r},{g},{b})">{html_escape(char)}</span>')
|
||||
elif output_format == "ansi":
|
||||
line.append(f"\033[38;2;{r};{g};{b}m{char}\033[0m")
|
||||
else:
|
||||
line.append(char)
|
||||
|
||||
lines.append("".join(line))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_demo(image_path: str, width: int = 70):
|
||||
"""Render the same image in multiple palettes for comparison."""
|
||||
from rich.console import Console
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ let currentFile = null;
|
|||
let animationInterval = null;
|
||||
let lastRenderedHtml = '';
|
||||
let zoomScale = 1.0;
|
||||
let currentMode = 'image'; // 'image' or 'pattern'
|
||||
|
||||
const zoomIn = document.getElementById('zoomIn');
|
||||
const zoomOut = document.getElementById('zoomOut');
|
||||
|
|
@ -80,6 +81,90 @@ function setFile(file) {
|
|||
generateBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Mode Switching ──────────────────────────────
|
||||
|
||||
const modeTabs = document.querySelectorAll('.mode-tab');
|
||||
const imageControls = document.getElementById('imageControls');
|
||||
const patternControls = document.getElementById('patternControls');
|
||||
const randomBtn = document.getElementById('randomBtn');
|
||||
const patternHeight = document.getElementById('patternHeight');
|
||||
const heightVal = document.getElementById('heightVal');
|
||||
|
||||
modeTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
modeTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentMode = tab.dataset.mode;
|
||||
|
||||
if (currentMode === 'pattern') {
|
||||
imageControls.style.display = 'none';
|
||||
patternControls.style.display = 'flex';
|
||||
generateBtn.style.display = 'none';
|
||||
randomBtn.style.display = '';
|
||||
} else {
|
||||
imageControls.style.display = 'flex';
|
||||
patternControls.style.display = 'none';
|
||||
generateBtn.style.display = '';
|
||||
randomBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
patternHeight.addEventListener('input', () => {
|
||||
heightVal.textContent = patternHeight.value;
|
||||
});
|
||||
|
||||
randomBtn.addEventListener('click', generatePattern);
|
||||
|
||||
async function generatePattern() {
|
||||
stopAnimation();
|
||||
previewArea.innerHTML = '';
|
||||
spinner.style.display = 'flex';
|
||||
randomBtn.disabled = true;
|
||||
copyBtn.style.display = 'none';
|
||||
downloadBtn.style.display = 'none';
|
||||
compareBox.style.display = 'none';
|
||||
|
||||
let patternType = document.getElementById('patternType').value;
|
||||
if (patternType === 'random') {
|
||||
const types = [...document.getElementById('patternType').options]
|
||||
.map(o => o.value).filter(v => v !== 'random');
|
||||
patternType = types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pattern', patternType);
|
||||
formData.append('width', widthSlider.value);
|
||||
formData.append('height', patternHeight.value);
|
||||
formData.append('palette', document.getElementById('palette').value);
|
||||
formData.append('output_format', 'html');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/pattern', { method: 'POST', body: formData });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const html = await resp.text();
|
||||
lastRenderedHtml = html;
|
||||
spinner.style.display = 'none';
|
||||
previewArea.innerHTML = html;
|
||||
|
||||
copyBtn.style.display = '';
|
||||
downloadBtn.style.display = '';
|
||||
|
||||
// Auto-fit
|
||||
requestAnimationFrame(() => {
|
||||
const containerWidth = previewArea.clientWidth - 24;
|
||||
const artWidth = previewArea.scrollWidth;
|
||||
if (artWidth > containerWidth) {
|
||||
setZoom(containerWidth / artWidth);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
spinner.style.display = 'none';
|
||||
previewArea.innerHTML = `<p class="placeholder" style="color:#ff453a">${err.message}</p>`;
|
||||
}
|
||||
randomBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Width Slider ──────────────────────────────
|
||||
|
||||
widthSlider.addEventListener('input', () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,34 @@ header {
|
|||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* ── Mode Tabs ─────────────────────────────── */
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--surface2);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-tab:hover { color: var(--text); }
|
||||
.mode-tab.active { background: var(--accent); color: #000; }
|
||||
|
||||
.mode-controls { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ── Upload Zone ─────────────────────────────── */
|
||||
|
||||
.upload-zone {
|
||||
|
|
|
|||
|
|
@ -16,19 +16,46 @@
|
|||
|
||||
<!-- Controls Bar -->
|
||||
<div class="controls">
|
||||
<div class="upload-zone" id="dropZone">
|
||||
<span class="upload-icon">🎨</span>
|
||||
<p><label for="fileInput" class="browse-link">Upload image</label></p>
|
||||
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
|
||||
<!-- Mode tabs -->
|
||||
<div class="mode-tabs">
|
||||
<button class="mode-tab active" data-mode="image">🖼 Image</button>
|
||||
<button class="mode-tab" data-mode="pattern">✨ Patterns</button>
|
||||
</div>
|
||||
<div id="fileInfo" class="file-info" style="display:none">
|
||||
<img id="thumbPreview" class="thumb">
|
||||
<div>
|
||||
<span id="fileName"></span>
|
||||
<button id="clearFile" class="btn-small" title="Clear">×</button>
|
||||
|
||||
<!-- Image mode controls -->
|
||||
<div id="imageControls" class="mode-controls">
|
||||
<div class="upload-zone" id="dropZone">
|
||||
<span class="upload-icon">🎨</span>
|
||||
<p><label for="fileInput" class="browse-link">Upload</label></p>
|
||||
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
|
||||
</div>
|
||||
<div id="fileInfo" class="file-info" style="display:none">
|
||||
<img id="thumbPreview" class="thumb">
|
||||
<div>
|
||||
<span id="fileName"></span>
|
||||
<button id="clearFile" class="btn-small" title="Clear">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern mode controls -->
|
||||
<div id="patternControls" class="mode-controls" style="display:none">
|
||||
<div class="setting">
|
||||
<label for="patternType">Pattern</label>
|
||||
<select id="patternType">
|
||||
{% for p in patterns %}
|
||||
<option value="{{ p }}">{{ p }}</option>
|
||||
{% endfor %}
|
||||
<option value="random">🎲 random</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<label for="patternHeight">H <span id="heightVal">80</span></label>
|
||||
<input type="range" id="patternHeight" min="20" max="200" value="80">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared controls -->
|
||||
<div class="settings-grid">
|
||||
<div class="setting">
|
||||
<label for="palette">Palette</label>
|
||||
|
|
@ -41,7 +68,7 @@
|
|||
</select>
|
||||
</div>
|
||||
<div class="setting">
|
||||
<label for="width">Width <span id="widthVal">200</span></label>
|
||||
<label for="width">W <span id="widthVal">200</span></label>
|
||||
<input type="range" id="width" min="40" max="500" value="200">
|
||||
</div>
|
||||
<div class="setting">
|
||||
|
|
@ -58,6 +85,7 @@
|
|||
</div>
|
||||
|
||||
<button id="generateBtn" class="btn-generate" disabled>Generate</button>
|
||||
<button id="randomBtn" class="btn-generate" style="display:none;background:linear-gradient(135deg,#ff6b6b,#ffd93d,#6bff6b,#6bbbff,#bf5af2)">🎲 Random</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview (fills viewport) -->
|
||||
|
|
|
|||
Loading…
Reference in New Issue