Files
epaper-dashboard/renderer.py
T
ki af9a99a379 Add classic design, set as default
Classic = original flat white design (matches reference screenshot)
2026-08-26 15:15:09 +04:00

503 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Render-Pipeline für das Dashboard.
Unterstützte Display-Designs:
classic — Original flach, weiß, kein UI-Chrome (DEFAULT)
minimal — Schwarz/Weiß, bold, clean, viel Weißraum
minimal_color — Minimal + dezenter Akzent-Strich pro Kategorie
bold — Riesen-Typography, 1px-Rahmen, vollflächig
analog — 2×2-Uhr als Ziffernblatt, Rest clean Panels
magazine — Bold Typography, farbige Akzentstreifen links
cards — iOS-Widget-Style, Emoji-Icons, warme Paneele
kiosk — Dunkel, Daten-first, kompakte Info-Zellen
wireframe — Blueprint-Style, gestrichelte Rahmen, keine Füllungen
bauhaus — Geometrische Primärfarben, blockweise
poster — Farbige Flächen, bold, fast nur Content
retro_lcd — Alte LCD-Uhren-Ästhetik, grüner Hintergrund
glossy — Moderner iOS-Style, Emoji-Icons, Panels
data_board — Kontrollraum-Dashboard, dunkel, invertierte Karten
Aufruf:
img = render_design(items, widgets, fonts, design="classic")
"""
from PIL import Image, ImageDraw, ImageFont
from palette import FG, BG, WHITE, BLACK, GREEN, BLUE, RED, YELLOW, ORANGE, OK, WARN, ALERT, INFO, ACCENT
import math
DISPLAY_W = 800
DISPLAY_H = 480
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
def render_design(items, widgets, fonts, design="classic") -> Image.Image:
fn = {
"magazine": _render_magazine,
"cards": _render_cards,
"kiosk": _render_kiosk,
"wireframe": _render_wireframe,
"bauhaus": _render_bauhaus,
"poster": _render_poster,
"retro_lcd": _render_retro_lcd,
"glossy": _render_glossy,
"data_board": _render_data_board,
"minimal": _render_minimal,
"minimal_color": _render_minimal_color,
"bold": _render_bold,
"analog": _render_analog_clock,
"classic": _render_classic,
}.get(design, _render_classic)
return fn(items, widgets, fonts)
# ============================================================================
# MAGAZINE — Bold Typography, Minimalist
# Idee: Wanduhr-Magazin / Bloomberg-Terminal-Ästhetik
# Schwarz/Weiß + eine Akzentfarbe pro Widget
# ============================================================================
def _render_magazine(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Jedes Widget bekommt einen vertikalen Akzent-Streifen links
accent = _widget_accent(widget.name)
draw.rectangle((x, y, x + 6, y + h - 1), fill=accent)
# Widget-Name oben links, klein
label = (widget.label or widget.name or "").upper()
draw.text((x + 14, y + 6), label, font=fonts.get("16", fonts.get("default")), fill=(160, 160, 160))
# Horizontale Linie unter Label
draw.line((x + 14, y + 26, x + w - 10, y + 26), fill=(220, 220, 220), width=1)
# Content-Bereich (ab y+32)
cy = y + 36
ch = h - 42
widget.render(draw, fonts, x + 14, cy, w - 20, ch)
return img
# ============================================================================
# CARDS — Background Panels + Emoji Icons + Vertical Accent Bars
# Idee: iOS Widgets / Google Smart Display
# Leicht grauer Hintergrund pro Karte, Emoji für Kategorie
# ============================================================================
def _render_cards(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 244, 240)) # warm white
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Card background
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE)
# Top accent bar
accent = _widget_accent(widget.name)
draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=accent)
# Emoji icon (use emoji renderer via fallback)
icon = _widget_icon(widget.name)
_draw_emoji(draw, icon, x + 10, y + 18, 28)
# Label
label = widget.label or widget.name or ""
draw.text((x + 44, y + 18), label, font=fonts.get("16", fonts.get("default")), fill=(80, 80, 80))
# Separator line
draw.line((x + 8, y + 52, x + w - 8, y + 52), fill=(230, 230, 230), width=1)
# Content
widget.render(draw, fonts, x + 8, y + 58, w - 16, h - 64)
return img
# ============================================================================
# KIOSK — Data-Dense, Compact, Multi-Zone
# Idee: Control-Room / LoRa-Tracker / Science-Dashboard
# Viele kleine Info-Zellen, horizontale Teilung, monochrome Palette
# ============================================================================
def _render_kiosk(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _pixels(item)
if widget is None:
continue
# Invertierte Box mit Akzent-Rand
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE)
accent = _widget_accent(widget.name)
draw.rectangle((x, y, x + w - 1, y + 3), fill=accent)
# Label in Akzentfarbe oben
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=accent)
# Content
widget.render(draw, fonts, x + 6, y + 28, w - 12, h - 34)
return img
# ============================================================================
# Helper
# ============================================================================
def _pixels(item):
"""Item (x,y,w,h) in Pixel (800x480, 4x4 grid)"""
COLS, ROWS = 4, 4
CELL_W = DISPLAY_W // COLS
CELL_H = DISPLAY_H // ROWS
return item.x * CELL_W, item.y * CELL_H, item.w * CELL_W, item.h * CELL_H
# Alias für neue Designs
def _px(item):
return _pixels(item)
def _widget_accent(name):
accents = {
"clock": BLUE,
"weather": GREEN,
"system": ORANGE,
"hello": RED,
"spotify": (0, 200, 0), # spotify green
"strava": ORANGE,
"gmail": RED,
"minimax": (180, 100, 255), # purple
}
return accents.get(name, ORANGE)
def _widget_icon(name):
icons = {
"clock": "⏰",
"weather": "🌤️",
"system": "💻",
"hello": "👋",
"spotify": "🎵",
"strava": "🚴",
"gmail": "📧",
"minimax": "🤖",
}
return icons.get(name, "📟")
def _draw_emoji(draw, emoji, x, y, size):
"""Draw emoji by using a fallback ImageFont trick with the emoji char."""
# On systems without an emoji font, this just draws a rectangle placeholder
# The real implementation would need a font with emoji support
# For now draw a colored square as placeholder
colors = {
"⏰": (80, 160, 255),
"🌤️": (255, 200, 80),
"💻": (100, 100, 100),
"👋": (255, 160, 120),
"🎵": (0, 200, 100),
"🚴": (255, 140, 0),
"📧": (220, 80, 80),
"🤖": (180, 100, 255),
}
c = colors.get(emoji, (200, 200, 200))
draw.ellipse((x, y, x + size, y + size), fill=c)
# ============================================================================
# DESIGN 5: WIREFRAME
# Technisches Plan-Drawing. Blaue Linien, weiße Flächen.
# Blueprint-Ästhetik.
# ============================================================================
def _render_wireframe(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (240, 248, 255))
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=BLUE, width=1)
draw.rectangle((x + 2, y + 2, x + w - 3, y + 10), fill=BLUE)
label = (widget.label or widget.name or "").upper()
draw.text((x + 10, y + 14), label, font=fonts.get("14", fonts.get("default")), fill=BLUE)
for row in range(item.y, item.y + item.h):
for col in range(item.x, item.x + item.w):
if (row + col) % 2 == 0:
cx = col * CELL_W + 4
cy = row * CELL_H + 4
cw = CELL_W - 6
ch = CELL_H - 6
draw.rectangle((cx, cy, cx + cw, cy + ch), fill=(240, 248, 255))
widget.render(draw, fonts, x + 6, y + 32, w - 12, h - 38)
return img
# ============================================================================
# DESIGN 6: BAUHAUS
# Geometrische Primärfarben. Jedes Widget bekommt eine der
# 7 ACeP-Farben als Hintergrund-Balken links.
# ============================================================================
def _render_bauhaus(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240))
draw = ImageDraw.Draw(img)
PALETTE_7 = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)]
for i, (item, widget) in enumerate(zip(items, widgets)):
x, y, w, h = _px(item)
if widget is None:
continue
color = PALETTE_7[i % len(PALETTE_7)]
block_w = w // 3
draw.rectangle((x + 2, y + 2, x + block_w - 2, y + h - 3), fill=color)
draw.rectangle((x + block_w, y + 2, x + w - 3, y + h - 3), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 3, y + h - 3), outline=FG, width=2)
label = (widget.label or widget.name or "").upper()
draw.text((x + 6, y + 8), label, font=fonts.get("14", fonts.get("default")), fill=WHITE)
widget.render(draw, fonts, x + block_w + 4, y + 4, w - block_w - 8, h - 8)
return img
# ============================================================================
# DESIGN 7: POSTER
# Farbige Flächen, bold, fast nur Content.
# Jedes Widget in einer kräftigen Farbe.
# ============================================================================
def _render_poster(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BLACK)
draw = ImageDraw.Draw(img)
colors = [RED, GREEN, BLUE, YELLOW, ORANGE, (0, 200, 200), (200, 100, 200)]
for i, (item, widget) in enumerate(zip(items, widgets)):
x, y, w, h = _px(item)
if widget is None:
continue
c = colors[i % len(colors)]
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=c)
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=WHITE, width=3)
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:18], font=fonts.get("12", fonts.get("default")), fill=WHITE)
widget.render(draw, fonts, x + 6, y + 24, w - 12, h - 30)
return img
# ============================================================================
# DESIGN 8: RETRO LCD
# Alte LCD-Uhren-Ästhetik. Dunkelgrüner Hintergrund,
# leuchtende grüne Ziffern, Fake-3D-Rahmen.
# ============================================================================
def _render_retro_lcd(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (0, 20, 0))
draw = ImageDraw.Draw(img)
LCD_GREEN = (0, 255, 120)
LCD_DARK = (0, 60, 30)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 3, y + 3, x + w - 4, y + h - 4), fill=LCD_DARK)
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=LCD_GREEN, width=2)
label = (widget.label or widget.name or "").upper()
draw.text((x + 8, y + 6), label[:16], font=fonts.get("11", fonts.get("default")), fill=LCD_GREEN)
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=LCD_GREEN, width=1)
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 32)
return img
# ============================================================================
# DESIGN 9: GLOSSY
# Moderne iOS-Widget-Ästhetik. Weiße Karten, Glossy-Header
# in Accent-Farbe, dezente Schatten-Rahmen.
# ============================================================================
def _render_glossy(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (220, 228, 235))
draw = ImageDraw.Draw(img)
WIDGET_ACCENTS = {
"clock": (80, 160, 255),
"weather": (60, 200, 100),
"system": (255, 160, 40),
"hello": (200, 80, 80),
"spotify": (0, 180, 80),
"strava": (255, 120, 0),
"gmail": (200, 60, 60),
"minimax": (160, 80, 255),
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
accent = WIDGET_ACCENTS.get(widget.name, (100, 100, 100))
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
draw.rectangle((x + 2, y + 2, x + w - 2, y + 14), fill=accent)
# Glossy highlight
for i in range(6):
alpha = int(80 - i * 12)
c = tuple(min(255, accent[j] + alpha) for j in range(3))
draw.line((x + 2, y + 2 + i, x + w - 2, y + 2 + i), fill=c, width=1)
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), outline=(200, 210, 220), width=1)
draw.rectangle((x + 3, y + 3, x + w - 3, y + h - 3), outline=(220, 228, 235), width=1)
draw.rectangle((x + 4, y + 4, x + w - 4, y + h - 4), outline=(180, 190, 200), width=1)
label = (widget.label or widget.name or "").title()
draw.text((x + 10, y + 18), label, font=fonts.get("14", fonts.get("default")), fill=accent)
draw.line((x + 8, y + 36, x + w - 8, y + 36), fill=(220, 220, 220), width=1)
widget.render(draw, fonts, x + 6, y + 40, w - 12, h - 46)
return img
# ============================================================================
# DESIGN 10: DATA BOARD
# Kontrollraum-Ästhetik. Winzige Zellen, viele Daten.
# Monochrome, Tick-Marks, Data-first.
# ============================================================================
def _render_data_board(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (10, 10, 15))
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x, y, x + w - 1, y + h - 1), fill=WHITE, width=2)
draw.rectangle((x, y, x + w - 1, y + 22), fill=WHITE)
draw.text((x + 6, y + 4), (widget.label or widget.name or "").upper(),
font=fonts.get("12", fonts.get("default")), fill=BLACK)
for tx in range(x + 10, x + w - 10, 20):
draw.line((tx, y + 24, tx, y + 28), fill=(180, 180, 180), width=1)
widget.render(draw, fonts, x + 4, y + 30, w - 8, h - 34)
return img
# ============================================================================
# CLASSIC — Original flaches Design
# Weißer Hintergrund, kein UI-Chrome, widget füllt Zelle aus
# Das was auf deinem Referenz-Bild zu sehen ist
# ============================================================================
def _render_classic(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
widget.render(draw, fonts, x + 8, y + 8, w - 16, h - 16)
return img
# ============================================================================
# MINIMAL — das Referenz-Design
# Schwarz/Weiß, bold, clean, viel Weißraum. Kein UI-Chrome.
# ============================================================================
def _render_minimal(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
widget.render(draw, fonts, x + 12, y + 12, w - 24, h - 24)
return img
# ============================================================================
# MINIMAL COLOR — Minimal mit dezenter Akzentfarbe pro Kategorie
# Kleiner Farbbalken oben links, sonst weiß + schwarz
# ============================================================================
def _render_minimal_color(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
CATEGORY_COLORS = {
"clock": (60, 80, 200),
"weather": (50, 160, 80),
"system": (200, 120, 30),
"hello": (180, 60, 60),
"spotify": (0, 160, 80),
"strava": (230, 110, 20),
"gmail": (200, 60, 60),
"minimax": (140, 80, 240),
}
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
color = CATEGORY_COLORS.get(widget.name, FG)
draw.rectangle((x + 12, y + 10, x + 16, y + 18), fill=color)
label = (widget.label or widget.name or "").upper()
draw.text((x + 22, y + 8), label, font=fonts.get("11", fonts.get("default")), fill=(160, 160, 160))
widget.render(draw, fonts, x + 12, y + 24, w - 24, h - 36)
return img
# ============================================================================
# BOLD — Riesen-Typography
# Zahlen und Text füllen die Zelle komplett.
# Kaum Weißraum, maximaler Informationsdichte.
# ============================================================================
def _render_bold(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), WHITE)
draw = ImageDraw.Draw(img)
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
draw.rectangle((x + 1, y + 1, x + w - 2, y + h - 2), outline=BLACK, width=1)
widget.render(draw, fonts, x + 6, y + 6, w - 12, h - 12)
return img
# ============================================================================
# ANALOG CLOCK — Die Uhr als Mittelpunkt
# 2x2-Clock-Zellen zeigen ein analoges Ziffernblatt.
# Übrige Zellen: cleanes Panel mit Monospace.
# ============================================================================
def _render_analog_clock(items, widgets, fonts) -> Image.Image:
img = Image.new("RGB", (DISPLAY_W, DISPLAY_H), (245, 245, 240))
draw = ImageDraw.Draw(img)
import math
for item, widget in zip(items, widgets):
x, y, w, h = _px(item)
if widget is None:
continue
is_2x2 = item.w >= 2 and item.h >= 2
is_clock = widget.name == "clock" and is_2x2
if is_clock:
cx, cy = x + w // 2, y + h // 2
r = min(w, h) // 2 - 16
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE, outline=BLACK, width=2)
for i in range(12):
angle = (i / 12) * 2 * math.pi - math.pi / 2
mx = cx + int((r - 12) * math.cos(angle))
my = cy + int((r - 12) * math.sin(angle))
draw.ellipse((mx - 3, my - 3, mx + 3, my + 3), fill=BLACK)
from datetime import datetime
now = datetime.now()
hour_angle = ((now.hour % 12) / 12) * 2 * math.pi - math.pi / 2
min_angle = (now.minute / 60) * 2 * math.pi - math.pi / 2
hx = cx + int(r * 0.5 * math.cos(hour_angle))
hy = cy + int(r * 0.5 * math.sin(hour_angle))
draw.line((cx, cy, hx, hy), fill=BLACK, width=4)
mx = cx + int(r * 0.75 * math.cos(min_angle))
my = cy + int(r * 0.75 * math.sin(min_angle))
draw.line((cx, cy, mx, my), fill=BLACK, width=2)
draw.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=BLACK)
draw.text((cx - 40, cy + r + 8), now.strftime("%H:%M"),
font=fonts.get("20", fonts.get("default")), fill=BLACK)
else:
draw.rectangle((x + 2, y + 2, x + w - 2, y + h - 2), fill=WHITE)
draw.ellipse((x + 8, y + 8, x + 14, y + 14), fill=(60, 100, 200))
label = (widget.label or widget.name or "").upper()[:12]
draw.text((x + 20, y + 6), label, font=fonts.get("12", fonts.get("default")), fill=(100, 100, 100))
draw.line((x + 8, y + 22, x + w - 8, y + 22), fill=(200, 200, 200), width=1)
widget.render(draw, fonts, x + 6, y + 26, w - 12, h - 34)
return img