NEWS
Kontakte (Cards)
-
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.
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.

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

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

-
-
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/ausDiese 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.
-
Es gibt neue Funktionen.

- 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.
-
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 happyEdit: Bild vergessen

-
Danke für das Feedback. Ich schaue mir das mit dem Design an, damit es auch übergreifend passt. Kann aber paar Tage dauern.
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
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" ],
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" ],
und dann werden Änderungen bei z.B.
times: { size: 12, color: "#d0d7e2", bold: true, italic: false }, // Öffnungszeiten-Zeilenbei mir nicht übernommen.
-
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
-
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
-
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. -
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
- 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)
-
@negalein
du mußt die svg Datenpunkte nehmen


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

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

