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
    13
    1
    1.0k

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

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

Kontakte (Cards)

Geplant Angeheftet Gesperrt Verschoben JavaScript
javascriptmultimediatemplate
23 Beiträge 4 Kommentatoren 389 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.

    VORAUSSETZUNGEN:

    • JS-Adapter: 9.0.18
    • Node-JS: 22.22.3

    Hier der Code:

    //********** START KONFIGURATION **********
    const root     = "0_userdata.0.Kontakte."
    const JSON_DP  = root + "json";
    const SVG_ROOT = root + "svg.";
    
    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
        }
    };
    
    // 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 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
    };
    
    //Beispieldaten json - als Base64 - für weniger Platz
    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`;
    
    /*
        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"/>`;
    
    const CARD_THEMES = {
        white: {
            iconBg: "#43A047",
            iconStroke: "#ffffff",
            titleUnderline: "#43A047",
            avatarStroke: "#43A047",
            urlColor: "#33691e",
            timesBoxBg: "rgba(190, 235, 159, 0.15)",
            timesBoxBorder: "rgba(0,0,0,0.15)",
            titleColor: "#1a1a1a",
            textColor: "#2d3748",
            timesTitleColor: "#254618",
            hintColor: "#1a1a1a",
            background: "rgba(255,255,255,1)"
        },
        coffee: {
            iconBg: "#6D4C41",
            iconStroke: "#F5F5F5",
            titleUnderline: "#6D4C41",
            avatarStroke: "#6D4C41",
            urlColor: "#6D4C41",
            timesBoxBg: "rgba(209, 188, 175, 0.25)",
            timesBoxBorder: "#A1887F",
            titleColor: "#4E342E",
            textColor: "#4E342E",
            timesTitleColor: "#4E342E",
            hintColor: "#4E342E",
            background: "rgba(245,245,245,1)"
        },
        yellow: {
            iconBg: "#F9A825",
            iconStroke: "#ffffff",
            titleUnderline: "#F9A825",
            avatarStroke: "#F9A825",
            urlColor: "#F57F17",
            timesBoxBg: "rgba(255, 224, 130, 0.25)",
            timesBoxBorder: "#FBC02D",
            titleColor: "#5D4037",
            textColor: "#4E342E",
            timesTitleColor: "#5D4037",
            hintColor: "#5D4037",
            background: "rgba(255,253,231,1)"
        },
        green: {
            iconBg: "#2E7D32",
            iconStroke: "#ffffff",
            titleUnderline: "#2E7D32",
            avatarStroke: "#2E7D32",
            urlColor: "#1B5E20",
            timesBoxBg: "rgba(200, 230, 201, 0.25)",
            timesBoxBorder: "#66BB6A",
            titleColor: "#1B5E20",
            textColor: "#2E7D32",
            timesTitleColor: "#1B5E20",
            hintColor: "#1B5E20",
            background: "rgba(241,248,233,1)"
        },
        red: {
            iconBg: "#C62828",
            iconStroke: "#ffffff",
            titleUnderline: "#C62828",
            avatarStroke: "#C62828",
            urlColor: "#B71C1C",
            timesBoxBg: "rgba(255, 205, 210, 0.25)",
            timesBoxBorder: "#EF5350",
            titleColor: "#B71C1C",
            textColor: "#C62828",
            timesTitleColor: "#B71C1C",
            hintColor: "#B71C1C",
            background: "rgba(255,235,238,1)"
        },
        blue: {
            iconBg: "#1E88E5",
            iconStroke: "#ffffff",
            titleUnderline: "#1E88E5",
            avatarStroke: "#1E88E5",
            urlColor: "#1565C0",
            timesBoxBg: "rgba(144, 202, 249, 0.25)",
            timesBoxBorder: "#64B5F6",
            titleColor: "#0D47A1",
            textColor: "#1E3A5F",
            timesTitleColor: "#0D47A1",
            hintColor: "#0D47A1",
            background: "rgba(227,242,253,1)"
        }
    };
    
    let TEXT_SHADOW_STYLE = "text-shadow:none;";
    
    function fontWeightFor(type) {
        return FONT.fields[type].bold ? FONT.weight.bold : FONT.weight.normal;
    }
    
    function fontStyleFor(type) {
        return FONT.fields[type].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 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(" ");
            const time = rest.join(" ");
    
            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 = [];
        for (const time in groups) {
            const list = groups[time];
    
            if (list.length === 1) {
                result.push(`${list[0]} ${time}`);
            } else {
                result.push(`${list[0]}–${list[list.length - 1]} ${time}`);
            }
        }
        return result.concat(specials);
    }
    
    function splitTimeRow(row) {
        const parts = row.split(" ");
        const day = parts[0];
        const time = parts.slice(1).join(" ");
        return { day, time };
    }
    
    function splitTimeAtomic(row) {
        const idx = row.indexOf(" ");
        if (idx === -1) return { day: row, parts: [] };
    
        const day  = row.substring(0, idx);
        const rest = row.substring(idx + 1).split(",");
        const parts = [];
    
        rest.forEach(block => {
            const m = block.trim().match(/^(\S+)\s*–\s*(\S+)$/);
            if (m) parts.push({ start: m[1], end: m[2] });
        });
        return { day, 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 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 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 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: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285};
    
    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.glow1?.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 => `
                    <text class="txt"
                        x="18"
                        y="${l.y}"
                        fill="${FONT.fields[l.type].color}"
                        font-size="${FONT.fields[l.type].size}"
                        font-weight="${FONT.fields[l.type].bold ? FONT.weight.bold : FONT.weight.normal}"
                        font-style="${FONT.fields[l.type].italic ? "italic" : "normal"}"
                        style="font-size:${FONT.fields[l.type].size}px;">
                        ${l.text}
                    </text>
                `).join("");
            })()}
    
            ${hasValidTimes ? zeiten.map((z,i)=>{
                const {day,parts}=splitTimeAtomic(z);
                const b1=parts[0]||null, b2=parts[1]||null;
                return `
                    <text class="txt"
                        y="${250+i*20}"
                        fill="${FONT.fields.times.color}"
                        font-size="${FONT.fields.times.size}"
                        font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}"
                        font-style="${FONT.fields.times.italic ? "italic" : "normal"}"
                        style="font-size:${FONT.fields.times.size}px;">
                        <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("") : `
                <text class="txt"
                    x="18"
                    y="250"
                    fill="${FONT.fields.times.color}"
                    font-size="${FONT.fields.times.size}"
                    font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}"
                    font-style="${FONT.fields.times.italic ? "italic" : "normal"}"
                    style="font-size:${FONT.fields.times.size}px;">
                    ${e.hinweis}
                </text>
            `}
    
            ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`
                <text class="txt"
                    x="18"
                    y="${250+zeiten.length*20+10+i*14}"
                    fill="${FONT.fields.hint.color}"
                    font-size="${FONT.fields.hint.size}"
                    font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                    font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                    style="font-size:${FONT.fields.hint.size}px;">
                    ${line}
                </text>
            `).join("") : ""}
        </svg>
    `.trim();
    }
    
    function buildSmallCard(e) {
        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";
            }
            </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 => `
                    <text class="txt"
                        x="18"
                        y="${l.y}"
                        fill="${FONT.fields[l.type].color}"
                        font-size="${FONT.fields[l.type].size}"
                        font-weight="${FONT.fields[l.type].bold ? FONT.weight.bold : FONT.weight.normal}"
                        font-style="${FONT.fields[l.type].italic ? "italic" : "normal"}"
                        style="font-size:${FONT.fields[l.type].size}px;">
                        ${l.text}
                    </text>
                `).join("");
            })()}
    
            ${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="${FONT.fields.times.color}" font-size="${FONT.fields.times.size}" font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}" font-style="${FONT.fields.times.italic ? "italic" : "normal"}" style="font-size:${FONT.fields.times.size}px;">
                        <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 uid = `preset2_${e.id}`;
    
        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 lineHeight    = 15;
    
        const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
    
        const timesBoxY      = 240;
        const timesBoxHeight = 110;
        const contactBlocks  = [];
    
        const CONTACT_Y = [
            132, // Telefon
            163, // Fax
            194  // Mail
        ];
    
        const iconWrap = (cx, cy, icon, scale = 0.55) => `
            <g transform="translate(${cx - 11}, ${cy - 11})">
                <circle cx="11" cy="11" r="11" fill="#bfdbfe"/>
                <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                    stroke="#ffffff" 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>
                    <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="#163a70" style="font-size:${FONT.fields.name.size}px !important;">${e.name}</text>
                <rect x="22" y="56" width="40" height="4" rx="2" fill="#3b82f6"/>
                <rect x="255" y="60" width="75" height="75" rx="16" fill="#d9e8ff" stroke="#93c5fd"/>
                <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="#173d74" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="#173d74" style="font-size:${FONT.fields.address.size}px !important;">${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="#173d74" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="#2563eb" style="font-size:${FONT.fields.web.size}px !important;">${e.kontakt.web}</text>` : ""}
                <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="#bfdbfe" opacity="0.38" stroke="#93c5fd"/>
    
                ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                <text class="txt" x="58" y="${timesBoxY + 25}" fill="#1d4ed8" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="#173d74" style="font-size:${FONT.fields.times.size}px !important;">
                        <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                            fill="#1a1a1a"
                            font-size="${FONT.fields.hint.size}"
                            font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                            font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                            style="font-size:${FONT.fields.hint.size}px;">
                            ${line}
                        </text>
                    `).join("")
                    : ""}
            </svg>
        `.trim();
    }
    
    function buildWaveCardSmall(e) {
        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;
    
        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";
                }
            </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="#93c5fd" 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="#163a70" font-size="${FONT.fields.name.size}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
            <text class="txt" x="18" y="55" fill="#173d74" font-size="${FONT.fields.contact.size}" style="font-size:${FONT.fields.contact.size}px;">Tel.: ${e.kontakt.telefon}</text>
            <text class="txt" x="18" y="78" fill="#1d4ed8" font-size="${FONT.fields.title.size}" style="font-size:${FONT.fields.title.size}px;">Ö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="#173d74" font-size="${FONT.fields.times.size}" style="font-size:${FONT.fields.times.size}px;">
                        <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 uid = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
    
        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 lineHeight    = 15;
    
        const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
    
        const timesBoxY      = 240;
        const timesBoxHeight = 110;
        const contactBlocks  = [];
    
        const CONTACT_Y = [
            132, // Telefon
            163, // Fax
            194  // Mail
        ];
    
        const iconWrap = (cx, cy, icon, scale = 0.55) => `
            <g transform="translate(${cx - 11}, ${cy - 11})">
                <circle cx="11" cy="11" r="11" fill="#e8ffef"/>
                <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                    stroke="#4a7763" 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;
    
        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="white" style="font-size:${FONT.fields.name.size}px !important;">${e.name}</text>
                <rect x="22" y="56" width="40" height="4" rx="2" fill="white" opacity="0.8"/>
    
                <rect x="255" y="60" width="75" height="75" rx="16" fill="rgba(255, 255, 255, 0.12)" stroke="rgba(255, 255, 255, 0.35)"/>
                <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="#e8ffef" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="#e8ffef" style="font-size:${FONT.fields.address.size}px !important;">${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="#e8ffef" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="#e8ffef" style="font-size:${FONT.fields.web.size}px !important;">${e.kontakt.web}</text>` : ""}
    
                <g>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="rgba(232,255,239,0.16)" filter="url(#blurBG)"/>
                    <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="rgba(255,255,255,0.45)" stroke-width="2"/>
                </g>
    
                ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                <text class="txt" x="58" y="${timesBoxY + 25}" fill="#e8ffef" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="#e8ffef" style="font-size:${FONT.fields.times.size}px !important;">
                        <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                              fill="#1a1a1a"
                              font-size="${FONT.fields.hint.size}"
                              font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                              font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                              style="font-size:${FONT.fields.hint.size}px;">
                              ${line}
                        </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 uid = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
    
        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 lineHeight    = 15;
    
        const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
    
        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 });
    
        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="${theme.titleColor}" style="font-size:${FONT.fields.name.size}px !important;">${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="${theme.textColor}" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                <text class="txt" x="50" y="101" fill="${theme.textColor}" style="font-size:${FONT.fields.address.size}px !important;">${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="${theme.textColor}" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                    `;
                }).join("")}
    
                ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${theme.urlColor}" style="font-size:${FONT.fields.web.size}px !important;">${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="${theme.timesTitleColor}" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="${theme.textColor}" style="font-size:${FONT.fields.times.size}px !important;">
                        <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                              fill="${theme.hintColor}"
                              font-size="${FONT.fields.hint.size}"
                              font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                              font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                              style="font-size:${FONT.fields.hint.size}px;">
                              ${line}
                        </text>
                    `).join("")
                    : ""}
            </svg>
        `.trim();
    }
    
    function buildTealCardSmall(e) {
        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;
    
        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";
                    }
                </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="rgba(255,255,255,0.35)" 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="white" font-size="${FONT.fields.name.size}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
                <text class="txt" x="18" y="55" fill="#e8ffef" font-size="${FONT.fields.contact.size}" style="font-size:${FONT.fields.contact.size}px;">Tel.: ${e.kontakt.telefon}</text>
                <text class="txt" x="18" y="78" fill="#e8ffef" font-size="${FONT.fields.title.size}" style="font-size:${FONT.fields.title.size}px;">Ö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="#e8ffef" font-size="${FONT.fields.times.size}" style="font-size:${FONT.fields.times.size}px;">
                            <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 buildUniColorCardSmall(e, theme) {
        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));
        const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
    
        const opacity = getState(root+'opacity').val;
    
        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";
                    }
                </style>
                
                <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="${theme.background}" opacity="${opacity}"/>
    
                <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="32" fill="none" stroke="${theme.avatarStroke}" 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"/>
    
                <text class="txt" x="18" y="30" fill="${theme.titleColor}" font-size="${FONT.fields.name.size}" font-weight="${fontWeightFor("name")}" font-style="${fontStyleFor("name")}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
                <text class="txt" x="18" y="55" fill="${theme.textColor}" font-size="${FONT.fields.contact.size}" font-weight="${fontWeightFor("contact")}" font-style="${fontStyleFor("contact")}">Tel.: ${e.kontakt.telefon}</text>
                <text class="txt" x="18" y="78" fill="${theme.timesTitleColor}" font-size="${FONT.fields.title.size}" font-weight="${fontWeightFor("title")}" font-style="${fontStyleFor("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="${theme.textColor}" font-size="${FONT.fields.times.size}" font-weight="${fontWeightFor("times")}" font-style="${fontStyleFor("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);
    }
    

    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.

    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
    3
    • 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.

      VORAUSSETZUNGEN:

      • JS-Adapter: 9.0.18
      • Node-JS: 22.22.3

      Hier der Code:

      //********** START KONFIGURATION **********
      const root     = "0_userdata.0.Kontakte."
      const JSON_DP  = root + "json";
      const SVG_ROOT = root + "svg.";
      
      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
          }
      };
      
      // 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 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
      };
      
      //Beispieldaten json - als Base64 - für weniger Platz
      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`;
      
      /*
          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"/>`;
      
      const CARD_THEMES = {
          white: {
              iconBg: "#43A047",
              iconStroke: "#ffffff",
              titleUnderline: "#43A047",
              avatarStroke: "#43A047",
              urlColor: "#33691e",
              timesBoxBg: "rgba(190, 235, 159, 0.15)",
              timesBoxBorder: "rgba(0,0,0,0.15)",
              titleColor: "#1a1a1a",
              textColor: "#2d3748",
              timesTitleColor: "#254618",
              hintColor: "#1a1a1a",
              background: "rgba(255,255,255,1)"
          },
          coffee: {
              iconBg: "#6D4C41",
              iconStroke: "#F5F5F5",
              titleUnderline: "#6D4C41",
              avatarStroke: "#6D4C41",
              urlColor: "#6D4C41",
              timesBoxBg: "rgba(209, 188, 175, 0.25)",
              timesBoxBorder: "#A1887F",
              titleColor: "#4E342E",
              textColor: "#4E342E",
              timesTitleColor: "#4E342E",
              hintColor: "#4E342E",
              background: "rgba(245,245,245,1)"
          },
          yellow: {
              iconBg: "#F9A825",
              iconStroke: "#ffffff",
              titleUnderline: "#F9A825",
              avatarStroke: "#F9A825",
              urlColor: "#F57F17",
              timesBoxBg: "rgba(255, 224, 130, 0.25)",
              timesBoxBorder: "#FBC02D",
              titleColor: "#5D4037",
              textColor: "#4E342E",
              timesTitleColor: "#5D4037",
              hintColor: "#5D4037",
              background: "rgba(255,253,231,1)"
          },
          green: {
              iconBg: "#2E7D32",
              iconStroke: "#ffffff",
              titleUnderline: "#2E7D32",
              avatarStroke: "#2E7D32",
              urlColor: "#1B5E20",
              timesBoxBg: "rgba(200, 230, 201, 0.25)",
              timesBoxBorder: "#66BB6A",
              titleColor: "#1B5E20",
              textColor: "#2E7D32",
              timesTitleColor: "#1B5E20",
              hintColor: "#1B5E20",
              background: "rgba(241,248,233,1)"
          },
          red: {
              iconBg: "#C62828",
              iconStroke: "#ffffff",
              titleUnderline: "#C62828",
              avatarStroke: "#C62828",
              urlColor: "#B71C1C",
              timesBoxBg: "rgba(255, 205, 210, 0.25)",
              timesBoxBorder: "#EF5350",
              titleColor: "#B71C1C",
              textColor: "#C62828",
              timesTitleColor: "#B71C1C",
              hintColor: "#B71C1C",
              background: "rgba(255,235,238,1)"
          },
          blue: {
              iconBg: "#1E88E5",
              iconStroke: "#ffffff",
              titleUnderline: "#1E88E5",
              avatarStroke: "#1E88E5",
              urlColor: "#1565C0",
              timesBoxBg: "rgba(144, 202, 249, 0.25)",
              timesBoxBorder: "#64B5F6",
              titleColor: "#0D47A1",
              textColor: "#1E3A5F",
              timesTitleColor: "#0D47A1",
              hintColor: "#0D47A1",
              background: "rgba(227,242,253,1)"
          }
      };
      
      let TEXT_SHADOW_STYLE = "text-shadow:none;";
      
      function fontWeightFor(type) {
          return FONT.fields[type].bold ? FONT.weight.bold : FONT.weight.normal;
      }
      
      function fontStyleFor(type) {
          return FONT.fields[type].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 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(" ");
              const time = rest.join(" ");
      
              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 = [];
          for (const time in groups) {
              const list = groups[time];
      
              if (list.length === 1) {
                  result.push(`${list[0]} ${time}`);
              } else {
                  result.push(`${list[0]}–${list[list.length - 1]} ${time}`);
              }
          }
          return result.concat(specials);
      }
      
      function splitTimeRow(row) {
          const parts = row.split(" ");
          const day = parts[0];
          const time = parts.slice(1).join(" ");
          return { day, time };
      }
      
      function splitTimeAtomic(row) {
          const idx = row.indexOf(" ");
          if (idx === -1) return { day: row, parts: [] };
      
          const day  = row.substring(0, idx);
          const rest = row.substring(idx + 1).split(",");
          const parts = [];
      
          rest.forEach(block => {
              const m = block.trim().match(/^(\S+)\s*–\s*(\S+)$/);
              if (m) parts.push({ start: m[1], end: m[2] });
          });
          return { day, 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 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 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 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: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285};
      
      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.glow1?.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 => `
                      <text class="txt"
                          x="18"
                          y="${l.y}"
                          fill="${FONT.fields[l.type].color}"
                          font-size="${FONT.fields[l.type].size}"
                          font-weight="${FONT.fields[l.type].bold ? FONT.weight.bold : FONT.weight.normal}"
                          font-style="${FONT.fields[l.type].italic ? "italic" : "normal"}"
                          style="font-size:${FONT.fields[l.type].size}px;">
                          ${l.text}
                      </text>
                  `).join("");
              })()}
      
              ${hasValidTimes ? zeiten.map((z,i)=>{
                  const {day,parts}=splitTimeAtomic(z);
                  const b1=parts[0]||null, b2=parts[1]||null;
                  return `
                      <text class="txt"
                          y="${250+i*20}"
                          fill="${FONT.fields.times.color}"
                          font-size="${FONT.fields.times.size}"
                          font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}"
                          font-style="${FONT.fields.times.italic ? "italic" : "normal"}"
                          style="font-size:${FONT.fields.times.size}px;">
                          <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("") : `
                  <text class="txt"
                      x="18"
                      y="250"
                      fill="${FONT.fields.times.color}"
                      font-size="${FONT.fields.times.size}"
                      font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}"
                      font-style="${FONT.fields.times.italic ? "italic" : "normal"}"
                      style="font-size:${FONT.fields.times.size}px;">
                      ${e.hinweis}
                  </text>
              `}
      
              ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>`
                  <text class="txt"
                      x="18"
                      y="${250+zeiten.length*20+10+i*14}"
                      fill="${FONT.fields.hint.color}"
                      font-size="${FONT.fields.hint.size}"
                      font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                      font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                      style="font-size:${FONT.fields.hint.size}px;">
                      ${line}
                  </text>
              `).join("") : ""}
          </svg>
      `.trim();
      }
      
      function buildSmallCard(e) {
          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";
              }
              </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 => `
                      <text class="txt"
                          x="18"
                          y="${l.y}"
                          fill="${FONT.fields[l.type].color}"
                          font-size="${FONT.fields[l.type].size}"
                          font-weight="${FONT.fields[l.type].bold ? FONT.weight.bold : FONT.weight.normal}"
                          font-style="${FONT.fields[l.type].italic ? "italic" : "normal"}"
                          style="font-size:${FONT.fields[l.type].size}px;">
                          ${l.text}
                      </text>
                  `).join("");
              })()}
      
              ${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="${FONT.fields.times.color}" font-size="${FONT.fields.times.size}" font-weight="${FONT.fields.times.bold ? FONT.weight.bold : FONT.weight.normal}" font-style="${FONT.fields.times.italic ? "italic" : "normal"}" style="font-size:${FONT.fields.times.size}px;">
                          <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 uid = `preset2_${e.id}`;
      
          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 lineHeight    = 15;
      
          const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
      
          const timesBoxY      = 240;
          const timesBoxHeight = 110;
          const contactBlocks  = [];
      
          const CONTACT_Y = [
              132, // Telefon
              163, // Fax
              194  // Mail
          ];
      
          const iconWrap = (cx, cy, icon, scale = 0.55) => `
              <g transform="translate(${cx - 11}, ${cy - 11})">
                  <circle cx="11" cy="11" r="11" fill="#bfdbfe"/>
                  <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                      stroke="#ffffff" 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>
                      <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="#163a70" style="font-size:${FONT.fields.name.size}px !important;">${e.name}</text>
                  <rect x="22" y="56" width="40" height="4" rx="2" fill="#3b82f6"/>
                  <rect x="255" y="60" width="75" height="75" rx="16" fill="#d9e8ff" stroke="#93c5fd"/>
                  <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="#173d74" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="#173d74" style="font-size:${FONT.fields.address.size}px !important;">${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="#173d74" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="#2563eb" style="font-size:${FONT.fields.web.size}px !important;">${e.kontakt.web}</text>` : ""}
                  <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="#bfdbfe" opacity="0.38" stroke="#93c5fd"/>
      
                  ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                  <text class="txt" x="58" y="${timesBoxY + 25}" fill="#1d4ed8" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="#173d74" style="font-size:${FONT.fields.times.size}px !important;">
                          <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                              fill="#1a1a1a"
                              font-size="${FONT.fields.hint.size}"
                              font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                              font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                              style="font-size:${FONT.fields.hint.size}px;">
                              ${line}
                          </text>
                      `).join("")
                      : ""}
              </svg>
          `.trim();
      }
      
      function buildWaveCardSmall(e) {
          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;
      
          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";
                  }
              </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="#93c5fd" 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="#163a70" font-size="${FONT.fields.name.size}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
              <text class="txt" x="18" y="55" fill="#173d74" font-size="${FONT.fields.contact.size}" style="font-size:${FONT.fields.contact.size}px;">Tel.: ${e.kontakt.telefon}</text>
              <text class="txt" x="18" y="78" fill="#1d4ed8" font-size="${FONT.fields.title.size}" style="font-size:${FONT.fields.title.size}px;">Ö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="#173d74" font-size="${FONT.fields.times.size}" style="font-size:${FONT.fields.times.size}px;">
                          <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 uid = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
      
          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 lineHeight    = 15;
      
          const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
      
          const timesBoxY      = 240;
          const timesBoxHeight = 110;
          const contactBlocks  = [];
      
          const CONTACT_Y = [
              132, // Telefon
              163, // Fax
              194  // Mail
          ];
      
          const iconWrap = (cx, cy, icon, scale = 0.55) => `
              <g transform="translate(${cx - 11}, ${cy - 11})">
                  <circle cx="11" cy="11" r="11" fill="#e8ffef"/>
                  <g transform="translate(${(11 - 11 * scale) - 0.7}, ${(11 - 11 * scale) - 0.4}) scale(${scale})"
                      stroke="#4a7763" 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;
      
          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="white" style="font-size:${FONT.fields.name.size}px !important;">${e.name}</text>
                  <rect x="22" y="56" width="40" height="4" rx="2" fill="white" opacity="0.8"/>
      
                  <rect x="255" y="60" width="75" height="75" rx="16" fill="rgba(255, 255, 255, 0.12)" stroke="rgba(255, 255, 255, 0.35)"/>
                  <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="#e8ffef" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="#e8ffef" style="font-size:${FONT.fields.address.size}px !important;">${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="#e8ffef" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="#e8ffef" style="font-size:${FONT.fields.web.size}px !important;">${e.kontakt.web}</text>` : ""}
      
                  <g>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="rgba(232,255,239,0.16)" filter="url(#blurBG)"/>
                      <rect x="18" y="${timesBoxY}" width="314" height="${timesBoxHeight}" rx="12" fill="none" stroke="rgba(255,255,255,0.45)" stroke-width="2"/>
                  </g>
      
                  ${iconWrap(38, timesBoxY + 21, ICON_CLOCK)}
                  <text class="txt" x="58" y="${timesBoxY + 25}" fill="#e8ffef" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="#e8ffef" style="font-size:${FONT.fields.times.size}px !important;">
                          <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                                fill="#1a1a1a"
                                font-size="${FONT.fields.hint.size}"
                                font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                                font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                                style="font-size:${FONT.fields.hint.size}px;">
                                ${line}
                          </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 uid = `c${Math.random().toString(36).slice(2, 7)}_${e.id}`;
      
          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 lineHeight    = 15;
      
          const X = {day: 33, s1: 150, d1: 160, e1: 170, s2: 255, d2: 265, e2: 275};
      
          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 });
      
          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="${theme.titleColor}" style="font-size:${FONT.fields.name.size}px !important;">${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="${theme.textColor}" style="font-size:${FONT.fields.address.size}px !important;">${e.adresse.strasse}</text>
                  <text class="txt" x="50" y="101" fill="${theme.textColor}" style="font-size:${FONT.fields.address.size}px !important;">${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="${theme.textColor}" style="font-size:${FONT.fields.contact.size}px !important;">${line}</text>`).join("")}
                      `;
                  }).join("")}
      
                  ${e.kontakt.web ? `${iconWrap(32, 221, ICON_GLOBE)}<text class="txt" x="50" y="225" fill="${theme.urlColor}" style="font-size:${FONT.fields.web.size}px !important;">${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="${theme.timesTitleColor}" style="font-size:${FONT.fields.title.size}px !important;">Ö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 + 55 + i * lineHeight}" fill="${theme.textColor}" style="font-size:${FONT.fields.times.size}px !important;">
                          <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="${280 + zeiten.length * 20 + 10 + i * 14}"
                                fill="${theme.hintColor}"
                                font-size="${FONT.fields.hint.size}"
                                font-weight="${FONT.fields.hint.bold ? FONT.weight.bold : FONT.weight.normal}"
                                font-style="${FONT.fields.hint.italic ? "italic" : "normal"}"
                                style="font-size:${FONT.fields.hint.size}px;">
                                ${line}
                          </text>
                      `).join("")
                      : ""}
              </svg>
          `.trim();
      }
      
      function buildTealCardSmall(e) {
          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;
      
          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";
                      }
                  </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="rgba(255,255,255,0.35)" 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="white" font-size="${FONT.fields.name.size}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
                  <text class="txt" x="18" y="55" fill="#e8ffef" font-size="${FONT.fields.contact.size}" style="font-size:${FONT.fields.contact.size}px;">Tel.: ${e.kontakt.telefon}</text>
                  <text class="txt" x="18" y="78" fill="#e8ffef" font-size="${FONT.fields.title.size}" style="font-size:${FONT.fields.title.size}px;">Ö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="#e8ffef" font-size="${FONT.fields.times.size}" style="font-size:${FONT.fields.times.size}px;">
                              <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 buildUniColorCardSmall(e, theme) {
          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));
          const X = { day: 18, s1: 145, d1: 160, e1: 175, s2: 255, d2: 270, e2: 285 };
      
          const opacity = getState(root+'opacity').val;
      
          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";
                      }
                  </style>
                  
                  <rect x="0" y="0" width="${CARD_SMALL_W}" height="${CARD_SMALL_H}" fill="${theme.background}" opacity="${opacity}"/>
      
                  <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="32" fill="none" stroke="${theme.avatarStroke}" 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"/>
      
                  <text class="txt" x="18" y="30" fill="${theme.titleColor}" font-size="${FONT.fields.name.size}" font-weight="${fontWeightFor("name")}" font-style="${fontStyleFor("name")}" style="font-size:${FONT.fields.name.size}px;">${e.name}</text>
                  <text class="txt" x="18" y="55" fill="${theme.textColor}" font-size="${FONT.fields.contact.size}" font-weight="${fontWeightFor("contact")}" font-style="${fontStyleFor("contact")}">Tel.: ${e.kontakt.telefon}</text>
                  <text class="txt" x="18" y="78" fill="${theme.timesTitleColor}" font-size="${FONT.fields.title.size}" font-weight="${fontWeightFor("title")}" font-style="${fontStyleFor("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="${theme.textColor}" font-size="${FONT.fields.times.size}" font-weight="${fontWeightFor("times")}" font-style="${fontStyleFor("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);
      }
      

      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.

      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 Offline
            NashraN Offline
            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 Offline
                NashraN Offline
                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 Offline
                      NashraN Offline
                      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 Offline
                          NashraN Offline
                          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 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 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 Offline
                                NashraN Offline
                                Nashra
                                Most Active Forum Testing
                                schrieb 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 Offline
                                  NashraN Offline
                                  Nashra
                                  Most Active Forum Testing
                                  schrieb 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 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 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 Offline
                                        NashraN Offline
                                        Nashra
                                        Most Active Forum Testing
                                        schrieb 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 Offline
                                          NashraN Offline
                                          Nashra
                                          Most Active Forum Testing
                                          schrieb 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

                                          580

                                          Online

                                          32.9k

                                          Benutzer

                                          83.1k

                                          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