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."; // 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.25, // Glow‑Dunkelheits‑Faktor NUR für kleine Karte 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 ********** 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 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; } on({ id: JSON_DP, change: "any" }, async () => { await processJSON(); }); function buildSVG(entry) { if (entry.karte === "small") { return buildSmallCard(entry); } else { return buildLargeCard(entry); } } async function main() { await smartCreateState(JSON_DP, "", { type: "string", name: "Kontakte JSON" }); 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 = `card${e.id}`; 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 }; } 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:3px 3px 4px #000;font-feature-settings:"tnum";}</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"/> <text class="txt" x="18" y="40" fill="#fff" font-size="15" font-weight="600">${e.name}</text> <text class="txt" x="18" y="75" fill="#d0d7e2" font-size="13">${e.adresse.strasse}</text> <text class="txt" x="18" y="93" fill="#d0d7e2" font-size="13">${e.adresse.plz} ${e.adresse.ort}</text> ${e.kontakt.telefon.split(/[,;]\s*/).map((tel,i)=>` <text class="txt" x="18" y="${130+i*18}" fill="#9fb3d9" font-size="12">Tel.: ${tel}</text> `).join("")} ${e.kontakt.fax ? `<text class="txt" x="18" y="148" fill="#9fb3d9" font-size="12">Fax: ${e.kontakt.fax}</text>` : ""} ${e.kontakt.email ? `<text class="txt" x="18" y="166" fill="#9fb3d9" font-size="12">${e.kontakt.email}</text>` : ""} ${e.kontakt.web ? `<text class="txt" x="18" y="184" fill="#6fa8ff" font-size="12">${e.kontakt.web}</text>` : ""} <text class="txt" x="18" y="225" fill="#fff" font-size="14" font-weight="600">Öffnungszeiten</text> ${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="#d0d7e2" font-size="12"> <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="#d0d7e2" font-size="12">${e.hinweis}</text> `} ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>` <text class="txt" x="18" y="${250+zeiten.length*20+10+i*14}" fill="#ffcc66" font-size="12" font-weight="600">${line}</text> `).join("") : ""} </svg> `.trim(); } function buildSmallCard(e) { const uid = `card${e.id}`; const gf = AVATAR.glowFactorSmall; 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; } const zeiten = e.zeiten.map(z => normalizeDays(z)); function splitTimeRow(row) { const idx = row.indexOf(" "); if (idx === -1) return { day: row, time: "" }; return { day: row.substring(0, idx), time: row.substring(idx + 1) }; } 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:3px 3px 4px #000;font-family:InterVariable;}</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}"/> <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="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"/> <text class="txt" x="15" y="32" fill="#fff" font-size="15" font-weight="600">${e.name}</text> <text class="txt" x="15" y="55" fill="#9fb3d9" font-size="13">Tel.: ${e.kontakt.telefon}</text> <text class="txt" x="15" y="78" fill="#fff" font-size="13" font-weight="600">Öffnungszeiten</text> ${zeiten.map((z,i)=>{ const row = splitTimeRow(z); return ` <text class="txt" x="15" y="${95+i*15}" fill="#d0d7e2" font-size="12"> <tspan x="15">${row.day}</tspan> <tspan x="120">${row.time}</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.
-
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."; // 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.25, // Glow‑Dunkelheits‑Faktor NUR für kleine Karte 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 ********** 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 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; } on({ id: JSON_DP, change: "any" }, async () => { await processJSON(); }); function buildSVG(entry) { if (entry.karte === "small") { return buildSmallCard(entry); } else { return buildLargeCard(entry); } } async function main() { await smartCreateState(JSON_DP, "", { type: "string", name: "Kontakte JSON" }); 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 = `card${e.id}`; 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 }; } 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:3px 3px 4px #000;font-feature-settings:"tnum";}</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"/> <text class="txt" x="18" y="40" fill="#fff" font-size="15" font-weight="600">${e.name}</text> <text class="txt" x="18" y="75" fill="#d0d7e2" font-size="13">${e.adresse.strasse}</text> <text class="txt" x="18" y="93" fill="#d0d7e2" font-size="13">${e.adresse.plz} ${e.adresse.ort}</text> ${e.kontakt.telefon.split(/[,;]\s*/).map((tel,i)=>` <text class="txt" x="18" y="${130+i*18}" fill="#9fb3d9" font-size="12">Tel.: ${tel}</text> `).join("")} ${e.kontakt.fax ? `<text class="txt" x="18" y="148" fill="#9fb3d9" font-size="12">Fax: ${e.kontakt.fax}</text>` : ""} ${e.kontakt.email ? `<text class="txt" x="18" y="166" fill="#9fb3d9" font-size="12">${e.kontakt.email}</text>` : ""} ${e.kontakt.web ? `<text class="txt" x="18" y="184" fill="#6fa8ff" font-size="12">${e.kontakt.web}</text>` : ""} <text class="txt" x="18" y="225" fill="#fff" font-size="14" font-weight="600">Öffnungszeiten</text> ${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="#d0d7e2" font-size="12"> <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="#d0d7e2" font-size="12">${e.hinweis}</text> `} ${e.hinweis && hasValidTimes ? hinweisLines.map((line,i)=>` <text class="txt" x="18" y="${250+zeiten.length*20+10+i*14}" fill="#ffcc66" font-size="12" font-weight="600">${line}</text> `).join("") : ""} </svg> `.trim(); } function buildSmallCard(e) { const uid = `card${e.id}`; const gf = AVATAR.glowFactorSmall; 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; } const zeiten = e.zeiten.map(z => normalizeDays(z)); function splitTimeRow(row) { const idx = row.indexOf(" "); if (idx === -1) return { day: row, time: "" }; return { day: row.substring(0, idx), time: row.substring(idx + 1) }; } 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:3px 3px 4px #000;font-family:InterVariable;}</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}"/> <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="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"/> <text class="txt" x="15" y="32" fill="#fff" font-size="15" font-weight="600">${e.name}</text> <text class="txt" x="15" y="55" fill="#9fb3d9" font-size="13">Tel.: ${e.kontakt.telefon}</text> <text class="txt" x="15" y="78" fill="#fff" font-size="13" font-weight="600">Öffnungszeiten</text> ${zeiten.map((z,i)=>{ const row = splitTimeRow(z); return ` <text class="txt" x="15" y="${95+i*15}" fill="#d0d7e2" font-size="12"> <tspan x="15">${row.day}</tspan> <tspan x="120">${row.time}</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.
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
