Weiter zum Inhalt
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Hell
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dunkel
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Skripten / Logik
  4. JavaScript
  5. Kontakte (Cards)

NEWS

  • Neues YouTube-Video: Visualisierung im Devices-Adapter
    BluefoxB
    Bluefox
    14
    1
    2.3k

  • Neuer ioBroker-Blog online: Monatsrückblick März/April 2026
    BluefoxB
    Bluefox
    8
    1
    2.7k

  • Verwendung von KI bitte immer deutlich kennzeichnen
    HomoranH
    Homoran
    11
    1
    1.5k

Kontakte (Cards)

Geplant Angeheftet Gesperrt Verschoben JavaScript
javascriptmultimediatemplate
32 Beiträge 4 Kommentatoren 991 Aufrufe 5 Beobachtet
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • Ro75R Online
    Ro75R Online
    Ro75
    schrieb am zuletzt editiert von Ro75
    #1

    In unserem Dashboard stehen alle wichtigen Kontakte als moderne, dynamische Karten bereit – so hat jedes Familienmitglied jederzeit Zugriff auf die aktuellsten Informationen, ohne suchen zu müssen.
    Die Daten liegen zentral in einer JSON, aus der automatisch schicke SVG‑Karten erzeugt werden, die sich direkt in VIS oder jede andere Visualisierung einfügen lassen.

    Sobald ein Eintrag geändert oder ein neuer Kontakt hinzugefügt wird, aktualisiert das System die Karten vollautomatisch.
    Keine Pflege an mehreren Stellen, keine veralteten Infos – einmal eintragen, überall aktuell.

    Das Script erzeugt beim ersten Start automatisch einen kompletten Beispieldatensatz mit drei fertigen Karten – inklusive der passenden SVG‑Grafiken.
    So sieht man sofort, wie das System funktioniert und wie die Karten später in der Visualisierung aussehen.

    card.png

    HINWEIS: Ein Teil des Codes wurde dabei mit Unterstützung einer KI (Microsoft Copilot) entwickelt, um die Erstellung noch effizienter und moderner zu gestalten.

    Hier der Code:

    // === Kontakte (CARDS) ===
     
    //Erst-Version 1.0.0 - 25.05.2026
    //Version 1.0.8 - 05.06.2026
    //Ersteller Ro75.
     
    //Voraussetzungen (Version 1.0.8 getestet mit)
    //NodeJS: 22.22.x
    //Javascript-Adapter: 9.0.18
    //Admin-Adapter: 7.8.23
    //js-controler: 7.0.7
    
    //********** START KONFIGURATION **********
    const root     = "0_userdata.0.Kontakte."
    const JSON_DP  = root + "json";
    const SVG_ROOT = root + "svg.";
    
    // zentrale SVG-Größen
    const CARD_LARGE_W = 350;
    const CARD_LARGE_H = 360;
    
    const CARD_SMALL_W = 350;
    const CARD_SMALL_H = 130;
    
    const FONT = {
        family: "InterVariable",   // Globale Schriftfamilie für alle Karten
    
        // STANDARD – neutral, technisch, kühl
        standard: {
            name:    { size: 16, color: "#ffffff", bold: true,  italic: false },  // Name / Praxis / Firma
            address: { size: 13, color: "#d0d7e2", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#9fb3d9", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#6fa8ff", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#ffffff", bold: true,  italic: false },  // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#d0d7e2", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#ffcc66", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // WAVE – kühl, frisch, maritim
        wave: {
            name:    { size: 16, color: "#163a70", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#173d74", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#173d74", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#2563eb", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#1d4ed8", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#173d74", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // TEAL – modern, kühl, glasig
        teal: {
            name:    { size: 16, color: "#ffffff", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#e8ffef", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#e8ffef", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#e8ffef", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#e8ffef", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#e8ffef", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // WHITE – hell, neutral, klar
        white: {
            name:    { size: 16, color: "#1a1a1a", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#2d3748", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#2d3748", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#33691e", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#254618", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#2d3748", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // COFFEE – warm, erdig, gemütlich
        coffee: {
            name:    { size: 16, color: "#4E342E", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#4E342E", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#4E342E", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#6D4C41", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#4E342E", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#4E342E", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#4E342E", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // YELLOW – warm, freundlich, sonnig
        yellow: {
            name:    { size: 16, color: "#5D4037", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#4E342E", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#4E342E", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#F57F17", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#5D4037", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#4E342E", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#5D4037", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // GREEN – natur, frisch, harmonisch
        green: {
            name:    { size: 16, color: "#1B5E20", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#2E7D32", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#2E7D32", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#1B5E20", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#1B5E20", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#2E7D32", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#1B5E20", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // RED – kräftig, energisch, dominant
        red: {
            name:    { size: 16, color: "#B71C1C", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#C62828", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#C62828", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#B71C1C", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#B71C1C", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#C62828", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#B71C1C", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        // BLUE – kühl, seriös, sachlich
        blue: {
            name:    { size: 16, color: "#0D47A1", bold: true, italic: false },   // Name / Praxis / Firma
            address: { size: 13, color: "#1E3A5F", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#1E3A5F", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#1565C0", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#0D47A1", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#1E3A5F", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#0D47A1", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        weight: {
            normal: 400,   // Normalgewicht
            bold:   600    // Fett
        }
    };
    
    const CARD_THEMES = {
        white: {
            fontTheme: "white",                      // Name des Font-Themes
            iconBg: "#43A047",                       // Hintergrundfarbe der runden Icons
            iconStroke: "#ffffff",                   // Farbe der Icons
            titleUnderline: "#43A047",               // Linie unter dem Namen
            avatarStroke: "#43A047",                 // Rahmen um das Bild/Avatar
            timesBoxBg: "rgba(190, 235, 159, 0.15)", // Hintergrund Öffnungszeiten-Box
            timesBoxBorder: "rgba(0,0,0,0.15)",      // Rahmen Öffnungszeiten-Box
            background: "rgba(255,255,255,1)"        // Hintergrund Uni-Card
        },
        coffee: {
            fontTheme: "coffee",
            iconBg: "#6D4C41",
            iconStroke: "#F5F5F5",
            titleUnderline: "#6D4C41",
            avatarStroke: "#6D4C41",
            timesBoxBg: "rgba(209, 188, 175, 0.25)",
            timesBoxBorder: "#A1887F",
            background: "rgba(245,245,245,1)"
        },
        yellow: {
            fontTheme: "yellow",
            iconBg: "#F9A825",
            iconStroke: "#ffffff",
            titleUnderline: "#F9A825",
            avatarStroke: "#F9A825",
            timesBoxBg: "rgba(255, 224, 130, 0.25)",
            timesBoxBorder: "#FBC02D",
            background: "rgba(255,253,231,1)"
        },
        green: {
            fontTheme: "green",
            iconBg: "#2E7D32",
            iconStroke: "#ffffff",
            titleUnderline: "#2E7D32",
            avatarStroke: "#2E7D32",
            timesBoxBg: "rgba(200, 230, 201, 0.25)",
            timesBoxBorder: "#66BB6A",
            background: "rgba(241,248,233,1)"
        },
        red: {
            fontTheme: "red",
            iconBg: "#C62828",
            iconStroke: "#ffffff",
            titleUnderline: "#C62828",
            avatarStroke: "#C62828",
            timesBoxBg: "rgba(255, 205, 210, 0.25)",
            timesBoxBorder: "#EF5350",
            background: "rgba(255,235,238,1)"
        },
        blue: {
            fontTheme: "blue",
            iconBg: "#1E88E5",
            iconStroke: "#ffffff",
            titleUnderline: "#1E88E5",
            avatarStroke: "#1E88E5",
            timesBoxBg: "rgba(144, 202, 249, 0.25)",
            timesBoxBorder: "#64B5F6",
            background: "rgba(227,242,253,1)"
        },
        wave: {
            fontTheme: "wave",
            iconBg: "#60A5FA",                        // Hintergrund der runden Icons
            iconStroke: "#ffffff",                    // Farbe der Icons
            titleUnderline: "#3B82F6",                // Linie unter dem Namen
            avatarStroke: "#93C5FD",                  // Avatar-Rahmen
            timesBoxBg: "rgba(191,219,254,0.38)",     // Öffnungszeiten-Box Hintergrund
            timesBoxBorder: "#93C5FD",                // Öffnungszeiten-Box Rahmen
        },
        teal: {
            fontTheme: "teal",
            iconBg: "#E8FFEF",                        // Hintergrund der runden Icons
            iconStroke: "#4A7763",                    // Farbe der Icons
            titleUnderline: "#FFFFFF",                // Linie unter dem Namen
            avatarStroke: "rgba(255,255,255,0.35)",   // Avatar-Rahmen
            timesBoxBg: "rgba(232,255,239,0.16)",     // Öffnungszeiten-Box Hintergrund
            timesBoxBorder: "rgba(255,255,255,0.45)", // Öffnungszeiten-Box Rahmen
        }
    };
    
    const AVATAR = {
        aura: {
            inner: {color: "#6E463E", opacity: 0.22}, // stärkster innerer Aura-Kern
            mid:   {color: "#6E463E", opacity: 0.08}, // mittlere Aura
            outer: {color: "#6E463E", opacity: 0.00}  // äußerer Rand, auslaufend
        },
    
        glass: {
            core:  {color: "#ffffff", opacity: 1.00}, // hellster Kern
            glow1: {color: "#6E463E", opacity: 0.92}, // starker Glow
            glow2: {color: "#6E463E", opacity: 0.48}, // mittlerer Glow
            glow3: {color: "#6E463E", opacity: 0.12}, // schwacher Glow
            fade:  {color: "#6E463E", opacity: 0.00}  // auslaufender Rand
        },
    
        glowFactorSmall: 1.45,           // Glow‑Dunkelheits‑Faktor NUR für kleine Karte
        auraFactorSmall: 0.45,           // Aura der kleinen Karte abdunkeln
        ring: "rgba(255,255,255,0.30)",  // äußerer Ring
        background: "#0f2647"            // Avatar-Hintergrund
    };
    
    /*
    HINWEIS: Sobald das Script das erste mal gestartet wurde, gibt es im Hauptordner den Datenpunkt "json".
             Dieser beinhaltet 3 Sätze (Karten / Kontakte) mit Beispieldaten
    
            JSON‑Datensatz – Anforderungen (kurz & eindeutig)
    
            • Jeder Eintrag benötigt eine eindeutige ID und muss sich exakt an die Struktur der Beispieldaten halten.
            • Telefonnummern dürfen mehrere Werte enthalten, getrennt durch Komma oder Semikolon.
            • Öffnungszeiten müssen exakt im Format „Wochentag HH.MM – HH.MM[, HH.MM – HH.MM]“ stehen
            und dabei zwingend den echten EN‑DASH (–) verwenden — kein Minuszeichen (-).
            • Das Bildfeld („bild“) kann eine WWW‑URL oder eine ioBroker‑interne URL sein,
            z. B. http://192.168.10.99:8082/vis.0/kontakte/xyz.png
            und es werden PNG, JPG und SVG unterstützt.
    */
    
    //********** ENDE KONFIGURATION **********
    
    const ICON_LOCATION = `<path d="M21 10c0 6-9 13-9 13S3 16 3 10a9 9 0 1 1 18 0z"/><circle cx="12" cy="10" r="3"/>`;
    const ICON_PHONE    = `<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.89.32 1.76.59 2.59a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.49-1.16a2 2 0 0 1 2.11-.45c.83.27 1.7.47 2.59.59A2 2 0 0 1 22 16.92z"/>`;
    const ICON_LIST     = `<path d="M6 18h12"/><path d="M6 14h12"/><path d="M6 10h12"/><rect x="4" y="4" width="16" height="16" rx="2"/>`;
    const ICON_MAIL     = `<path d="M4 4h16v16H4z"/><path d="M22 6l-10 7L2 6"/>`;
    const ICON_GLOBE    = `<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20"/><path d="M12 2a15 15 0 0 0 0 20"/>`;
    const ICON_CLOCK    = `<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>`;
    const ICON_FAX      = `<path d="M4 4h16v16H4z"/><path d="M8 2h8v4H8z"/><path d="M6 10h12"/><path d="M6 14h12"/>`;
    
    let TEXT_SHADOW_STYLE = "text-shadow:none;";
    
    function fontStyle(F) {
        return `
            font-size:${F.size}px !important;
            font-weight:${F.weight} !important;
            font-style:${F.style} !important;
        `;
    }
    
    function fontCfg(theme, type) {
        return FONT[theme]?.[type] ?? FONT.standard[type];
    }
    
    function fontAttr(theme, type) {
        const f = fontCfg(theme, type);
    
        return {
            family: FONT.family, size: f.size, color: f.color, bold: f.bold, italic: f.italic, weight: f.bold ? FONT.weight.bold : FONT.weight.normal, style: f.italic ? "italic" : "normal"
        };
    }
    
    async function smartCreateState(id, value, options = {}) {
        if (existsState(id)) return;
        await createState(id, value, options);
    }
    
    function sleepMs(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    function base64Decode(str) {
        return Buffer.from(str, "base64").toString("utf8");
    }
    
    function decodeSampleData() {
        const decoded = base64Decode(SAMPLE_DATA_BASE64);
        return JSON.parse(decoded);
    }
    
    async function processJSON() {
        try {
            const raw = getState(JSON_DP).val;
            if (!raw) return;
    
            const data = JSON.parse(raw);
            if (!data.eintraege || !Array.isArray(data.eintraege)) return;
    
            for (const entry of data.eintraege) {
                const svg = buildSVG(entry);
                const dp = SVG_ROOT + entry.id;
    
                await smartCreateState(dp, "", { type: "string", name: "SVG Karte " + entry.id });
                await sleepMs(100);
    
                setStateIfChanged(dp, svg);
            }
        } catch (err) {
        }
    }
    
    function normalizeDays(str) {
        const map = {"Montag": "Mo", "Dienstag": "Di", "Mittwoch": "Mi", "Donnerstag": "Do", "Freitag": "Fr", "Samstag": "Sa", "Sonntag": "So"};
        const rangeRegex = /(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)\s*[–-]\s*(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)/g;
    
        str = str.replace(rangeRegex, (m, a, b) => `${map[a]}–${map[b]}`);
        for (const full in map) str = str.replace(new RegExp(full, "g"), map[full]);
        return str;
    }
    
    function compressTimes(times) {
        const full    = ["Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag","Sonntag"];
        const short   = ["Mo","Di","Mi","Do","Fr","Sa","So"];
        const special = {"Feiertage": "Feiertage", "Brückentage": "Brückentage"};
    
        const parsed = times.map(t => {
            const [day, ...rest] = t.split(" ");
            let time = rest.join(" ");
    
            time = time.replace(/–/g, "-");
    
            if (special[day]) {
                return { day, short: special[day], time, special: true };
            }
            return {day, short: short[full.indexOf(day)], time, special: false};
        });
    
        const groups = {};
        const specials = [];
    
        parsed.forEach(p => {
            if (p.special) {
                specials.push(`${p.short} ${p.time}`);
            } else {
                if (!groups[p.time]) groups[p.time] = [];
                groups[p.time].push(p.short);
            }
        });
    
        const result = [];
        const order = ["Mo","Di","Mi","Do","Fr","Sa","So"];
    
        for (const time in groups) {
            const list          = groups[time];
            const indices       = list.map(d => order.indexOf(d));
            const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
    
            if (list.length > 1 && isConsecutive) {
                result.push(`${list[0]}–${list[list.length - 1]} ${time}`);
            } else {
                result.push(`${list.join(", ")} ${time}`);
            }
        }
        return result.concat(specials);
    }
    
    function splitTimeAtomic(row) {
        const idx = row.search(/\d/);
        if (idx === -1) return { day: row.trim(), parts: [] };
    
        const dayPart  = row.slice(0, idx).trim();
        const timePart = row.slice(idx).trim();
    
        const blocks = timePart.split(",");
        const parts = [];
    
        blocks.forEach(block => {
            const m = block.trim().match(/^(\S+)\s*[-–]\s*(\S+)$/);
            if (m) parts.push({ start: m[1], end: m[2] });
        });
    
        return { day: dayPart, parts };
    }
    
    function wrapText(text, maxLen = 32) {
        const words = text.split(" ");
        const lines = [];
        let current = "";
    
        for (const w of words) {
            if ((current + w).length > maxLen) {
                lines.push(current.trim());
                current = w + " ";
            } else {
                current += w + " ";
            }
        }
        if (current.trim().length > 0) lines.push(current.trim());
        return lines;
    }
    
    function buildSVG(entry) {
        const DESIGN_PRESET = getState(root+'theme').val;
    
        if (entry.karte === "small") {
            if (DESIGN_PRESET === "standard") return buildSmallCard(entry);
            if (DESIGN_PRESET === "teal")     return buildTealCardSmall(entry);
            if (DESIGN_PRESET === "wave")     return buildWaveCardSmall(entry);
    
            const theme = CARD_THEMES[DESIGN_PRESET] || CARD_THEMES.white;
            return buildUniColorCardSmall(entry, theme);
        }
    
        switch (DESIGN_PRESET) {
            case "teal":     return buildTealCard(entry);
            case "coffee":   return buildCoffeeCard(entry);
            case "wave":     return buildWaveCard(entry);
            case "white":    return buildWhiteCard(entry);
            case "red":      return buildRedCard(entry);
            case "green":    return buildGreenCard(entry);
            case "blue":     return buildBlueCard(entry);
            case "yellow":   return buildYellowCard(entry);
            case "standard":
            default:
                return buildLargeCard(entry);
        }
    }
    
    async function main() {
        await smartCreateState(JSON_DP, "", { type: "string", name: "Kontakte JSON" });
        await smartCreateState(root+'theme', "standard", {
            type: "mixed",
            read: true,
            write: true,
            name: "Card Theme",
            states: {
                "blue":     "Blau",
                "coffee":   "Coffee",
                "yellow":   "Gelb",
                "green":    "Grün",
                "red":      "Rot",
                "standard": "Standard",
                "teal":     "Teal",
                "wave":     "Welle",
                "white":    "Weiß"
            }
        });
        await smartCreateState(root+'textshadow', false, { type: "boolean", name: "Textschatten" });
        await smartCreateState(root+'opacity', 1, { type: "number", name: "Deckkraft" });
        await sleepMs(200);
    
        TEXT_SHADOW_STYLE = getState(root+'textshadow').val ? "text-shadow:3px 3px 4px #000;" : "text-shadow:none;";
    
        on({ id: JSON_DP, change: "any" }, async () => {
            await processJSON();
        });
        on({ id: root+'theme', change: "ne" }, async () => {
            await processJSON();
        });
        on({ id: root+'textshadow', change: "ne" }, async obj => {
            TEXT_SHADOW_STYLE = obj.state.val ? "text-shadow:3px 3px 4px #000;" : "text-shadow:none;";
            await processJSON();
        });
        on({ id: root+'opacity', change: "ne" }, async () => {
            await processJSON();
        });
    
        const current = getState(JSON_DP).val;
        if (!current || current.trim() === "") {
            const sample = decodeSampleData();
            setState(JSON_DP, JSON.stringify(sample, null, 2));
        }
        await processJSON();
    }
    main();
    
    function buildLargeCard(e) {
        const FONT_THEME    = "standard";
        const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
        const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
        const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
        const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
    
        const X = {day: 18, s1: 150, d1: 165, e1: 180, s2: 260, d2: 275, e2: 290};
    
        return `
            <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                <style>
                .txt {
                    ${TEXT_SHADOW_STYLE}
                    font-family:${FONT.family};
                    font-feature-settings:"tnum";
                    font-weight: normal;
                    font-style: normal;
                }
                </style>
    
                <defs>
                    <radialGradient id="glassGlow-${uid}" cx="50%" cy="50%" r="50%">
                        <stop offset="0%" stop-color="${AVATAR.glass.core.color}" stop-opacity="${AVATAR.glass.core.opacity}"/>
                        <stop offset="22%" stop-color="${AVATAR.glass.glow1.color}" stop-opacity="${AVATAR.glass.glow1.opacity}"/>
                        <stop offset="50%" stop-color="${AVATAR.glass.glow2.color}" stop-opacity="${AVATAR.glass.glow2.opacity}"/>
                        <stop offset="82%" stop-color="${AVATAR.glass.glow3.color}" stop-opacity="${AVATAR.glass.glow3.opacity}"/>
                        <stop offset="100%" stop-color="${AVATAR.glass.fade.color}" stop-opacity="${AVATAR.glass.fade.opacity}"/>
                    </radialGradient>
    
                    <radialGradient id="avatarAuraGlow-${uid}" cx="50%" cy="50%" r="50%">
                        <stop offset="0%" stop-color="${AVATAR.aura.inner.color}" stop-opacity="${AVATAR.aura.inner.opacity}"/>
                        <stop offset="60%" stop-color="${AVATAR.aura.mid.color}" stop-opacity="${AVATAR.aura.mid.opacity}"/>
                        <stop offset="100%" stop-color="${AVATAR.aura.outer.color}" stop-opacity="${AVATAR.aura.outer.opacity}"/>
                    </radialGradient>
    
                    <filter id="glassBloom-${uid}" x="-250%" y="-250%" width="600%" height="600%"><feGaussianBlur stdDeviation="12" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
                    <filter id="glassShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                    <mask id="avatarMask-${uid}"><circle cx="280" cy="115" r="40" fill="white"/></mask>
                </defs>
    
                <circle cx="280" cy="115" r="90" fill="url(#avatarAuraGlow-${uid})" filter="url(#glassBloom-${uid})" opacity="0.78"/>
                <circle cx="280" cy="115" r="62" fill="url(#glassGlow-${uid})" filter="url(#glassBloom-${uid})"/>
                <circle cx="280" cy="115" r="46" fill="none" stroke="${AVATAR.ring}" stroke-width="1.5"/>
                <ellipse cx="270" cy="102" rx="12" ry="4" fill="rgba(255,255,255,0.22)" transform="rotate(-25 270 102)"/>
                <circle cx="280" cy="115" r="42" fill="${AVATAR.background}" filter="url(#glassShadow-${uid})"/>
                <image href="${e.bild}" x="240" y="75" width="80" height="80" mask="url(#avatarMask-${uid})" preserveAspectRatio="xMidYMid slice"/>
    
                ${(() => {
                    const lines = [];
                    lines.push({text: e.name, type: "name", y: 40});
                    lines.push({text: e.adresse.strasse, type: "address", y: 75});
                    lines.push({text: `${e.adresse.plz} ${e.adresse.ort}`, type: "address", y: 93});
    
                    const tels = e.kontakt.telefon.split(/[,;]\s*/);
                    tels.forEach((tel, i) => lines.push({text: `Tel.: ${tel}`, type: "contact", y: 130 + i * 18}));
    
                    if (e.kontakt.fax)   lines.push({text: `Fax: ${e.kontakt.fax}`, type: "contact", y: 148});
                    if (e.kontakt.email) lines.push({text: e.kontakt.email, type: "contact", y: 166});
                    if (e.kontakt.web)   lines.push({text: e.kontakt.web, type: "web", y: 184});
    
                    lines.push({text: "Öffnungszeiten", type: "title", y: 225});
                    return lines.map(l => {
                        const F = fontAttr(FONT_THEME, l.type);
                        return `<text class="txt" x="18" y="${l.y}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${l.text}</text>`;
                    }).join("");
                })()}
    
                ${hasValidTimes ? zeiten.map((z,i)=>{
                    const {day,parts}=splitTimeAtomic(z);
                    const b1=parts[0]||null, b2=parts[1]||null;
                    const F = fontAttr(FONT_THEME, "times");
                    return `<text class="txt" y="${250+i*20}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}"><tspan x="${X.day}">${day}</tspan>${b1?`<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>`:""}${b2?`<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>`:""}</text>`;
                }).join("") : (() => {
                    const F = fontAttr(FONT_THEME, "times");
                    return `<text class="txt" x="18" y="250" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${e.hinweis}</text>`;
                })()}
    
                ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>{
                    const F = fontAttr(FONT_THEME, "hint");
                    return `<text class="txt" x="18" y="${250+zeiten.length*20+10+i*14}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${line}</text>`;
                }).join("") : ""}
            </svg>
        `.trim();
    }
    
    function buildSmallCard(e) {
        const FONT_THEME = "standard";
        const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const gf         = AVATAR.glowFactorSmall;
        const af         = AVATAR.auraFactorSmall;
        const zeiten     = e.zeiten.map(z => normalizeDays(z));
    
        return `
            <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                <style>
                .txt {
                    ${TEXT_SHADOW_STYLE}
                    font-family:${FONT.family};
                    font-feature-settings:"tnum";
                    font-weight:normal;
                    font-style:normal;
                }
                </style>
    
                <defs>
                    <radialGradient id="avatarCoreGlow-${uid}" cx="50%" cy="50%" r="50%">
                        <stop offset="0%"  stop-color="${AVATAR.glass.core.color}" stop-opacity="${AVATAR.glass.core.opacity}"/>
                        <stop offset="22%" stop-color="${AVATAR.glass.glow1.color}" stop-opacity="${AVATAR.glass.glow1.opacity * gf}"/>
                        <stop offset="50%" stop-color="${AVATAR.glass.glow2.color}" stop-opacity="${AVATAR.glass.glow2.opacity * gf}"/>
                        <stop offset="82%" stop-color="${AVATAR.glass.glow3.color}" stop-opacity="${AVATAR.glass.glow3.opacity * gf}"/>
                        <stop offset="100%" stop-color="${AVATAR.glass.fade.color}" stop-opacity="${AVATAR.glass.fade.opacity * gf}"/>
                    </radialGradient>
    
                    <radialGradient id="avatarAuraGlow-${uid}" cx="50%" cy="50%" r="50%">
                        <stop offset="0%"   stop-color="${AVATAR.aura.inner.color}" stop-opacity="${AVATAR.aura.inner.opacity * af}"/>
                        <stop offset="60%"  stop-color="${AVATAR.aura.mid.color}"   stop-opacity="${AVATAR.aura.mid.opacity * af}"/>
                        <stop offset="100%" stop-color="${AVATAR.aura.outer.color}" stop-opacity="${AVATAR.aura.outer.opacity * af}"/>
                    </radialGradient>
    
                    <filter id="avatarBloom-${uid}" x="-250%" y="-250%" width="600%" height="600%"><feGaussianBlur stdDeviation="8" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
                    <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                    <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                </defs>
    
                <circle cx="300" cy="80" r="48" fill="url(#avatarAuraGlow-${uid})" filter="url(#avatarBloom-${uid})" opacity="0.88"/>
                <circle cx="300" cy="80" r="44" fill="url(#avatarCoreGlow-${uid})" filter="url(#avatarBloom-${uid})"/>
                <circle cx="300" cy="80" r="32" fill="none" stroke="${AVATAR.ring}" stroke-width="1.5"/>
                <ellipse cx="289" cy="67" rx="11" ry="4" fill="rgba(255,255,255,0.22)" transform="rotate(-25 289 67)"/>
                <circle cx="300" cy="80" r="22.4" fill="${AVATAR.background}" filter="url(#avatarShadow-${uid})"/>
                <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
    
                ${(() => {
                    const lines = [];
                    lines.push({text: e.name, type: "name", y: 30});
                    lines.push({text: `Tel.: ${e.kontakt.telefon}`, type: "contact", y: 55});
                    lines.push({text: "Öffnungszeiten", type: "title", y: 78});
    
                    return lines.map(l => {
                        const F = fontAttr(FONT_THEME, l.type);
                        return `<text class="txt" x="18" y="${l.y}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${l.text}</text>`;
                    }).join("");
                })()}
    
                ${zeiten.map((z,i)=>{
                    const {day,parts}=splitTimeAtomic(z);
                    const b1=parts[0]||null, b2=parts[1]||null;
                    const F = fontAttr(FONT_THEME, "times");
    
                    return `
                        <text class="txt"
                            y="${95+i*20}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}"><tspan x="18">${day}</tspan>
                            ${b1?`<tspan x="145" text-anchor="end">${b1.start}</tspan><tspan x="160" text-anchor="middle">–</tspan><tspan x="175" text-anchor="start">${b1.end}</tspan>`:""}
                            ${b2?`<tspan x="255" text-anchor="end">${b2.start}</tspan><tspan x="270" text-anchor="middle">–</tspan><tspan x="285" text-anchor="start">${b2.end}</tspan>`:""}
                        </text>`;
                }).join("")}
            </svg>
        `.trim();
    }
    
    function buildWaveCard(e) {
        const FONT_THEME    = "wave";
        const THEME         = CARD_THEMES.wave;
        const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
        const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
        const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
        const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
        const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const lineHeight    = 15;
    
        const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
    
        const timesBoxY      = 240;
        const timesBoxHeight = 110;
        const contactBlocks  = [];
        const CONTACT_Y      = [132, 163, 194];
    
        const iconWrap = (cx, cy, icon, scale = 0.55) => `
            <g transform="translate(${cx - 11}, ${cy - 11})">
                <circle cx="11" cy="11" r="11" fill="${THEME.iconBg}"/>
                <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                    stroke="${THEME.iconStroke}" stroke-width="2" fill="none">
                    ${icon}
                </g>
            </g>
        `;
    
        const opacity = getState(root+'opacity').val;
    
        if (tels.length) contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
        if (e.kontakt.fax) contactBlocks.push({ icon: ICON_FAX, text: `Fax: ${e.kontakt.fax}` });
        if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_ADDRESS = fontAttr(FONT_THEME, "address");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_WEB     = fontAttr(FONT_THEME, "web");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
        const F_HINT    = fontAttr(FONT_THEME, "hint");
    
        return `
            <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <defs>
                    <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#f8fbff"/><stop offset="100%" stop-color="#dcecff"/></linearGradient>
                    <linearGradient id="header-${uid}" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#2563eb"/></linearGradient>
                    <clipPath id="avatarClip-${uid}"><rect x="260" y="65" width="65" height="65" rx="16"/></clipPath>
                </defs>
    
                <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                <path d="M0 24 C70 -4 140 44 220 24 C290 4 320 44 ${CARD_LARGE_W} 14 L${CARD_LARGE_W} 0 L0 0 Z" fill="url(#header-${uid})"/>
                <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <rect x="22" y="56" width="40" height="4" rx="2" fill="${THEME.titleUnderline}"/>
                <rect x="255" y="60" width="75" height="75" rx="16" fill="#d9e8ff" stroke="${THEME.avatarStroke}"/>
                <image href="${e.bild}" x="260" y="65" width="65" height="65" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatarClip-${uid})"/>
    
                ${iconWrap(32, 89, ICON_LOCATION)}
                <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
    
                ${contactBlocks.map((entry, i) => {
                    const y = CONTACT_Y[i];
                    if (!y) return "";
    
                    const lines = entry.lines || [entry.text];
                    return `
                        ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                        ${lines.map((line, idx) => `<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
    
                <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${THEME.timesBoxBg}" stroke="${THEME.timesBoxBorder}"/>
                ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
    
                <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
                ${zeiten.map((z, i) => {
                    const { day, parts } = splitTimeAtomic(z);
                    const b1 = parts[0] || null;
                    const b2 = parts[1] || null;
    
                    return `
                    <text class="txt"
                        y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                        ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                        ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                    </text>`;
                }).join("")}
                ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
            </svg>
        `.trim();
    }
    
    function buildWaveCardSmall(e) {
        const FONT_THEME = "wave";
        const THEME      = CARD_THEMES.wave;
        const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const zeiten     = e.zeiten.map(z => normalizeDays(z));
        const opacity    = getState(root+'opacity').val;
    
        const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
    
        return `
            <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <defs>
                    <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#f8fbff"/><stop offset="100%" stop-color="#dcecff"/></linearGradient>
                    <linearGradient id="header-${uid}" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#2563eb"/></linearGradient>
                    <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                    <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                </defs>
    
                <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                <path d="M0 24 C70 -4 140 44 220 24 C290 4 320 44 ${CARD_SMALL_W} 14 L${CARD_SMALL_W} 0 L0 0 Z" fill="url(#header-${uid})"/>
                <circle cx="300" cy="80" r="32" fill="#d9e8ff" stroke="${THEME.avatarStroke}" stroke-width="1.5"/>
                <circle cx="300" cy="80" r="22.4" fill="#d9e8ff" filter="url(#avatarShadow-${uid})"/>
                <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
                <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
    
                ${zeiten.map((z,i)=>{
                    const {day,parts}=splitTimeAtomic(z);
                    const b1=parts[0]||null;
                    const b2=parts[1]||null;
    
                    return `
                        <text class="txt"
                            y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                            ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                            ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                        </text>
                    `;
                }).join("")}
            </svg>
        `.trim();
    }
    
    function buildTealCard(e) {
        const FONT_THEME    = "teal";
        const THEME         = CARD_THEMES.teal;
        const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
        const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
        const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
        const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
        const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const lineHeight    = 15;
    
        const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
    
        const timesBoxY      = 240;
        const timesBoxHeight = 110;
        const contactBlocks  = [];
        const CONTACT_Y      = [132, 163, 194];
    
        const iconWrap = (cx, cy, icon, scale = 0.55) => `
            <g transform="translate(${cx - 11}, ${cy - 11})">
                <circle cx="11" cy="11" r="11" fill="${THEME.iconBg}"/>
                <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                    stroke="${THEME.iconStroke}" stroke-width="2" fill="none">
                    ${icon}
                </g>
            </g>
        `;
    
        if (tels.length) contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
        if (e.kontakt.fax) contactBlocks.push({ icon: ICON_FAX,  text: `Fax: ${e.kontakt.fax}` });
        if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
    
        const opacity = getState(root+'opacity').val;
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_ADDRESS = fontAttr(FONT_THEME, "address");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_WEB     = fontAttr(FONT_THEME, "web");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
        const F_HINT    = fontAttr(FONT_THEME, "hint");
    
        return `
            <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <defs>
                    <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#005a70"/><stop offset="100%" stop-color="#87c3ab"/></linearGradient>
                    <clipPath id="avatarClip-${uid}"><rect x="260" y="65" width="65" height="65" rx="14"/></clipPath>
                    <filter id="blurBG" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="8"/></filter>
                </defs>
    
                <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <rect x="22" y="56" width="40" height="4" rx="2" fill="${THEME.titleUnderline}" opacity="0.8"/>
                <rect x="255" y="60" width="75" height="75" rx="16" fill="rgba(255,255,255,0.12)" stroke="${THEME.avatarStroke}"/>
                <image href="${e.bild}" x="260" y="65" width="65" height="65" clip-path="url(#avatarClip-${uid})" preserveAspectRatio="xMidYMid slice"/>
    
                ${iconWrap(32, 89, ICON_LOCATION)}
                <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
    
                ${contactBlocks.map((entry, i) => {
                    const y = CONTACT_Y[i];
                    if (!y) return "";
    
                    const lines = entry.lines || [entry.text];
                    return `
                        ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                        ${lines.map((line,idx)=>`<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
                <g>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${THEME.timesBoxBg}" filter="url(#blurBG)"/>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="${THEME.timesBoxBorder}" stroke-width="2"/>
                </g>
    
                ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
    
                ${zeiten.map((z, i) => {
                    const { day, parts } = splitTimeAtomic(z);
                    const b1 = parts[0] || null;
                    const b2 = parts[1] || null;
    
                    return `
                    <text class="txt"
                        y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                        ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                        ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                    </text>`;
                }).join("")}
                ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
            </svg>
        `.trim();
    }
    
    function buildTealCardSmall(e) {
        const FONT_THEME = "teal";
        const THEME      = CARD_THEMES.teal;
        const zeiten     = e.zeiten.map(z => normalizeDays(z));
        const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
    
        const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
    
        const opacity = getState(root+'opacity').val;
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
    
        return `
            <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <defs>
                    <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#005a70"/><stop offset="100%" stop-color="#87c3ab"/></linearGradient>
                    <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                    <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                </defs>
    
                <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                <circle cx="300" cy="80" r="32" fill="rgba(255,255,255,0.12)" stroke="${THEME.avatarStroke}" stroke-width="1.5"/>
                <circle cx="300" cy="80" r="22.4" fill="#0f2647" filter="url(#avatarShadow-${uid})"/>
                <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
                <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
                ${zeiten.map((z,i)=>{
                    const {day,parts}=splitTimeAtomic(z);
                    const b1=parts[0]||null;
                    const b2=parts[1]||null;
    
                    return `
                        <text class="txt"
                            y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                            ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                            ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                        </text>
                    `;
                }).join("")}
            </svg>
        `.trim();
    }
    
    function buildWhiteCard(e) {
        return buildUniColorCard(e, CARD_THEMES.white);
    }
    
    function buildCoffeeCard(e) {
        return buildUniColorCard(e, CARD_THEMES.coffee);
    }
    
    function buildYellowCard(e) {
        return buildUniColorCard(e, CARD_THEMES.yellow);
    }
    
    function buildGreenCard(e) {
        return buildUniColorCard(e, CARD_THEMES.green);
    }
    
    function buildRedCard(e) {
        return buildUniColorCard(e, CARD_THEMES.red);
    }
    
    function buildBlueCard(e) {
        return buildUniColorCard(e, CARD_THEMES.blue);
    }
    
    function buildUniColorCard(e, theme) {
        const FONT_THEME    = theme.fontTheme;
        const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
        const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
        const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
        const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
        const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const lineHeight    = 15;
    
        const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
    
        const timesBoxY      = 240;
        const timesBoxHeight = 110;
        const contactBlocks  = [];
        const CONTACT_Y      = [132, 163, 194];
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_ADDRESS = fontAttr(FONT_THEME, "address");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_WEB     = fontAttr(FONT_THEME, "web");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
        const F_HINT    = fontAttr(FONT_THEME, "hint");
    
        const iconWrap = (cx, cy, icon, scale = 0.55) => `
            <g transform="translate(${cx - 11}, ${cy - 11})">
                <circle cx="11" cy="11" r="11" fill="${theme.iconBg}"/>
                <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                    stroke="${theme.iconStroke}" stroke-width="2" fill="none">
                    ${icon}
                </g>
            </g>
        `;
    
        const opacity = getState(root+'opacity').val;
    
        if (tels.length)     contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
        if (e.kontakt.fax)   contactBlocks.push({ icon: ICON_FAX,  text: `Fax: ${e.kontakt.fax}` });
        if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
    
        return `
            <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <defs>
                    <clipPath id="avatarClip-${uid}"><circle cx="292" cy="98" r="38"/></clipPath>
                    <filter id="blurUni-${uid}" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2"/></filter>
                </defs>
                <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="${theme.background}" opacity="${opacity}"/>
                <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <rect x="22" y="56" width="40" height="4" rx="2" fill="${theme.titleUnderline}" opacity="0.9"/>
                <circle cx="292" cy="98" r="42" fill="none" stroke="${theme.avatarStroke}" stroke-width="2"/>
                <image href="${e.bild}" x="250" y="56" width="84" height="84" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatarClip-${uid})"/>
    
                ${iconWrap(32, 89, ICON_LOCATION)}
                <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
    
                ${contactBlocks.map((entry, i) => {
                    const y = CONTACT_Y[i];
                    if (!y) return "";
    
                    const lines = entry.lines || [entry.text];
                    return `
                        ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                        ${lines.map((line, idx) => `<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
                <g>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${theme.timesBoxBg}" filter="url(#blurUni-${uid})"/>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="${theme.timesBoxBorder}" stroke-width="2"/>
                </g>
    
                ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
    
                ${zeiten.map((z, i) => {
                    const { day, parts } = splitTimeAtomic(z);
                    const b1 = parts[0] || null;
                    const b2 = parts[1] || null;
    
                    return `
                    <text class="txt"
                        y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                        ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                        ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                    </text>`;
                }).join("")}
    
                ${e.hinweis && hasValidTimes ? hinweisLines.map((line, i) => `<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
            </svg>
        `.trim();
    }
    
    function buildUniColorCardSmall(e, theme) {
        const FONT_THEME = theme.fontTheme;
        const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
        const zeiten     = e.zeiten.map(z => normalizeDays(z));
    
        const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
    
        const opacity = getState(root+'opacity').val;
    
        const F_NAME    = fontAttr(FONT_THEME, "name");
        const F_CONTACT = fontAttr(FONT_THEME, "contact");
        const F_TITLE   = fontAttr(FONT_THEME, "title");
        const F_TIMES   = fontAttr(FONT_THEME, "times");
    
        return `
            <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                <style>
                    .txt {
                        ${TEXT_SHADOW_STYLE}
                        font-family:${FONT.family};
                        font-feature-settings:"tnum";
                        font-weight:normal;
                        font-style:normal;
                    }
                </style>
    
                <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="${theme.background}" opacity="${opacity}"/>
                <defs>
                    <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                </defs>
    
                <circle cx="300" cy="80" r="32" fill="none" stroke="${theme.avatarStroke}" stroke-width="1.5"/>
                <circle cx="300" cy="80" r="22.4" fill="${AVATAR.background}"/>
                <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
    
                <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
    
                ${zeiten.map((z,i)=>{
                    const {day,parts}=splitTimeAtomic(z);
                    const b1=parts[0]||null, b2=parts[1]||null;
    
                    return `
                        <text class="txt"
                            y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                            ${b1?`<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>`:""}
                            ${b2?`<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>`:""}
                        </text>
                    `;
                }).join("")}
            </svg>
        `.trim();
    }
    
    function setStateIfChanged(id, value) {
        const state = getState(id);
        if (!state) return;
    
        const old = state.val;
        const isObject = v => v !== null && typeof v === "object";
    
        if (isObject(value)) {
            const newStr = JSON.stringify(value);
            let oldStr = null;
    
            if (typeof old === "string") oldStr = old;
            else if (isObject(old)) oldStr = JSON.stringify(old);
    
            if (newStr === oldStr) return;
            return setState(id, newStr, true);
        }
    
        if (old === value) return;
        setState(id, value, true);
    }
    
    const SAMPLE_DATA_BASE64 =
    `ewogICJlaW50cmFlZ2UiOiBbCiAgICB7CiAgICAgICJpZCI6IDEsCiAgICAgICJrYXJ0ZSI6ICJsYXJnZSIsCiAgICAgICJ0eXAiOiAiaGF1c2FyenQiLAogICAgICAi
    bmFtZSI6ICJIYXVzYXJ6dCDigJMgRHIuIE1heCBNdXN0ZXJtYW5uIiwKICAgICAgImJpbGQiOiAiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvYXJ6dC8zMDAvMzAw
    IiwKICAgICAgImFkcmVzc2UiOiB7CiAgICAgICAgInN0cmFzc2UiOiAiTXVzdGVyc3RyYcOfZSAxMiIsCiAgICAgICAgInBseiI6ICIwMTIzNCIsCiAgICAgICAgIm9y
    dCI6ICJNdXN0ZXJzdGFkdCIKICAgICAgfSwKICAgICAgInplaXRlbiI6IFsKICAgICAgICAiTW9udGFnIDA4LjAwIOKAkyAxMi4wMCIsCiAgICAgICAgIkRpZW5zdGFn
    IDA4LjAwIOKAkyAxMi4wMCwgMTQuMDAg4oCTIDE4LjAwIiwKICAgICAgICAiTWl0d29jaCAwOC4wMCDigJMgMTIuMDAiLAogICAgICAgICJEb25uZXJzdGFnIDA4LjAw
    IOKAkyAxMi4wMCIsCiAgICAgICAgIkZyZWl0YWcgMDguMDAg4oCTIDEyLjAwIgogICAgICBdLAogICAgICAia29udGFrdCI6IHsKICAgICAgICAidGVsZWZvbiI6ICIo
    MDEyMzQpIDEyMzQ1NiIsCiAgICAgICAgImZheCI6ICIoMDEyMzQpIDY1NDMyMSIsCiAgICAgICAgImVtYWlsIjogInByYXhpc0BtdXN0ZXJtYW5uLmRlIiwKICAgICAg
    ICAid2ViIjogImh0dHBzOi8vd3d3Lm11c3Rlcm1hbm4taGF1c2FyenQuZGUvIgogICAgICB9LAogICAgICAiaGlud2VpcyI6ICIiCiAgICB9LAogICAgewogICAgICAi
    aWQiOiAyLAogICAgICAia2FydGUiOiAibGFyZ2UiLAogICAgICAidHlwIjogInphaG5hcnp0IiwKICAgICAgIm5hbWUiOiAiWmFobmFyenQg4oCTIERyLiBKdWxpYSBC
    ZWlzcGllbCIsCiAgICAgICJiaWxkIjogImh0dHBzOi8vcGljc3VtLnBob3Rvcy9zZWVkL3phaG5hcnp0LzMwMC8zMDAiLAogICAgICAiYWRyZXNzZSI6IHsKICAgICAg
    ICAic3RyYXNzZSI6ICJCZWlzcGllbHdlZyA1IiwKICAgICAgICAicGx6IjogIjU2Nzg5IiwKICAgICAgICAib3J0IjogIkJlaXNwaWVsc3RhZHQiCiAgICAgIH0sCiAg
    ICAgICJ6ZWl0ZW4iOiBbCiAgICAgICAgIk1vbnRhZyAwOS4wMCDigJMgMTMuMDAiLAogICAgICAgICJEaWVuc3RhZyAwOS4wMCDigJMgMTMuMDAsIDE0LjAwIOKAkyAx
    OC4wMCIsCiAgICAgICAgIk1pdHR3b2NoIDA5LjAwIOKAkyAxMy4wMCIsCiAgICAgICAgIkRvbm5lcnN0YWcgMDkuMDAg4oCTIDEzLjAwLCAxNC4wMCDigJMgMTguMDAi
    LAogICAgICAgICJGcmVpdGFnIDA5LjAwIOKAkyAxMi4wMCIKICAgICAgXSwKICAgICAgImtvbnRha3QiOiB7CiAgICAgICAgInRlbGVmb24iOiAiKDA1Njc4KSA5ODc2
    NTQiLAogICAgICAgICJmYXgiOiAiIiwKICAgICAgICAiZW1haWwiOiAiaW5mb0B6YWhuYXJ6dC1iZWlzcGllbC5kZSIsCiAgICAgICAgIndlYiI6ICJodHRwczovL3d3
    dy56YWhuYXJ6dC1iZWlzcGllbC5kZS8iCiAgICAgIH0sCiAgICAgICJoaW53ZWlzIjogIlRlcm1pbmUgbmFjaCBWZXJlaW5iYXJ1bmciCiAgICB9LAogICAgewogICAg
    ICAiaWQiOiAzLAogICAgICAia2FydGUiOiAic21hbGwiLAogICAgICAidHlwIjogImZyaXNldXIiLAogICAgICAibmFtZSI6ICJGcmlzZXVyIFNhbG9uIEJlaXNwaWVs
    IiwKICAgICAgImJpbGQiOiAiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvZnJpc2V1ci8zMDAvMzAwIiwKICAgICAgImFkcmVzc2UiOiB7CiAgICAgICAgInN0cmFz
    c2UiOiAiIiwKICAgICAgICAicGx6IjogIiIsCiAgICAgICAgIm9ydCI6ICIiCiAgICAgIH0sCiAgICAgICJ6ZWl0ZW4iOiBbCiAgICAgICAgIk1vbnRhZyDigJMgRnJl
    aXRhZyAwOC4wMCDigJMgMTguMDAiLAogICAgICAgICJTYW1zdGFnIG5hY2ggVmVyZWluYmFydW5nIgogICAgICBdLAogICAgICAia29udGFrdCI6IHsKICAgICAgICAi
    dGVsZWZvbiI6ICIoMDEyMzQpIDExMjIzMyIsCiAgICAgICAgImZheCI6ICIiLAogICAgICAgICJlbWFpbCI6ICIiLAogICAgICAgICJ3ZWIiOiAiIgogICAgICB9LAog
    ICAgICAiaGlud2VpcyI6ICIiCiAgICB9CiAgXQp9`;
    

    Wünsche Euch viel Spaß bei der Umsetzung.

    Ro75.

    EDIT: Mit Stand 31.05.2026 gibt es neue Funktionen.

    CARDS.png

    • Themen: 9 verschiedene Themen stehen via Datenpunkt (theme) zur Auswahl. Standard = standard.
    • Opacity: Die Deckkraft kann gesteuert werden. Standard = 1 (100%).
    • Text-Shadow: Kann nun ebenfalls via Datenpunkt (textshadow) gesteuert werden. Standard = false.

    Zusätzlich können Details zentral konfiguriert werden

    const FONT = {
        family: "InterVariable",   // globale Schriftfamilie
    
        fields: {
            name:    { size: 16, color: "#ffffff", bold: true,  italic: false },  // Name / Praxis / Firma
            address: { size: 13, color: "#d0d7e2", bold: false, italic: false },  // Straße, PLZ, Ort
            contact: { size: 12, color: "#9fb3d9", bold: false, italic: false },  // Tel., Fax, E-Mail
            web:     { size: 12, color: "#6fa8ff", bold: false, italic: false },  // Website-Link
            title:   { size: 13, color: "#ffffff", bold: true,  italic: false },  // Abschnittstitel ("Öffnungszeiten")
            times:   { size: 12, color: "#d0d7e2", bold: false, italic: false },  // Öffnungszeiten-Zeilen
            hint:    { size: 12, color: "#ffcc66", bold: true,  italic: false }   // Hinweis-/Infozeilen
        },
    
        weight: {
            normal: 400,  // normales Schriftgewicht
            bold:   600   // fettes Schriftgewicht
        }
    };
    

    Sollte selbsterklärend sein.

    EDIT: Mit Stand 03.06.2026 gibt es Fehlerkorrekturen.

    • Korrektur der Position für den Hinweistext
    • Korrektur der Öffnungszeiten (falsche Zusammenstellung)
    • Bei Öffnungszeiten kann nun zusätzlich zum EN-DASH auch der normale Bindestrich (-) Verwendung finden.
    • Konfiguration (Schriftgröße, Farbe, Stil) der Felder nun in allen Themen möglich.

    EDIT: Mit Stand 05.06.2026 gibt es Fehlerkorrekturen.

    • Korrektur der Themen "wave" und "teal" - Konfiguration jetzt möglich.

    SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

    sigi234S 1 Antwort Letzte Antwort
    4
    • Ro75R Ro75

      In unserem Dashboard stehen alle wichtigen Kontakte als moderne, dynamische Karten bereit – so hat jedes Familienmitglied jederzeit Zugriff auf die aktuellsten Informationen, ohne suchen zu müssen.
      Die Daten liegen zentral in einer JSON, aus der automatisch schicke SVG‑Karten erzeugt werden, die sich direkt in VIS oder jede andere Visualisierung einfügen lassen.

      Sobald ein Eintrag geändert oder ein neuer Kontakt hinzugefügt wird, aktualisiert das System die Karten vollautomatisch.
      Keine Pflege an mehreren Stellen, keine veralteten Infos – einmal eintragen, überall aktuell.

      Das Script erzeugt beim ersten Start automatisch einen kompletten Beispieldatensatz mit drei fertigen Karten – inklusive der passenden SVG‑Grafiken.
      So sieht man sofort, wie das System funktioniert und wie die Karten später in der Visualisierung aussehen.

      card.png

      HINWEIS: Ein Teil des Codes wurde dabei mit Unterstützung einer KI (Microsoft Copilot) entwickelt, um die Erstellung noch effizienter und moderner zu gestalten.

      Hier der Code:

      // === Kontakte (CARDS) ===
       
      //Erst-Version 1.0.0 - 25.05.2026
      //Version 1.0.8 - 05.06.2026
      //Ersteller Ro75.
       
      //Voraussetzungen (Version 1.0.8 getestet mit)
      //NodeJS: 22.22.x
      //Javascript-Adapter: 9.0.18
      //Admin-Adapter: 7.8.23
      //js-controler: 7.0.7
      
      //********** START KONFIGURATION **********
      const root     = "0_userdata.0.Kontakte."
      const JSON_DP  = root + "json";
      const SVG_ROOT = root + "svg.";
      
      // zentrale SVG-Größen
      const CARD_LARGE_W = 350;
      const CARD_LARGE_H = 360;
      
      const CARD_SMALL_W = 350;
      const CARD_SMALL_H = 130;
      
      const FONT = {
          family: "InterVariable",   // Globale Schriftfamilie für alle Karten
      
          // STANDARD – neutral, technisch, kühl
          standard: {
              name:    { size: 16, color: "#ffffff", bold: true,  italic: false },  // Name / Praxis / Firma
              address: { size: 13, color: "#d0d7e2", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#9fb3d9", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#6fa8ff", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#ffffff", bold: true,  italic: false },  // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#d0d7e2", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#ffcc66", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // WAVE – kühl, frisch, maritim
          wave: {
              name:    { size: 16, color: "#163a70", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#173d74", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#173d74", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#2563eb", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#1d4ed8", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#173d74", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // TEAL – modern, kühl, glasig
          teal: {
              name:    { size: 16, color: "#ffffff", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#e8ffef", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#e8ffef", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#e8ffef", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#e8ffef", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#e8ffef", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // WHITE – hell, neutral, klar
          white: {
              name:    { size: 16, color: "#1a1a1a", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#2d3748", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#2d3748", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#33691e", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#254618", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#2d3748", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#1a1a1a", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // COFFEE – warm, erdig, gemütlich
          coffee: {
              name:    { size: 16, color: "#4E342E", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#4E342E", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#4E342E", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#6D4C41", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#4E342E", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#4E342E", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#4E342E", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // YELLOW – warm, freundlich, sonnig
          yellow: {
              name:    { size: 16, color: "#5D4037", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#4E342E", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#4E342E", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#F57F17", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#5D4037", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#4E342E", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#5D4037", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // GREEN – natur, frisch, harmonisch
          green: {
              name:    { size: 16, color: "#1B5E20", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#2E7D32", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#2E7D32", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#1B5E20", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#1B5E20", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#2E7D32", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#1B5E20", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // RED – kräftig, energisch, dominant
          red: {
              name:    { size: 16, color: "#B71C1C", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#C62828", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#C62828", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#B71C1C", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#B71C1C", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#C62828", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#B71C1C", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          // BLUE – kühl, seriös, sachlich
          blue: {
              name:    { size: 16, color: "#0D47A1", bold: true, italic: false },   // Name / Praxis / Firma
              address: { size: 13, color: "#1E3A5F", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#1E3A5F", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#1565C0", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#0D47A1", bold: true, italic: false },   // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#1E3A5F", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#0D47A1", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          weight: {
              normal: 400,   // Normalgewicht
              bold:   600    // Fett
          }
      };
      
      const CARD_THEMES = {
          white: {
              fontTheme: "white",                      // Name des Font-Themes
              iconBg: "#43A047",                       // Hintergrundfarbe der runden Icons
              iconStroke: "#ffffff",                   // Farbe der Icons
              titleUnderline: "#43A047",               // Linie unter dem Namen
              avatarStroke: "#43A047",                 // Rahmen um das Bild/Avatar
              timesBoxBg: "rgba(190, 235, 159, 0.15)", // Hintergrund Öffnungszeiten-Box
              timesBoxBorder: "rgba(0,0,0,0.15)",      // Rahmen Öffnungszeiten-Box
              background: "rgba(255,255,255,1)"        // Hintergrund Uni-Card
          },
          coffee: {
              fontTheme: "coffee",
              iconBg: "#6D4C41",
              iconStroke: "#F5F5F5",
              titleUnderline: "#6D4C41",
              avatarStroke: "#6D4C41",
              timesBoxBg: "rgba(209, 188, 175, 0.25)",
              timesBoxBorder: "#A1887F",
              background: "rgba(245,245,245,1)"
          },
          yellow: {
              fontTheme: "yellow",
              iconBg: "#F9A825",
              iconStroke: "#ffffff",
              titleUnderline: "#F9A825",
              avatarStroke: "#F9A825",
              timesBoxBg: "rgba(255, 224, 130, 0.25)",
              timesBoxBorder: "#FBC02D",
              background: "rgba(255,253,231,1)"
          },
          green: {
              fontTheme: "green",
              iconBg: "#2E7D32",
              iconStroke: "#ffffff",
              titleUnderline: "#2E7D32",
              avatarStroke: "#2E7D32",
              timesBoxBg: "rgba(200, 230, 201, 0.25)",
              timesBoxBorder: "#66BB6A",
              background: "rgba(241,248,233,1)"
          },
          red: {
              fontTheme: "red",
              iconBg: "#C62828",
              iconStroke: "#ffffff",
              titleUnderline: "#C62828",
              avatarStroke: "#C62828",
              timesBoxBg: "rgba(255, 205, 210, 0.25)",
              timesBoxBorder: "#EF5350",
              background: "rgba(255,235,238,1)"
          },
          blue: {
              fontTheme: "blue",
              iconBg: "#1E88E5",
              iconStroke: "#ffffff",
              titleUnderline: "#1E88E5",
              avatarStroke: "#1E88E5",
              timesBoxBg: "rgba(144, 202, 249, 0.25)",
              timesBoxBorder: "#64B5F6",
              background: "rgba(227,242,253,1)"
          },
          wave: {
              fontTheme: "wave",
              iconBg: "#60A5FA",                        // Hintergrund der runden Icons
              iconStroke: "#ffffff",                    // Farbe der Icons
              titleUnderline: "#3B82F6",                // Linie unter dem Namen
              avatarStroke: "#93C5FD",                  // Avatar-Rahmen
              timesBoxBg: "rgba(191,219,254,0.38)",     // Öffnungszeiten-Box Hintergrund
              timesBoxBorder: "#93C5FD",                // Öffnungszeiten-Box Rahmen
          },
          teal: {
              fontTheme: "teal",
              iconBg: "#E8FFEF",                        // Hintergrund der runden Icons
              iconStroke: "#4A7763",                    // Farbe der Icons
              titleUnderline: "#FFFFFF",                // Linie unter dem Namen
              avatarStroke: "rgba(255,255,255,0.35)",   // Avatar-Rahmen
              timesBoxBg: "rgba(232,255,239,0.16)",     // Öffnungszeiten-Box Hintergrund
              timesBoxBorder: "rgba(255,255,255,0.45)", // Öffnungszeiten-Box Rahmen
          }
      };
      
      const AVATAR = {
          aura: {
              inner: {color: "#6E463E", opacity: 0.22}, // stärkster innerer Aura-Kern
              mid:   {color: "#6E463E", opacity: 0.08}, // mittlere Aura
              outer: {color: "#6E463E", opacity: 0.00}  // äußerer Rand, auslaufend
          },
      
          glass: {
              core:  {color: "#ffffff", opacity: 1.00}, // hellster Kern
              glow1: {color: "#6E463E", opacity: 0.92}, // starker Glow
              glow2: {color: "#6E463E", opacity: 0.48}, // mittlerer Glow
              glow3: {color: "#6E463E", opacity: 0.12}, // schwacher Glow
              fade:  {color: "#6E463E", opacity: 0.00}  // auslaufender Rand
          },
      
          glowFactorSmall: 1.45,           // Glow‑Dunkelheits‑Faktor NUR für kleine Karte
          auraFactorSmall: 0.45,           // Aura der kleinen Karte abdunkeln
          ring: "rgba(255,255,255,0.30)",  // äußerer Ring
          background: "#0f2647"            // Avatar-Hintergrund
      };
      
      /*
      HINWEIS: Sobald das Script das erste mal gestartet wurde, gibt es im Hauptordner den Datenpunkt "json".
               Dieser beinhaltet 3 Sätze (Karten / Kontakte) mit Beispieldaten
      
              JSON‑Datensatz – Anforderungen (kurz & eindeutig)
      
              • Jeder Eintrag benötigt eine eindeutige ID und muss sich exakt an die Struktur der Beispieldaten halten.
              • Telefonnummern dürfen mehrere Werte enthalten, getrennt durch Komma oder Semikolon.
              • Öffnungszeiten müssen exakt im Format „Wochentag HH.MM – HH.MM[, HH.MM – HH.MM]“ stehen
              und dabei zwingend den echten EN‑DASH (–) verwenden — kein Minuszeichen (-).
              • Das Bildfeld („bild“) kann eine WWW‑URL oder eine ioBroker‑interne URL sein,
              z. B. http://192.168.10.99:8082/vis.0/kontakte/xyz.png
              und es werden PNG, JPG und SVG unterstützt.
      */
      
      //********** ENDE KONFIGURATION **********
      
      const ICON_LOCATION = `<path d="M21 10c0 6-9 13-9 13S3 16 3 10a9 9 0 1 1 18 0z"/><circle cx="12" cy="10" r="3"/>`;
      const ICON_PHONE    = `<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.89.32 1.76.59 2.59a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.49-1.16a2 2 0 0 1 2.11-.45c.83.27 1.7.47 2.59.59A2 2 0 0 1 22 16.92z"/>`;
      const ICON_LIST     = `<path d="M6 18h12"/><path d="M6 14h12"/><path d="M6 10h12"/><rect x="4" y="4" width="16" height="16" rx="2"/>`;
      const ICON_MAIL     = `<path d="M4 4h16v16H4z"/><path d="M22 6l-10 7L2 6"/>`;
      const ICON_GLOBE    = `<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20"/><path d="M12 2a15 15 0 0 0 0 20"/>`;
      const ICON_CLOCK    = `<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>`;
      const ICON_FAX      = `<path d="M4 4h16v16H4z"/><path d="M8 2h8v4H8z"/><path d="M6 10h12"/><path d="M6 14h12"/>`;
      
      let TEXT_SHADOW_STYLE = "text-shadow:none;";
      
      function fontStyle(F) {
          return `
              font-size:${F.size}px !important;
              font-weight:${F.weight} !important;
              font-style:${F.style} !important;
          `;
      }
      
      function fontCfg(theme, type) {
          return FONT[theme]?.[type] ?? FONT.standard[type];
      }
      
      function fontAttr(theme, type) {
          const f = fontCfg(theme, type);
      
          return {
              family: FONT.family, size: f.size, color: f.color, bold: f.bold, italic: f.italic, weight: f.bold ? FONT.weight.bold : FONT.weight.normal, style: f.italic ? "italic" : "normal"
          };
      }
      
      async function smartCreateState(id, value, options = {}) {
          if (existsState(id)) return;
          await createState(id, value, options);
      }
      
      function sleepMs(ms) {
          return new Promise(resolve => setTimeout(resolve, ms));
      }
      
      function base64Decode(str) {
          return Buffer.from(str, "base64").toString("utf8");
      }
      
      function decodeSampleData() {
          const decoded = base64Decode(SAMPLE_DATA_BASE64);
          return JSON.parse(decoded);
      }
      
      async function processJSON() {
          try {
              const raw = getState(JSON_DP).val;
              if (!raw) return;
      
              const data = JSON.parse(raw);
              if (!data.eintraege || !Array.isArray(data.eintraege)) return;
      
              for (const entry of data.eintraege) {
                  const svg = buildSVG(entry);
                  const dp = SVG_ROOT + entry.id;
      
                  await smartCreateState(dp, "", { type: "string", name: "SVG Karte " + entry.id });
                  await sleepMs(100);
      
                  setStateIfChanged(dp, svg);
              }
          } catch (err) {
          }
      }
      
      function normalizeDays(str) {
          const map = {"Montag": "Mo", "Dienstag": "Di", "Mittwoch": "Mi", "Donnerstag": "Do", "Freitag": "Fr", "Samstag": "Sa", "Sonntag": "So"};
          const rangeRegex = /(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)\s*[–-]\s*(Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag)/g;
      
          str = str.replace(rangeRegex, (m, a, b) => `${map[a]}–${map[b]}`);
          for (const full in map) str = str.replace(new RegExp(full, "g"), map[full]);
          return str;
      }
      
      function compressTimes(times) {
          const full    = ["Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag","Sonntag"];
          const short   = ["Mo","Di","Mi","Do","Fr","Sa","So"];
          const special = {"Feiertage": "Feiertage", "Brückentage": "Brückentage"};
      
          const parsed = times.map(t => {
              const [day, ...rest] = t.split(" ");
              let time = rest.join(" ");
      
              time = time.replace(/–/g, "-");
      
              if (special[day]) {
                  return { day, short: special[day], time, special: true };
              }
              return {day, short: short[full.indexOf(day)], time, special: false};
          });
      
          const groups = {};
          const specials = [];
      
          parsed.forEach(p => {
              if (p.special) {
                  specials.push(`${p.short} ${p.time}`);
              } else {
                  if (!groups[p.time]) groups[p.time] = [];
                  groups[p.time].push(p.short);
              }
          });
      
          const result = [];
          const order = ["Mo","Di","Mi","Do","Fr","Sa","So"];
      
          for (const time in groups) {
              const list          = groups[time];
              const indices       = list.map(d => order.indexOf(d));
              const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
      
              if (list.length > 1 && isConsecutive) {
                  result.push(`${list[0]}–${list[list.length - 1]} ${time}`);
              } else {
                  result.push(`${list.join(", ")} ${time}`);
              }
          }
          return result.concat(specials);
      }
      
      function splitTimeAtomic(row) {
          const idx = row.search(/\d/);
          if (idx === -1) return { day: row.trim(), parts: [] };
      
          const dayPart  = row.slice(0, idx).trim();
          const timePart = row.slice(idx).trim();
      
          const blocks = timePart.split(",");
          const parts = [];
      
          blocks.forEach(block => {
              const m = block.trim().match(/^(\S+)\s*[-–]\s*(\S+)$/);
              if (m) parts.push({ start: m[1], end: m[2] });
          });
      
          return { day: dayPart, parts };
      }
      
      function wrapText(text, maxLen = 32) {
          const words = text.split(" ");
          const lines = [];
          let current = "";
      
          for (const w of words) {
              if ((current + w).length > maxLen) {
                  lines.push(current.trim());
                  current = w + " ";
              } else {
                  current += w + " ";
              }
          }
          if (current.trim().length > 0) lines.push(current.trim());
          return lines;
      }
      
      function buildSVG(entry) {
          const DESIGN_PRESET = getState(root+'theme').val;
      
          if (entry.karte === "small") {
              if (DESIGN_PRESET === "standard") return buildSmallCard(entry);
              if (DESIGN_PRESET === "teal")     return buildTealCardSmall(entry);
              if (DESIGN_PRESET === "wave")     return buildWaveCardSmall(entry);
      
              const theme = CARD_THEMES[DESIGN_PRESET] || CARD_THEMES.white;
              return buildUniColorCardSmall(entry, theme);
          }
      
          switch (DESIGN_PRESET) {
              case "teal":     return buildTealCard(entry);
              case "coffee":   return buildCoffeeCard(entry);
              case "wave":     return buildWaveCard(entry);
              case "white":    return buildWhiteCard(entry);
              case "red":      return buildRedCard(entry);
              case "green":    return buildGreenCard(entry);
              case "blue":     return buildBlueCard(entry);
              case "yellow":   return buildYellowCard(entry);
              case "standard":
              default:
                  return buildLargeCard(entry);
          }
      }
      
      async function main() {
          await smartCreateState(JSON_DP, "", { type: "string", name: "Kontakte JSON" });
          await smartCreateState(root+'theme', "standard", {
              type: "mixed",
              read: true,
              write: true,
              name: "Card Theme",
              states: {
                  "blue":     "Blau",
                  "coffee":   "Coffee",
                  "yellow":   "Gelb",
                  "green":    "Grün",
                  "red":      "Rot",
                  "standard": "Standard",
                  "teal":     "Teal",
                  "wave":     "Welle",
                  "white":    "Weiß"
              }
          });
          await smartCreateState(root+'textshadow', false, { type: "boolean", name: "Textschatten" });
          await smartCreateState(root+'opacity', 1, { type: "number", name: "Deckkraft" });
          await sleepMs(200);
      
          TEXT_SHADOW_STYLE = getState(root+'textshadow').val ? "text-shadow:3px 3px 4px #000;" : "text-shadow:none;";
      
          on({ id: JSON_DP, change: "any" }, async () => {
              await processJSON();
          });
          on({ id: root+'theme', change: "ne" }, async () => {
              await processJSON();
          });
          on({ id: root+'textshadow', change: "ne" }, async obj => {
              TEXT_SHADOW_STYLE = obj.state.val ? "text-shadow:3px 3px 4px #000;" : "text-shadow:none;";
              await processJSON();
          });
          on({ id: root+'opacity', change: "ne" }, async () => {
              await processJSON();
          });
      
          const current = getState(JSON_DP).val;
          if (!current || current.trim() === "") {
              const sample = decodeSampleData();
              setState(JSON_DP, JSON.stringify(sample, null, 2));
          }
          await processJSON();
      }
      main();
      
      function buildLargeCard(e) {
          const FONT_THEME    = "standard";
          const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
          const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
          const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
          const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
      
          const X = {day: 18, s1: 150, d1: 165, e1: 180, s2: 260, d2: 275, e2: 290};
      
          return `
              <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                  <style>
                  .txt {
                      ${TEXT_SHADOW_STYLE}
                      font-family:${FONT.family};
                      font-feature-settings:"tnum";
                      font-weight: normal;
                      font-style: normal;
                  }
                  </style>
      
                  <defs>
                      <radialGradient id="glassGlow-${uid}" cx="50%" cy="50%" r="50%">
                          <stop offset="0%" stop-color="${AVATAR.glass.core.color}" stop-opacity="${AVATAR.glass.core.opacity}"/>
                          <stop offset="22%" stop-color="${AVATAR.glass.glow1.color}" stop-opacity="${AVATAR.glass.glow1.opacity}"/>
                          <stop offset="50%" stop-color="${AVATAR.glass.glow2.color}" stop-opacity="${AVATAR.glass.glow2.opacity}"/>
                          <stop offset="82%" stop-color="${AVATAR.glass.glow3.color}" stop-opacity="${AVATAR.glass.glow3.opacity}"/>
                          <stop offset="100%" stop-color="${AVATAR.glass.fade.color}" stop-opacity="${AVATAR.glass.fade.opacity}"/>
                      </radialGradient>
      
                      <radialGradient id="avatarAuraGlow-${uid}" cx="50%" cy="50%" r="50%">
                          <stop offset="0%" stop-color="${AVATAR.aura.inner.color}" stop-opacity="${AVATAR.aura.inner.opacity}"/>
                          <stop offset="60%" stop-color="${AVATAR.aura.mid.color}" stop-opacity="${AVATAR.aura.mid.opacity}"/>
                          <stop offset="100%" stop-color="${AVATAR.aura.outer.color}" stop-opacity="${AVATAR.aura.outer.opacity}"/>
                      </radialGradient>
      
                      <filter id="glassBloom-${uid}" x="-250%" y="-250%" width="600%" height="600%"><feGaussianBlur stdDeviation="12" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
                      <filter id="glassShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                      <mask id="avatarMask-${uid}"><circle cx="280" cy="115" r="40" fill="white"/></mask>
                  </defs>
      
                  <circle cx="280" cy="115" r="90" fill="url(#avatarAuraGlow-${uid})" filter="url(#glassBloom-${uid})" opacity="0.78"/>
                  <circle cx="280" cy="115" r="62" fill="url(#glassGlow-${uid})" filter="url(#glassBloom-${uid})"/>
                  <circle cx="280" cy="115" r="46" fill="none" stroke="${AVATAR.ring}" stroke-width="1.5"/>
                  <ellipse cx="270" cy="102" rx="12" ry="4" fill="rgba(255,255,255,0.22)" transform="rotate(-25 270 102)"/>
                  <circle cx="280" cy="115" r="42" fill="${AVATAR.background}" filter="url(#glassShadow-${uid})"/>
                  <image href="${e.bild}" x="240" y="75" width="80" height="80" mask="url(#avatarMask-${uid})" preserveAspectRatio="xMidYMid slice"/>
      
                  ${(() => {
                      const lines = [];
                      lines.push({text: e.name, type: "name", y: 40});
                      lines.push({text: e.adresse.strasse, type: "address", y: 75});
                      lines.push({text: `${e.adresse.plz} ${e.adresse.ort}`, type: "address", y: 93});
      
                      const tels = e.kontakt.telefon.split(/[,;]\s*/);
                      tels.forEach((tel, i) => lines.push({text: `Tel.: ${tel}`, type: "contact", y: 130 + i * 18}));
      
                      if (e.kontakt.fax)   lines.push({text: `Fax: ${e.kontakt.fax}`, type: "contact", y: 148});
                      if (e.kontakt.email) lines.push({text: e.kontakt.email, type: "contact", y: 166});
                      if (e.kontakt.web)   lines.push({text: e.kontakt.web, type: "web", y: 184});
      
                      lines.push({text: "Öffnungszeiten", type: "title", y: 225});
                      return lines.map(l => {
                          const F = fontAttr(FONT_THEME, l.type);
                          return `<text class="txt" x="18" y="${l.y}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${l.text}</text>`;
                      }).join("");
                  })()}
      
                  ${hasValidTimes ? zeiten.map((z,i)=>{
                      const {day,parts}=splitTimeAtomic(z);
                      const b1=parts[0]||null, b2=parts[1]||null;
                      const F = fontAttr(FONT_THEME, "times");
                      return `<text class="txt" y="${250+i*20}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}"><tspan x="${X.day}">${day}</tspan>${b1?`<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>`:""}${b2?`<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>`:""}</text>`;
                  }).join("") : (() => {
                      const F = fontAttr(FONT_THEME, "times");
                      return `<text class="txt" x="18" y="250" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${e.hinweis}</text>`;
                  })()}
      
                  ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>{
                      const F = fontAttr(FONT_THEME, "hint");
                      return `<text class="txt" x="18" y="${250+zeiten.length*20+10+i*14}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${line}</text>`;
                  }).join("") : ""}
              </svg>
          `.trim();
      }
      
      function buildSmallCard(e) {
          const FONT_THEME = "standard";
          const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const gf         = AVATAR.glowFactorSmall;
          const af         = AVATAR.auraFactorSmall;
          const zeiten     = e.zeiten.map(z => normalizeDays(z));
      
          return `
              <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                  <style>
                  .txt {
                      ${TEXT_SHADOW_STYLE}
                      font-family:${FONT.family};
                      font-feature-settings:"tnum";
                      font-weight:normal;
                      font-style:normal;
                  }
                  </style>
      
                  <defs>
                      <radialGradient id="avatarCoreGlow-${uid}" cx="50%" cy="50%" r="50%">
                          <stop offset="0%"  stop-color="${AVATAR.glass.core.color}" stop-opacity="${AVATAR.glass.core.opacity}"/>
                          <stop offset="22%" stop-color="${AVATAR.glass.glow1.color}" stop-opacity="${AVATAR.glass.glow1.opacity * gf}"/>
                          <stop offset="50%" stop-color="${AVATAR.glass.glow2.color}" stop-opacity="${AVATAR.glass.glow2.opacity * gf}"/>
                          <stop offset="82%" stop-color="${AVATAR.glass.glow3.color}" stop-opacity="${AVATAR.glass.glow3.opacity * gf}"/>
                          <stop offset="100%" stop-color="${AVATAR.glass.fade.color}" stop-opacity="${AVATAR.glass.fade.opacity * gf}"/>
                      </radialGradient>
      
                      <radialGradient id="avatarAuraGlow-${uid}" cx="50%" cy="50%" r="50%">
                          <stop offset="0%"   stop-color="${AVATAR.aura.inner.color}" stop-opacity="${AVATAR.aura.inner.opacity * af}"/>
                          <stop offset="60%"  stop-color="${AVATAR.aura.mid.color}"   stop-opacity="${AVATAR.aura.mid.opacity * af}"/>
                          <stop offset="100%" stop-color="${AVATAR.aura.outer.color}" stop-opacity="${AVATAR.aura.outer.opacity * af}"/>
                      </radialGradient>
      
                      <filter id="avatarBloom-${uid}" x="-250%" y="-250%" width="600%" height="600%"><feGaussianBlur stdDeviation="8" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
                      <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                      <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                  </defs>
      
                  <circle cx="300" cy="80" r="48" fill="url(#avatarAuraGlow-${uid})" filter="url(#avatarBloom-${uid})" opacity="0.88"/>
                  <circle cx="300" cy="80" r="44" fill="url(#avatarCoreGlow-${uid})" filter="url(#avatarBloom-${uid})"/>
                  <circle cx="300" cy="80" r="32" fill="none" stroke="${AVATAR.ring}" stroke-width="1.5"/>
                  <ellipse cx="289" cy="67" rx="11" ry="4" fill="rgba(255,255,255,0.22)" transform="rotate(-25 289 67)"/>
                  <circle cx="300" cy="80" r="22.4" fill="${AVATAR.background}" filter="url(#avatarShadow-${uid})"/>
                  <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
      
                  ${(() => {
                      const lines = [];
                      lines.push({text: e.name, type: "name", y: 30});
                      lines.push({text: `Tel.: ${e.kontakt.telefon}`, type: "contact", y: 55});
                      lines.push({text: "Öffnungszeiten", type: "title", y: 78});
      
                      return lines.map(l => {
                          const F = fontAttr(FONT_THEME, l.type);
                          return `<text class="txt" x="18" y="${l.y}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}">${l.text}</text>`;
                      }).join("");
                  })()}
      
                  ${zeiten.map((z,i)=>{
                      const {day,parts}=splitTimeAtomic(z);
                      const b1=parts[0]||null, b2=parts[1]||null;
                      const F = fontAttr(FONT_THEME, "times");
      
                      return `
                          <text class="txt"
                              y="${95+i*20}" fill="${F.color}" font-size="${F.size}" font-weight="${F.weight}" font-style="${F.style}" style="${fontStyle(F)}"><tspan x="18">${day}</tspan>
                              ${b1?`<tspan x="145" text-anchor="end">${b1.start}</tspan><tspan x="160" text-anchor="middle">–</tspan><tspan x="175" text-anchor="start">${b1.end}</tspan>`:""}
                              ${b2?`<tspan x="255" text-anchor="end">${b2.start}</tspan><tspan x="270" text-anchor="middle">–</tspan><tspan x="285" text-anchor="start">${b2.end}</tspan>`:""}
                          </text>`;
                  }).join("")}
              </svg>
          `.trim();
      }
      
      function buildWaveCard(e) {
          const FONT_THEME    = "wave";
          const THEME         = CARD_THEMES.wave;
          const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
          const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
          const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
          const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
          const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const lineHeight    = 15;
      
          const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
      
          const timesBoxY      = 240;
          const timesBoxHeight = 110;
          const contactBlocks  = [];
          const CONTACT_Y      = [132, 163, 194];
      
          const iconWrap = (cx, cy, icon, scale = 0.55) => `
              <g transform="translate(${cx - 11}, ${cy - 11})">
                  <circle cx="11" cy="11" r="11" fill="${THEME.iconBg}"/>
                  <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                      stroke="${THEME.iconStroke}" stroke-width="2" fill="none">
                      ${icon}
                  </g>
              </g>
          `;
      
          const opacity = getState(root+'opacity').val;
      
          if (tels.length) contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
          if (e.kontakt.fax) contactBlocks.push({ icon: ICON_FAX, text: `Fax: ${e.kontakt.fax}` });
          if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_ADDRESS = fontAttr(FONT_THEME, "address");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_WEB     = fontAttr(FONT_THEME, "web");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
          const F_HINT    = fontAttr(FONT_THEME, "hint");
      
          return `
              <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <defs>
                      <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#f8fbff"/><stop offset="100%" stop-color="#dcecff"/></linearGradient>
                      <linearGradient id="header-${uid}" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#2563eb"/></linearGradient>
                      <clipPath id="avatarClip-${uid}"><rect x="260" y="65" width="65" height="65" rx="16"/></clipPath>
                  </defs>
      
                  <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                  <path d="M0 24 C70 -4 140 44 220 24 C290 4 320 44 ${CARD_LARGE_W} 14 L${CARD_LARGE_W} 0 L0 0 Z" fill="url(#header-${uid})"/>
                  <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <rect x="22" y="56" width="40" height="4" rx="2" fill="${THEME.titleUnderline}"/>
                  <rect x="255" y="60" width="75" height="75" rx="16" fill="#d9e8ff" stroke="${THEME.avatarStroke}"/>
                  <image href="${e.bild}" x="260" y="65" width="65" height="65" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatarClip-${uid})"/>
      
                  ${iconWrap(32, 89, ICON_LOCATION)}
                  <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
      
                  ${contactBlocks.map((entry, i) => {
                      const y = CONTACT_Y[i];
                      if (!y) return "";
      
                      const lines = entry.lines || [entry.text];
                      return `
                          ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                          ${lines.map((line, idx) => `<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
      
                  <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${THEME.timesBoxBg}" stroke="${THEME.timesBoxBorder}"/>
                  ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
      
                  <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
                  ${zeiten.map((z, i) => {
                      const { day, parts } = splitTimeAtomic(z);
                      const b1 = parts[0] || null;
                      const b2 = parts[1] || null;
      
                      return `
                      <text class="txt"
                          y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                          ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                          ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                      </text>`;
                  }).join("")}
                  ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
              </svg>
          `.trim();
      }
      
      function buildWaveCardSmall(e) {
          const FONT_THEME = "wave";
          const THEME      = CARD_THEMES.wave;
          const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const zeiten     = e.zeiten.map(z => normalizeDays(z));
          const opacity    = getState(root+'opacity').val;
      
          const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
      
          return `
              <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <defs>
                      <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#f8fbff"/><stop offset="100%" stop-color="#dcecff"/></linearGradient>
                      <linearGradient id="header-${uid}" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#2563eb"/></linearGradient>
                      <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                      <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                  </defs>
      
                  <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                  <path d="M0 24 C70 -4 140 44 220 24 C290 4 320 44 ${CARD_SMALL_W} 14 L${CARD_SMALL_W} 0 L0 0 Z" fill="url(#header-${uid})"/>
                  <circle cx="300" cy="80" r="32" fill="#d9e8ff" stroke="${THEME.avatarStroke}" stroke-width="1.5"/>
                  <circle cx="300" cy="80" r="22.4" fill="#d9e8ff" filter="url(#avatarShadow-${uid})"/>
                  <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
                  <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                  <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
      
                  ${zeiten.map((z,i)=>{
                      const {day,parts}=splitTimeAtomic(z);
                      const b1=parts[0]||null;
                      const b2=parts[1]||null;
      
                      return `
                          <text class="txt"
                              y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                              ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                              ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                          </text>
                      `;
                  }).join("")}
              </svg>
          `.trim();
      }
      
      function buildTealCard(e) {
          const FONT_THEME    = "teal";
          const THEME         = CARD_THEMES.teal;
          const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
          const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
          const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
          const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
          const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const lineHeight    = 15;
      
          const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
      
          const timesBoxY      = 240;
          const timesBoxHeight = 110;
          const contactBlocks  = [];
          const CONTACT_Y      = [132, 163, 194];
      
          const iconWrap = (cx, cy, icon, scale = 0.55) => `
              <g transform="translate(${cx - 11}, ${cy - 11})">
                  <circle cx="11" cy="11" r="11" fill="${THEME.iconBg}"/>
                  <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                      stroke="${THEME.iconStroke}" stroke-width="2" fill="none">
                      ${icon}
                  </g>
              </g>
          `;
      
          if (tels.length) contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
          if (e.kontakt.fax) contactBlocks.push({ icon: ICON_FAX,  text: `Fax: ${e.kontakt.fax}` });
          if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
      
          const opacity = getState(root+'opacity').val;
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_ADDRESS = fontAttr(FONT_THEME, "address");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_WEB     = fontAttr(FONT_THEME, "web");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
          const F_HINT    = fontAttr(FONT_THEME, "hint");
      
          return `
              <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <defs>
                      <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#005a70"/><stop offset="100%" stop-color="#87c3ab"/></linearGradient>
                      <clipPath id="avatarClip-${uid}"><rect x="260" y="65" width="65" height="65" rx="14"/></clipPath>
                      <filter id="blurBG" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="8"/></filter>
                  </defs>
      
                  <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                  <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <rect x="22" y="56" width="40" height="4" rx="2" fill="${THEME.titleUnderline}" opacity="0.8"/>
                  <rect x="255" y="60" width="75" height="75" rx="16" fill="rgba(255,255,255,0.12)" stroke="${THEME.avatarStroke}"/>
                  <image href="${e.bild}" x="260" y="65" width="65" height="65" clip-path="url(#avatarClip-${uid})" preserveAspectRatio="xMidYMid slice"/>
      
                  ${iconWrap(32, 89, ICON_LOCATION)}
                  <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
      
                  ${contactBlocks.map((entry, i) => {
                      const y = CONTACT_Y[i];
                      if (!y) return "";
      
                      const lines = entry.lines || [entry.text];
                      return `
                          ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                          ${lines.map((line,idx)=>`<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
                  <g>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${THEME.timesBoxBg}" filter="url(#blurBG)"/>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="${THEME.timesBoxBorder}" stroke-width="2"/>
                  </g>
      
                  ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                  <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
      
                  ${zeiten.map((z, i) => {
                      const { day, parts } = splitTimeAtomic(z);
                      const b1 = parts[0] || null;
                      const b2 = parts[1] || null;
      
                      return `
                      <text class="txt"
                          y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                          ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                          ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                      </text>`;
                  }).join("")}
                  ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
              </svg>
          `.trim();
      }
      
      function buildTealCardSmall(e) {
          const FONT_THEME = "teal";
          const THEME      = CARD_THEMES.teal;
          const zeiten     = e.zeiten.map(z => normalizeDays(z));
          const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
      
          const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
      
          const opacity = getState(root+'opacity').val;
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
      
          return `
              <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <defs>
                      <linearGradient id="bg-${uid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#005a70"/><stop offset="100%" stop-color="#87c3ab"/></linearGradient>
                      <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                      <filter id="avatarShadow-${uid}" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000" flood-opacity="0.46"/></filter>
                  </defs>
      
                  <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="url(#bg-${uid})" opacity="${opacity}"/>
                  <circle cx="300" cy="80" r="32" fill="rgba(255,255,255,0.12)" stroke="${THEME.avatarStroke}" stroke-width="1.5"/>
                  <circle cx="300" cy="80" r="22.4" fill="#0f2647" filter="url(#avatarShadow-${uid})"/>
                  <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
                  <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                  <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
                  ${zeiten.map((z,i)=>{
                      const {day,parts}=splitTimeAtomic(z);
                      const b1=parts[0]||null;
                      const b2=parts[1]||null;
      
                      return `
                          <text class="txt"
                              y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                              ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                              ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                          </text>
                      `;
                  }).join("")}
              </svg>
          `.trim();
      }
      
      function buildWhiteCard(e) {
          return buildUniColorCard(e, CARD_THEMES.white);
      }
      
      function buildCoffeeCard(e) {
          return buildUniColorCard(e, CARD_THEMES.coffee);
      }
      
      function buildYellowCard(e) {
          return buildUniColorCard(e, CARD_THEMES.yellow);
      }
      
      function buildGreenCard(e) {
          return buildUniColorCard(e, CARD_THEMES.green);
      }
      
      function buildRedCard(e) {
          return buildUniColorCard(e, CARD_THEMES.red);
      }
      
      function buildBlueCard(e) {
          return buildUniColorCard(e, CARD_THEMES.blue);
      }
      
      function buildUniColorCard(e, theme) {
          const FONT_THEME    = theme.fontTheme;
          const hasValidTimes = e.zeiten.some(z => z.trim() !== "");
          const zeiten        = hasValidTimes ? compressTimes(e.zeiten) : [];
          const hinweisLines  = e.hinweis ? wrapText(e.hinweis, 45) : [];
          const tels          = e.kontakt.telefon ? e.kontakt.telefon.split(/[,;]\s*/) : [];
          const uid           = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const lineHeight    = 15;
      
          const X = {day: 33, s1: 157, d1: 167, e1: 177, s2: 260, d2: 270, e2: 280};
      
          const timesBoxY      = 240;
          const timesBoxHeight = 110;
          const contactBlocks  = [];
          const CONTACT_Y      = [132, 163, 194];
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_ADDRESS = fontAttr(FONT_THEME, "address");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_WEB     = fontAttr(FONT_THEME, "web");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
          const F_HINT    = fontAttr(FONT_THEME, "hint");
      
          const iconWrap = (cx, cy, icon, scale = 0.55) => `
              <g transform="translate(${cx - 11}, ${cy - 11})">
                  <circle cx="11" cy="11" r="11" fill="${theme.iconBg}"/>
                  <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                      stroke="${theme.iconStroke}" stroke-width="2" fill="none">
                      ${icon}
                  </g>
              </g>
          `;
      
          const opacity = getState(root+'opacity').val;
      
          if (tels.length)     contactBlocks.push({ icon: ICON_PHONE, lines: [`Tel.: ${tels[0]}`, ...tels.slice(1)] });
          if (e.kontakt.fax)   contactBlocks.push({ icon: ICON_FAX,  text: `Fax: ${e.kontakt.fax}` });
          if (e.kontakt.email) contactBlocks.push({ icon: ICON_MAIL, text: e.kontakt.email });
      
          return `
              <svg viewBox="0 0 ${CARD_LARGE_W} ${CARD_LARGE_H}" xmlns="http://www.w3.org/2000/svg">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <defs>
                      <clipPath id="avatarClip-${uid}"><circle cx="292" cy="98" r="38"/></clipPath>
                      <filter id="blurUni-${uid}" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2"/></filter>
                  </defs>
                  <rect x="0" y="0" width="${CARD_LARGE_W}" height="${CARD_LARGE_H}" fill="${theme.background}" opacity="${opacity}"/>
                  <text class="txt" x="22" y="44" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <rect x="22" y="56" width="40" height="4" rx="2" fill="${theme.titleUnderline}" opacity="0.9"/>
                  <circle cx="292" cy="98" r="42" fill="none" stroke="${theme.avatarStroke}" stroke-width="2"/>
                  <image href="${e.bild}" x="250" y="56" width="84" height="84" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatarClip-${uid})"/>
      
                  ${iconWrap(32, 89, ICON_LOCATION)}
                  <text class="txt" x="50" y="86" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="${F_ADDRESS.color}" font-size="${F_ADDRESS.size}" font-weight="${F_ADDRESS.weight}" font-style="${F_ADDRESS.style}" style="${fontStyle(F_ADDRESS)}">${e.adresse.plz} ${e.adresse.ort}</text>
      
                  ${contactBlocks.map((entry, i) => {
                      const y = CONTACT_Y[i];
                      if (!y) return "";
      
                      const lines = entry.lines || [entry.text];
                      return `
                          ${entry.icon ? iconWrap(32, y - 4, entry.icon) : ""}
                          ${lines.map((line, idx) => `<text class="txt" x="50" y="${y + idx * 14}" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${F_WEB.color}" font-size="${F_WEB.size}" font-weight="${F_WEB.weight}" font-style="${F_WEB.style}" style="${fontStyle(F_WEB)}">${e.kontakt.web}</text>` : ""}
                  <g>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="${theme.timesBoxBg}" filter="url(#blurUni-${uid})"/>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="${theme.timesBoxBorder}" stroke-width="2"/>
                  </g>
      
                  ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                  <text class="txt" x="58" y="${timesBoxY + 25}" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
      
                  ${zeiten.map((z, i) => {
                      const { day, parts } = splitTimeAtomic(z);
                      const b1 = parts[0] || null;
                      const b2 = parts[1] || null;
      
                      return `
                      <text class="txt"
                          y="${timesBoxY + 48 + i * lineHeight}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                          ${b1 ? `<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>` : ""}
                          ${b2 ? `<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>` : ""}
                      </text>`;
                  }).join("")}
      
                  ${e.hinweis && hasValidTimes ? hinweisLines.map((line, i) => `<text class="txt" x="33" y="${277 + zeiten.length * 20 + 10 + i * 14}" fill="${F_HINT.color}" font-size="${F_HINT.size}" font-weight="${F_HINT.weight}" font-style="${F_HINT.style}" style="${fontStyle(F_HINT)}">${line}</text>`).join("") : ""}
              </svg>
          `.trim();
      }
      
      function buildUniColorCardSmall(e, theme) {
          const FONT_THEME = theme.fontTheme;
          const uid        = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
          const zeiten     = e.zeiten.map(z => normalizeDays(z));
      
          const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
      
          const opacity = getState(root+'opacity').val;
      
          const F_NAME    = fontAttr(FONT_THEME, "name");
          const F_CONTACT = fontAttr(FONT_THEME, "contact");
          const F_TITLE   = fontAttr(FONT_THEME, "title");
          const F_TIMES   = fontAttr(FONT_THEME, "times");
      
          return `
              <svg viewBox="0 0 ${CARD_SMALL_W} ${CARD_SMALL_H}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
                  <style>
                      .txt {
                          ${TEXT_SHADOW_STYLE}
                          font-family:${FONT.family};
                          font-feature-settings:"tnum";
                          font-weight:normal;
                          font-style:normal;
                      }
                  </style>
      
                  <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="${theme.background}" opacity="${opacity}"/>
                  <defs>
                      <clipPath id="avatarClipSmall-${uid}"><circle cx="300" cy="80" r="28"/></clipPath>
                  </defs>
      
                  <circle cx="300" cy="80" r="32" fill="none" stroke="${theme.avatarStroke}" stroke-width="1.5"/>
                  <circle cx="300" cy="80" r="22.4" fill="${AVATAR.background}"/>
                  <image href="${e.bild}" x="272" y="52" width="56" height="56" clip-path="url(#avatarClipSmall-${uid})" preserveAspectRatio="xMidYMid slice"/>
      
                  <text class="txt" x="18" y="30" fill="${F_NAME.color}" font-size="${F_NAME.size}" font-weight="${F_NAME.weight}" font-style="${F_NAME.style}" style="${fontStyle(F_NAME)}">${e.name}</text>
                  <text class="txt" x="18" y="55" fill="${F_CONTACT.color}" font-size="${F_CONTACT.size}" font-weight="${F_CONTACT.weight}" font-style="${F_CONTACT.style}" style="${fontStyle(F_CONTACT)}">Tel.: ${e.kontakt.telefon}</text>
                  <text class="txt" x="18" y="78" fill="${F_TITLE.color}" font-size="${F_TITLE.size}" font-weight="${F_TITLE.weight}" font-style="${F_TITLE.style}" style="${fontStyle(F_TITLE)}">Öffnungszeiten</text>
      
                  ${zeiten.map((z,i)=>{
                      const {day,parts}=splitTimeAtomic(z);
                      const b1=parts[0]||null, b2=parts[1]||null;
      
                      return `
                          <text class="txt"
                              y="${95+i*20}" fill="${F_TIMES.color}" font-size="${F_TIMES.size}" font-weight="${F_TIMES.weight}" font-style="${F_TIMES.style}" style="${fontStyle(F_TIMES)}"><tspan x="${X.day}">${day}</tspan>
                              ${b1?`<tspan x="${X.s1}" text-anchor="end">${b1.start}</tspan><tspan x="${X.d1}" text-anchor="middle">–</tspan><tspan x="${X.e1}" text-anchor="start">${b1.end}</tspan>`:""}
                              ${b2?`<tspan x="${X.s2}" text-anchor="end">${b2.start}</tspan><tspan x="${X.d2}" text-anchor="middle">–</tspan><tspan x="${X.e2}" text-anchor="start">${b2.end}</tspan>`:""}
                          </text>
                      `;
                  }).join("")}
              </svg>
          `.trim();
      }
      
      function setStateIfChanged(id, value) {
          const state = getState(id);
          if (!state) return;
      
          const old = state.val;
          const isObject = v => v !== null && typeof v === "object";
      
          if (isObject(value)) {
              const newStr = JSON.stringify(value);
              let oldStr = null;
      
              if (typeof old === "string") oldStr = old;
              else if (isObject(old)) oldStr = JSON.stringify(old);
      
              if (newStr === oldStr) return;
              return setState(id, newStr, true);
          }
      
          if (old === value) return;
          setState(id, value, true);
      }
      
      const SAMPLE_DATA_BASE64 =
      `ewogICJlaW50cmFlZ2UiOiBbCiAgICB7CiAgICAgICJpZCI6IDEsCiAgICAgICJrYXJ0ZSI6ICJsYXJnZSIsCiAgICAgICJ0eXAiOiAiaGF1c2FyenQiLAogICAgICAi
      bmFtZSI6ICJIYXVzYXJ6dCDigJMgRHIuIE1heCBNdXN0ZXJtYW5uIiwKICAgICAgImJpbGQiOiAiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvYXJ6dC8zMDAvMzAw
      IiwKICAgICAgImFkcmVzc2UiOiB7CiAgICAgICAgInN0cmFzc2UiOiAiTXVzdGVyc3RyYcOfZSAxMiIsCiAgICAgICAgInBseiI6ICIwMTIzNCIsCiAgICAgICAgIm9y
      dCI6ICJNdXN0ZXJzdGFkdCIKICAgICAgfSwKICAgICAgInplaXRlbiI6IFsKICAgICAgICAiTW9udGFnIDA4LjAwIOKAkyAxMi4wMCIsCiAgICAgICAgIkRpZW5zdGFn
      IDA4LjAwIOKAkyAxMi4wMCwgMTQuMDAg4oCTIDE4LjAwIiwKICAgICAgICAiTWl0d29jaCAwOC4wMCDigJMgMTIuMDAiLAogICAgICAgICJEb25uZXJzdGFnIDA4LjAw
      IOKAkyAxMi4wMCIsCiAgICAgICAgIkZyZWl0YWcgMDguMDAg4oCTIDEyLjAwIgogICAgICBdLAogICAgICAia29udGFrdCI6IHsKICAgICAgICAidGVsZWZvbiI6ICIo
      MDEyMzQpIDEyMzQ1NiIsCiAgICAgICAgImZheCI6ICIoMDEyMzQpIDY1NDMyMSIsCiAgICAgICAgImVtYWlsIjogInByYXhpc0BtdXN0ZXJtYW5uLmRlIiwKICAgICAg
      ICAid2ViIjogImh0dHBzOi8vd3d3Lm11c3Rlcm1hbm4taGF1c2FyenQuZGUvIgogICAgICB9LAogICAgICAiaGlud2VpcyI6ICIiCiAgICB9LAogICAgewogICAgICAi
      aWQiOiAyLAogICAgICAia2FydGUiOiAibGFyZ2UiLAogICAgICAidHlwIjogInphaG5hcnp0IiwKICAgICAgIm5hbWUiOiAiWmFobmFyenQg4oCTIERyLiBKdWxpYSBC
      ZWlzcGllbCIsCiAgICAgICJiaWxkIjogImh0dHBzOi8vcGljc3VtLnBob3Rvcy9zZWVkL3phaG5hcnp0LzMwMC8zMDAiLAogICAgICAiYWRyZXNzZSI6IHsKICAgICAg
      ICAic3RyYXNzZSI6ICJCZWlzcGllbHdlZyA1IiwKICAgICAgICAicGx6IjogIjU2Nzg5IiwKICAgICAgICAib3J0IjogIkJlaXNwaWVsc3RhZHQiCiAgICAgIH0sCiAg
      ICAgICJ6ZWl0ZW4iOiBbCiAgICAgICAgIk1vbnRhZyAwOS4wMCDigJMgMTMuMDAiLAogICAgICAgICJEaWVuc3RhZyAwOS4wMCDigJMgMTMuMDAsIDE0LjAwIOKAkyAx
      OC4wMCIsCiAgICAgICAgIk1pdHR3b2NoIDA5LjAwIOKAkyAxMy4wMCIsCiAgICAgICAgIkRvbm5lcnN0YWcgMDkuMDAg4oCTIDEzLjAwLCAxNC4wMCDigJMgMTguMDAi
      LAogICAgICAgICJGcmVpdGFnIDA5LjAwIOKAkyAxMi4wMCIKICAgICAgXSwKICAgICAgImtvbnRha3QiOiB7CiAgICAgICAgInRlbGVmb24iOiAiKDA1Njc4KSA5ODc2
      NTQiLAogICAgICAgICJmYXgiOiAiIiwKICAgICAgICAiZW1haWwiOiAiaW5mb0B6YWhuYXJ6dC1iZWlzcGllbC5kZSIsCiAgICAgICAgIndlYiI6ICJodHRwczovL3d3
      dy56YWhuYXJ6dC1iZWlzcGllbC5kZS8iCiAgICAgIH0sCiAgICAgICJoaW53ZWlzIjogIlRlcm1pbmUgbmFjaCBWZXJlaW5iYXJ1bmciCiAgICB9LAogICAgewogICAg
      ICAiaWQiOiAzLAogICAgICAia2FydGUiOiAic21hbGwiLAogICAgICAidHlwIjogImZyaXNldXIiLAogICAgICAibmFtZSI6ICJGcmlzZXVyIFNhbG9uIEJlaXNwaWVs
      IiwKICAgICAgImJpbGQiOiAiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvZnJpc2V1ci8zMDAvMzAwIiwKICAgICAgImFkcmVzc2UiOiB7CiAgICAgICAgInN0cmFz
      c2UiOiAiIiwKICAgICAgICAicGx6IjogIiIsCiAgICAgICAgIm9ydCI6ICIiCiAgICAgIH0sCiAgICAgICJ6ZWl0ZW4iOiBbCiAgICAgICAgIk1vbnRhZyDigJMgRnJl
      aXRhZyAwOC4wMCDigJMgMTguMDAiLAogICAgICAgICJTYW1zdGFnIG5hY2ggVmVyZWluYmFydW5nIgogICAgICBdLAogICAgICAia29udGFrdCI6IHsKICAgICAgICAi
      dGVsZWZvbiI6ICIoMDEyMzQpIDExMjIzMyIsCiAgICAgICAgImZheCI6ICIiLAogICAgICAgICJlbWFpbCI6ICIiLAogICAgICAgICJ3ZWIiOiAiIgogICAgICB9LAog
      ICAgICAiaGlud2VpcyI6ICIiCiAgICB9CiAgXQp9`;
      

      Wünsche Euch viel Spaß bei der Umsetzung.

      Ro75.

      EDIT: Mit Stand 31.05.2026 gibt es neue Funktionen.

      CARDS.png

      • Themen: 9 verschiedene Themen stehen via Datenpunkt (theme) zur Auswahl. Standard = standard.
      • Opacity: Die Deckkraft kann gesteuert werden. Standard = 1 (100%).
      • Text-Shadow: Kann nun ebenfalls via Datenpunkt (textshadow) gesteuert werden. Standard = false.

      Zusätzlich können Details zentral konfiguriert werden

      const FONT = {
          family: "InterVariable",   // globale Schriftfamilie
      
          fields: {
              name:    { size: 16, color: "#ffffff", bold: true,  italic: false },  // Name / Praxis / Firma
              address: { size: 13, color: "#d0d7e2", bold: false, italic: false },  // Straße, PLZ, Ort
              contact: { size: 12, color: "#9fb3d9", bold: false, italic: false },  // Tel., Fax, E-Mail
              web:     { size: 12, color: "#6fa8ff", bold: false, italic: false },  // Website-Link
              title:   { size: 13, color: "#ffffff", bold: true,  italic: false },  // Abschnittstitel ("Öffnungszeiten")
              times:   { size: 12, color: "#d0d7e2", bold: false, italic: false },  // Öffnungszeiten-Zeilen
              hint:    { size: 12, color: "#ffcc66", bold: true,  italic: false }   // Hinweis-/Infozeilen
          },
      
          weight: {
              normal: 400,  // normales Schriftgewicht
              bold:   600   // fettes Schriftgewicht
          }
      };
      

      Sollte selbsterklärend sein.

      EDIT: Mit Stand 03.06.2026 gibt es Fehlerkorrekturen.

      • Korrektur der Position für den Hinweistext
      • Korrektur der Öffnungszeiten (falsche Zusammenstellung)
      • Bei Öffnungszeiten kann nun zusätzlich zum EN-DASH auch der normale Bindestrich (-) Verwendung finden.
      • Konfiguration (Schriftgröße, Farbe, Stil) der Felder nun in allen Themen möglich.

      EDIT: Mit Stand 05.06.2026 gibt es Fehlerkorrekturen.

      • Korrektur der Themen "wave" und "teal" - Konfiguration jetzt möglich.
      sigi234S Online
      sigi234S Online
      sigi234
      Forum Testing Most Active
      schrieb am zuletzt editiert von
      #2

      @Ro75 sagte:

      die sich direkt in VIS oder jede andere Visualisierung einfügen lassen.

      und wie?

      Bitte benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.
      Immer Daten sichern!

      1 Antwort Letzte Antwort
      0
      • Ro75R Online
        Ro75R Online
        Ro75
        schrieb am zuletzt editiert von Ro75
        #3

        mit einem Basic - String (unescaped). Hier VIS1.
        76ae27c0-3e71-4d7d-b2de-9f15c0dff723-image.jpeg

        Ro75.

        SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

        NegaleinN 1 Antwort Letzte Antwort
        0
        • Ro75R Online
          Ro75R Online
          Ro75
          schrieb am zuletzt editiert von
          #4

          Habe den Code im Eingangspost aktualisiert. Da fehlte eine Funktion.

          Ro75.

          SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

          1 Antwort Letzte Antwort
          0
          • NashraN Online
            NashraN Online
            Nashra
            Most Active Forum Testing
            schrieb am zuletzt editiert von
            #5

            Hi, erstmal tolles Skript.
            Aber ich habe ein kleines Problem bei den Zeiten.
            Im json

                  "zeiten": [
                    "Montag 08.00 – 12.30, 15.00 – 18.00",
                    "Dienstag 08.00 – 12.30, 15.00 – 18.00",
                    "Mitwoch 08.00 – 12.30",
                    "Donnerstag 08.00 – 12.30, 15.00 – 18.00",
                    "Freitag 08.00 – 12.30"
                  ],
            

            Ausgabe
            Screenshot 2026-05-28 130002.png

            Gruß Ralf
            Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

            Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

            1 Antwort Letzte Antwort
            0
            • Ro75R Online
              Ro75R Online
              Ro75
              schrieb am zuletzt editiert von
              #6

              Mittwoch bitte mit 2t, dann klappt das auch.
              Ro75.

              SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

              NashraN 1 Antwort Letzte Antwort
              0
              • Ro75R Ro75

                Mittwoch bitte mit 2t, dann klappt das auch.
                Ro75.

                NashraN Online
                NashraN Online
                Nashra
                Most Active Forum Testing
                schrieb am zuletzt editiert von
                #7

                @Ro75 sagte:

                Mittwoch bitte mit 2t, dann klappt das auch.
                Ro75.

                🤦 boah, da sucht man die ganze Zeit und übersieht ein "t"
                Danke 😊

                Gruß Ralf
                Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                1 Antwort Letzte Antwort
                0
                • Ro75R Online
                  Ro75R Online
                  Ro75
                  schrieb am zuletzt editiert von
                  #8

                  Ich habe am Script 2 kleine Modifikationen vorgenommen. Ab jetzt kann im Konfigurationsbereich die Schrift und der Textschatten konfiguriert werden.

                  const FONT_FAMILY         = "InterVariable";
                  const TEXT_SHADOW_ENABLED = true;   // globaler Text‑Shadow an/aus
                  

                  Diese Einstellungen sind die bisherigen Basiswerte und waren bereits fest definiert. Ab jetzt sind sie konfigurierbar.

                  Das Script kann 1:1 aus dem Post#1 übernommen werden. Falls

                  const root     = "0_userdata.0.Kontakte."
                  

                  angepasst wurde, bitte nach Scriptübernahme wieder korrigieren.

                  Ro75.

                  SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

                  1 Antwort Letzte Antwort
                  1
                  • Ro75R Online
                    Ro75R Online
                    Ro75
                    schrieb am zuletzt editiert von
                    #9

                    Es gibt neue Funktionen.

                    CARDS.png

                    • Themen: 9 verschiedene Themen stehen via Datenpunkt (theme) zur Auswahl. Standard = standard.
                    • Opacity: Die Deckkraft kann gesteuert werden. Standard = 1 (100%).
                    • Text-Shadow: Kann nun ebenfalls via Datenpunkt (textshadow) gesteuert werden. Standard = false.

                    Zusätzlich können Details zentral konfiguriert werden

                    const FONT = {
                        family: "InterVariable",   // globale Schriftfamilie
                    
                        fields: {
                            name:    { size: 16, color: "#ffffff", bold: true,  italic: false },  // Name / Praxis / Firma
                            address: { size: 13, color: "#d0d7e2", bold: false, italic: false },  // Straße, PLZ, Ort
                            contact: { size: 12, color: "#9fb3d9", bold: false, italic: false },  // Tel., Fax, E-Mail
                            web:     { size: 12, color: "#6fa8ff", bold: false, italic: false },  // Website-Link
                            title:   { size: 13, color: "#ffffff", bold: true,  italic: false },  // Abschnittstitel ("Öffnungszeiten")
                            times:   { size: 12, color: "#d0d7e2", bold: false, italic: false },  // Öffnungszeiten-Zeilen
                            hint:    { size: 12, color: "#ffcc66", bold: true,  italic: false }   // Hinweis-/Infozeilen
                        },
                    
                        weight: {
                            normal: 400,  // normales Schriftgewicht
                            bold:   600   // fettes Schriftgewicht
                        }
                    };
                    

                    Sollte selbsterklärend sein. Script im Eingangspost #1 aktualisiert.

                    Ro75.

                    SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

                    1 Antwort Letzte Antwort
                    1
                    • NashraN Online
                      NashraN Online
                      Nashra
                      Most Active Forum Testing
                      schrieb am zuletzt editiert von Nashra
                      #10

                      Kleine Änderung bitte bei den Öffnungszeiten. Die Umrandung ist etwas zu knapp wenn
                      der Text "Termine nach Vereinbarung" angezeigt wird.
                      Sonst super und ein dickes Danke von meiner Frau, die ist happy

                      Edit: Bild vergessen
                      Screenshot 2026-06-01 114620.png

                      Gruß Ralf
                      Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                      Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                      1 Antwort Letzte Antwort
                      0
                      • Ro75R Online
                        Ro75R Online
                        Ro75
                        schrieb am zuletzt editiert von
                        #11

                        Danke für das Feedback. Ich schaue mir das mit dem Design an, damit es auch übergreifend passt. Kann aber paar Tage dauern.

                        Ro75

                        SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

                        NashraN 1 Antwort Letzte Antwort
                        0
                        • Ro75R Ro75

                          Danke für das Feedback. Ich schaue mir das mit dem Design an, damit es auch übergreifend passt. Kann aber paar Tage dauern.

                          Ro75

                          NashraN Online
                          NashraN Online
                          Nashra
                          Most Active Forum Testing
                          schrieb am zuletzt editiert von
                          #12

                          @Ro75 sagte:

                          Danke für das Feedback. Ich schaue mir das mit dem Design an, damit es auch übergreifend passt. Kann aber paar Tage dauern.

                          Ro75

                          Kein Thema, eilt nicht. Habe noch etwas, sag mal, seltsames mit der Anzeige

                                "zeiten": [
                                  "Montag 08.00 – 12.30, 14.30 – 18.00",
                                  "Dienstag 08.00 – 12.30, 14.30 – 18.00",
                                  "Mittwoch 08.00 – 12.30",
                                  "Donnerstag 08.00 – 12.30, 14.30 – 18.00",
                                  "Freitag 08.00 – 12.30"
                                ],
                          

                          Screenshot 2026-06-01 135218.png

                          anderer Eintrag...

                                "zeiten": [
                                  "Montag 08.00 – 11.45, 14.00 – 16.45",
                                  "Dienstag 08.00 – 11.45, 14.00 – 16.45",
                                  "Mittwoch 08.00 – 11.45",
                                  "Donnerstag 08.30 – 11.45, 14.00 – 16.45",
                                  "Freitag 08.30 – 11.45"
                                ],
                          

                          Screenshot 2026-06-01 135402.png

                          und dann werden Änderungen bei z.B.

                                  times:   { size: 12, color: "#d0d7e2", bold: true, italic: false },  // Öffnungszeiten-Zeilen
                          

                          bei mir nicht übernommen.

                          Gruß Ralf
                          Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                          Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                          1 Antwort Letzte Antwort
                          0
                          • NegaleinN Offline
                            NegaleinN Offline
                            Negalein
                            schrieb am zuletzt editiert von
                            #13

                            Servus @ro75

                            Ich steh noch etwas daneben.

                            • Jeder Eintrag benötigt eine eindeutige ID und muss sich exakt an die Struktur der Beispieldaten halten.
                            • Öffnungszeiten müssen exakt im Format „Wochentag HH.MM – HH.MM[, HH.MM – HH.MM]“ stehen
                            und dabei zwingend den echten EN‑DASH (–) verwenden — kein Minuszeichen (-).

                            • wie bring ich diese Base64 Beispieldaten in verständliches editierbares Format?
                            • alle Kontakte in 1 JSON, oder je Kontakt eine eigene?
                            • wie macht man den En-Dash?

                            Danke

                            ° Node.js & System Update ---> sudo apt update, iob stop, sudo apt full-upgrade
                            ° Node.js Fixer ---> iob nodejs-update
                            ° Fixer ---> iob fix

                            NashraN 1 Antwort Letzte Antwort
                            0
                            • Ro75R Online
                              Ro75R Online
                              Ro75
                              schrieb am zuletzt editiert von Ro75
                              #14

                              Das Script erstellt, sofern es das erste mal gestartet wird die Beispieldaten selbst. Genau dafür wird der Base64 Code benötigt. Du selbst musst damit gar nichts machen.

                              Sobald das Script läuft siehst die den Datenpunkt. Anklicken, ansehen und damit arbeiten. Also anpassen, hinzufügen. Wenn gespeichert wird sofort der SVG Code aktualisiert.

                              Ro75

                              SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

                              1 Antwort Letzte Antwort
                              1
                              • NashraN Online
                                NashraN Online
                                Nashra
                                Most Active Forum Testing
                                schrieb am zuletzt editiert von
                                #15

                                Moin @ro75
                                das mit der Anzeige (Uhrzeiten) hat sich erledigt.
                                Es hatten sich Bindestriche eingeschlichen, kam wohl dadurch das ich
                                alles in Notepad++ bearbeitet habe und beim tippen diese dann mit
                                reingehauen hatte. Jetzt wird alles richtig angezeigt.

                                Gruß Ralf
                                Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                                Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                                1 Antwort Letzte Antwort
                                1
                                • NegaleinN Negalein

                                  Servus @ro75

                                  Ich steh noch etwas daneben.

                                  • Jeder Eintrag benötigt eine eindeutige ID und muss sich exakt an die Struktur der Beispieldaten halten.
                                  • Öffnungszeiten müssen exakt im Format „Wochentag HH.MM – HH.MM[, HH.MM – HH.MM]“ stehen
                                  und dabei zwingend den echten EN‑DASH (–) verwenden — kein Minuszeichen (-).

                                  • wie bring ich diese Base64 Beispieldaten in verständliches editierbares Format?
                                  • alle Kontakte in 1 JSON, oder je Kontakt eine eigene?
                                  • wie macht man den En-Dash?

                                  Danke

                                  NashraN Online
                                  NashraN Online
                                  Nashra
                                  Most Active Forum Testing
                                  schrieb am zuletzt editiert von Nashra
                                  #16

                                  @negalein

                                  • alle Kontakte in 1 JSON, oder je Kontakt eine eigene?

                                  kommt alles in eine json, d.h. alles im DP editieren

                                  • wie macht man den En-Dash?

                                  Bei Windows: Alt + 0150 (auf dem Ziffernblock)

                                  Gruß Ralf
                                  Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                                  Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                                  1 Antwort Letzte Antwort
                                  0
                                  • Ro75R Online
                                    Ro75R Online
                                    Ro75
                                    schrieb am zuletzt editiert von Ro75
                                    #17

                                    Die Position vom Hinweis passe ich noch an. Schaue auch, das ich den Bindestrich mit unterstütze. Und auch die Konfiguration. Wie gesagt, kann paar Tage dauern.

                                    Ro75.

                                    SERVER = Beelink U59 16GB DDR4 RAM 512GB SSD, FB 7490, FritzDect 200+301+440, ConBee II, Zigbee Aqara Sensoren + NOUS A1Z, NOUS A1T, Philips Hue ** ioBroker, REDIS, influxdb2, Grafana, PiHole, Plex-Mediaserver, paperless-ngx (Docker), MariaDB + phpmyadmin *** VIS-Runtime = Intel NUC 8GB RAM 128GB SSD + 24" Touchscreen

                                    NashraN 1 Antwort Letzte Antwort
                                    0
                                    • Ro75R Ro75

                                      mit einem Basic - String (unescaped). Hier VIS1.
                                      76ae27c0-3e71-4d7d-b2de-9f15c0dff723-image.jpeg

                                      Ro75.

                                      NegaleinN Offline
                                      NegaleinN Offline
                                      Negalein
                                      schrieb am zuletzt editiert von
                                      #18

                                      @Ro75 sagte:

                                      mit einem Basic - String (unescaped). Hier VIS1.

                                      das funktioniert bei mir nicht

                                      Vis-Edit
                                      Edit-vis-02-06-2026_09_45.png

                                      Vis-Runtime
                                      vis-02-06-2026_09_45.png

                                      ° Node.js & System Update ---> sudo apt update, iob stop, sudo apt full-upgrade
                                      ° Node.js Fixer ---> iob nodejs-update
                                      ° Fixer ---> iob fix

                                      1 Antwort Letzte Antwort
                                      0
                                      • NashraN Online
                                        NashraN Online
                                        Nashra
                                        Most Active Forum Testing
                                        schrieb am zuletzt editiert von
                                        #19

                                        @negalein
                                        du mußt die svg Datenpunkte nehmen
                                        Screenshot 2026-06-02 105714.png

                                        Screenshot 2026-06-02 105645.png

                                        Gruß Ralf
                                        Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                                        Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                                        NegaleinN 1 Antwort Letzte Antwort
                                        0
                                        • Ro75R Ro75

                                          Die Position vom Hinweis passe ich noch an. Schaue auch, das ich den Bindestrich mit unterstütze. Und auch die Konfiguration. Wie gesagt, kann paar Tage dauern.

                                          Ro75.

                                          NashraN Online
                                          NashraN Online
                                          Nashra
                                          Most Active Forum Testing
                                          schrieb am zuletzt editiert von Nashra
                                          #20

                                          @Ro75 sagte:

                                          Die Position vom Hinweis passe ich noch an. Schaue auch, das ich den Bindestrich mit unterstütze. Und auch die Konfiguration. Wie gesagt, kann paar Tage dauern.

                                          Ro75.

                                          Wie schon geschrieben, keine Eile.

                                          Gruß Ralf
                                          Mir egal, wer Dein Vater ist! Wenn ich hier angel, wird nicht übers Wasser gelaufen!!

                                          Benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.

                                          1 Antwort Letzte Antwort
                                          0

                                          Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.

                                          Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.

                                          Mit deinem Input könnte dieser Beitrag noch besser werden 💗

                                          Registrieren Anmelden
                                          Antworten
                                          • In einem neuen Thema antworten
                                          Anmelden zum Antworten
                                          • Älteste zuerst
                                          • Neuste zuerst
                                          • Meiste Stimmen


                                          Support us

                                          ioBroker
                                          Community Adapters
                                          Donate

                                          521

                                          Online

                                          32.9k

                                          Benutzer

                                          83.2k

                                          Themen

                                          1.3m

                                          Beiträge
                                          Community
                                          Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
                                          ioBroker Community 2014-2026
                                          logo
                                          • Anmelden

                                          • Du hast noch kein Konto? Registrieren

                                          • Anmelden oder registrieren, um zu suchen
                                          • Erster Beitrag
                                            Letzter Beitrag
                                          0
                                          • Home
                                          • Aktuell
                                          • Tags
                                          • Ungelesen 0
                                          • Kategorien
                                          • Unreplied
                                          • Beliebt
                                          • GitHub
                                          • Docu
                                          • Hilfe