Files
Sonnenweg-Observatory-Home-…/sternwarte-homeassistant-card/dist/sternwarte-card-retro.js
T

1536 lines
58 KiB
JavaScript

/* Sternwarte Card · Retro Edition
* Vintage Maschinen-Bedienpaneel mit echten 7-Segment-Anzeigen,
* Kippschaltern, Jewel-Lampen, illuminierten Druckknoepfen und
* analoger Zeigerinstrument-Anzeige. Funktional identisch mit der
* Standard-Card, aber komplett unabhaengig.
*/
const DEFAULT_ENTITIES = {
roof: "cover.sternwarte_dach",
power: "switch.sternwarte_dachsteuerung",
roofClosed: "binary_sensor.sternwarte_dach_zu",
roofOpen: "binary_sensor.sternwarte_dach_offen",
roofMoving: "binary_sensor.sternwarte_dach_bewegt",
roofStatus: "sensor.sternwarte_dachstatus",
roofPosition: "sensor.sternwarte_dachposition",
rain: "binary_sensor.sternwarte_regen",
wind: "sensor.sternwarte_windgeschwindigkeit",
scheduleOpen: "datetime.sternwarte_offnen_geplant",
scheduleClose: "datetime.sternwarte_schliessen_geplant",
astroLat: 50.2984230,
astroLon: 11.0481766,
};
const ASTRONOMY_LIB_URL = (() => {
try { return new URL("./astronomy.browser.min.js", import.meta.url).toString(); }
catch { return null; }
})();
const MOON_IMAGE_URL = (() => {
try { return new URL("./moon_small.png", import.meta.url).toString(); }
catch { return null; }
})();
/* ---------- 7-Segment-SVG ---------- */
const SEG_POLYS = {
a: "6,0 24,0 27,3 24,6 6,6 3,3",
b: "27,5 30,8 30,20 27,23 24,20 24,8",
c: "27,29 30,32 30,44 27,47 24,44 24,32",
d: "6,44 24,44 27,47 24,50 6,50 3,47",
e: "3,29 6,32 6,44 3,47 0,44 0,32",
f: "3,5 6,8 6,20 3,23 0,20 0,8",
g: "6,22 24,22 27,25 24,28 6,28 3,25",
};
const SEG_MAP = {
"0": "abcdef", "1": "bc", "2": "abdeg", "3": "abcdg", "4": "bcfg",
"5": "acdfg", "6": "acdefg", "7": "abc", "8": "abcdefg", "9": "abcdfg",
"-": "g", " ": "", "_": "d", "?": "abeg",
};
function seg7(text, opts = {}) {
const { color = "red", size = 1, pad = 0 } = opts;
let str = String(text ?? "");
if (pad > 0 && str.length < pad) str = str.padStart(pad, " ");
const out = [];
for (const ch of str) {
if (ch === ":") {
out.push(`<svg class="d7-colon" viewBox="0 0 10 50" aria-hidden="true">
<circle cx="5" cy="20" r="3" class="dot"/>
<circle cx="5" cy="36" r="3" class="dot"/>
</svg>`);
continue;
}
if (ch === ".") {
out.push(`<svg class="d7-dot" viewBox="0 0 10 50" aria-hidden="true">
<circle cx="5" cy="46" r="3" class="dot"/>
</svg>`);
continue;
}
const lit = SEG_MAP[ch.toUpperCase()] ?? "";
const polys = Object.entries(SEG_POLYS).map(([name, points]) =>
`<polygon class="seg${lit.includes(name) ? " on" : ""}" points="${points}"/>`
).join("");
out.push(`<svg class="d7" viewBox="0 0 30 50" aria-hidden="true">${polys}</svg>`);
}
return `<span class="seg7 col-${color}" style="--d7-size:${size}">${out.join("")}</span>`;
}
/* ---------- Card ---------- */
class SternwarteCardRetro extends HTMLElement {
constructor() {
super();
this._scheduleInputState = {
open: { draft: "", editing: false, pending: false },
close: { draft: "", editing: false, pending: false },
};
this._pendingRender = false;
this._astronomyLoadPromise = null;
this._moonTexture = null;
this._moonTexturePromise = null;
}
connectedCallback() {
this._ensureAstronomy();
this._ensureMoonTexture();
this._ensureClockTimer();
}
disconnectedCallback() {
if (this._clockTimer) { window.clearInterval(this._clockTimer); this._clockTimer = null; }
}
setConfig(config) {
this.config = { ...DEFAULT_ENTITIES, ...config };
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
}
set hass(hass) { this._hass = hass; this._ensureClockTimer(); this._requestRender(); }
getCardSize() { return 9; }
getGridOptions() { return { rows: 9, columns: 18, min_rows: 8 }; }
_state(id) { return this._hass?.states?.[id]; }
_ensureClockTimer() {
if (this._clockTimer) return;
this._clockTimer = window.setInterval(() => {
if (this._hass) this._requestRender();
}, 1000);
}
_astronomy() { return window.Astronomy || null; }
_ensureAstronomy() {
const a = this._astronomy();
if (a) return Promise.resolve(a);
if (this._astronomyLoadPromise) return this._astronomyLoadPromise;
if (!ASTRONOMY_LIB_URL) return Promise.resolve(null);
this._astronomyLoadPromise = new Promise((resolve) => {
const finish = () => {
const loaded = this._astronomy();
if (loaded && this._hass) this._requestRender();
this._astronomyLoadPromise = null;
resolve(loaded || null);
};
const existing = Array.from(document.querySelectorAll("script"))
.find((s) => s.src === ASTRONOMY_LIB_URL);
if (existing) {
existing.addEventListener("load", finish, { once: true });
existing.addEventListener("error", finish, { once: true });
return;
}
const s = document.createElement("script");
s.src = ASTRONOMY_LIB_URL; s.async = true;
s.addEventListener("load", finish, { once: true });
s.addEventListener("error", finish, { once: true });
document.head.appendChild(s);
});
return this._astronomyLoadPromise;
}
_ensureMoonTexture() {
if (this._moonTexture) return Promise.resolve(this._moonTexture);
if (this._moonTexturePromise) return this._moonTexturePromise;
if (!MOON_IMAGE_URL) return Promise.resolve(null);
this._moonTexturePromise = new Promise((resolve) => {
const img = new Image();
img.addEventListener("load", () => {
this._moonTexture = img;
this._moonTexturePromise = null;
if (this._hass) this._requestRender();
resolve(img);
}, { once: true });
img.addEventListener("error", () => { this._moonTexturePromise = null; resolve(null); }, { once: true });
img.src = MOON_IMAGE_URL;
});
return this._moonTexturePromise;
}
_requestRender() {
if (this._isScheduleEditing()) { this._pendingRender = true; return; }
this._pendingRender = false;
this.render();
}
_isScheduleEditing() { return Object.values(this._scheduleInputState).some((f) => f.editing); }
_scheduleFieldState(k) { return this._scheduleInputState[k]; }
_scheduleInputValue(k, fb) { const f = this._scheduleFieldState(k); return f.draft || this._isoForInput(fb); }
_resetScheduleDrafts() {
for (const f of Object.values(this._scheduleInputState)) {
f.draft = ""; f.editing = false; f.pending = false;
}
}
_beginScheduleEdit(k, fb) {
const f = this._scheduleFieldState(k);
f.editing = true; f.pending = false;
if (!f.draft) f.draft = this._isoForInput(fb);
}
_updateScheduleDraft(k, v) { const f = this._scheduleFieldState(k); f.draft = v; f.pending = false; }
_endScheduleEdit(k) {
const f = this._scheduleFieldState(k); f.editing = false;
if (!this._isScheduleEditing() && this._pendingRender) this._requestRender();
}
_bindScheduleInput(k, id, fb) {
const i = this.shadowRoot.getElementById(id); if (!i) return;
i.addEventListener("focus", () => this._beginScheduleEdit(k, fb));
i.addEventListener("input", (e) => this._updateScheduleDraft(k, e.target.value));
i.addEventListener("change", (e) => this._updateScheduleDraft(k, e.target.value));
i.addEventListener("blur", () => this._endScheduleEdit(k));
}
_firstExistingEntityId(ids) {
for (const id of ids) if (id && this._state(id)) return id;
return ids.find(Boolean) || null;
}
_scheduleOpenEntityId() {
return this._firstExistingEntityId([
this.config.scheduleOpen,
"datetime.sternwarte_offnen_geplant",
"datetime.sternwarte_oeffnen_geplant",
]);
}
_scheduleCloseEntityId() {
return this._firstExistingEntityId([this.config.scheduleClose, "datetime.sternwarte_schliessen_geplant"]);
}
_powerEntityId() {
const c = this.config?.power;
if (c && this._state(c)) return c;
const states = this._hass?.states || {};
const fb = Object.keys(states).find((id) => {
if (!id.startsWith("switch.")) return false;
const n = id.toLowerCase();
const fn = String(states[id]?.attributes?.friendly_name || "").toLowerCase();
return n.includes("dachsteuer") || (n.includes("sternwarte") && n.includes("power")) || fn.includes("dachsteuerung");
});
return fb || c || "switch.sternwarte_dachsteuerung";
}
_call(d, s) { try { Promise.resolve(this._hass.callService(d, s, {})).catch(() => {}); } catch {} }
_callEntity(d, s, id) {
if (!id) return;
try { Promise.resolve(this._hass.callService(d, s, { entity_id: id })).catch(() => {}); } catch {}
}
_bindClick(id, fn) {
this.shadowRoot.getElementById(id)?.addEventListener("click", (e) => {
e.preventDefault(); e.stopPropagation(); fn();
});
}
_showMoreInfo(id) {
if (!id) return;
const ev = new Event("hass-more-info", { bubbles: true, composed: true });
ev.detail = { entityId: id };
this.dispatchEvent(ev);
}
_bindMoreInfo(id, entityId) {
const el = this.shadowRoot.getElementById(id);
if (!el || !entityId) return;
const open = (e) => { e.preventDefault(); e.stopPropagation(); this._showMoreInfo(entityId); };
el.addEventListener("click", open);
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") open(e); });
}
_setDateTime(eid, inputId, key) {
const i = this.shadowRoot.getElementById(inputId);
const f = this._scheduleFieldState(key);
const v = f?.draft || i?.value || "";
if (!v || !eid) return;
f.pending = true; f.editing = false; f.draft = v;
this._hass.callService("datetime", "set_value", { entity_id: eid, datetime: v });
if (this._pendingRender) this._requestRender();
}
_isoForInput(state) {
if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") return "";
const v = new Date(state.state); if (Number.isNaN(v.getTime())) return "";
const p = (n) => String(n).padStart(2, "0");
return `${v.getFullYear()}-${p(v.getMonth() + 1)}-${p(v.getDate())}T${p(v.getHours())}:${p(v.getMinutes())}`;
}
_seg7DateTime(state) {
if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") return "--:--";
const v = new Date(state.state); if (Number.isNaN(v.getTime())) return "--:--";
const p = (n) => String(n).padStart(2, "0");
return `${p(v.getDate())}.${p(v.getMonth() + 1)} ${p(v.getHours())}:${p(v.getMinutes())}`;
}
_roofPositionValue() {
const v = Number(this._state(this.config.roofPosition)?.state);
if (Number.isNaN(v)) return null;
return Math.max(0, Math.min(100, Math.round(v)));
}
_windValue() {
const w = this._state(this.config.wind);
const v = Number(w?.state);
if (Number.isNaN(v)) return { num: null, unit: "km/h", level: 0 };
let level = 0;
if (v > 15) level = 2; else if (v > 10) level = 1;
return { num: Math.round(v), unit: w?.attributes?.unit_of_measurement ?? "km/h", level };
}
/* --- astronomy / moon (unchanged from original) --- */
_isMESZ(d) {
const y = d.getUTCFullYear();
const lastSunday = (m, h) => {
const e = new Date(Date.UTC(y, m + 1, 0, h));
while (e.getUTCDay() !== 0) e.setUTCDate(e.getUTCDate() - 1);
return e;
};
return d >= lastSunday(2, 1) && d < lastSunday(9, 1);
}
_astroObserver() {
const a = this._astronomy(); if (!a) return null;
const lat = Number(this.config.astroLat), lon = Number(this.config.astroLon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return new a.Observer(lat, lon, 0);
}
_astroTimeToUtcHour(t) {
if (!t) return null;
const d = t.date instanceof Date ? t.date : null;
if (!d || Number.isNaN(d.getTime())) return null;
return d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600;
}
_calcTwilight(date) {
const a = this._astronomy(), o = this._astroObserver();
const empty = { sun: { dawn: null, dusk: null }, civil: { dawn: null, dusk: null }, naut: { dawn: null, dusk: null }, astro: { dawn: null, dusk: null } };
if (!a || !o) return empty;
try {
const body = a.Body?.Sun ?? "Sun";
const start = new Date(date); start.setHours(0, 0, 0, 0);
const limit = 2;
const ev = (alt) => ({
dawn: this._astroTimeToUtcHour(a.SearchAltitude(body, o, +1, start, limit, alt)),
dusk: this._astroTimeToUtcHour(a.SearchAltitude(body, o, -1, start, limit, alt)),
});
return {
sun: { dawn: this._astroTimeToUtcHour(a.SearchRiseSet(body, o, +1, start, limit, 0)), dusk: this._astroTimeToUtcHour(a.SearchRiseSet(body, o, -1, start, limit, 0)) },
civil: ev(-6), naut: ev(-12), astro: ev(-18),
};
} catch { return empty; }
}
_moonStateAt(d) {
const a = this._astronomy(), o = this._astroObserver();
if (!a || !o) return { altitude: -Infinity, illumination: 0, phaseAngle: 0 };
try {
const body = a.Body?.Moon ?? "Moon";
const eq = a.Equator(body, d, o, true, true);
const hz = a.Horizon(d, o, eq.ra, eq.dec, "normal");
const ill = a.Illumination(body, d);
const ph = Number(a.MoonPhase(d)) || 0;
return {
altitude: (Number(hz?.altitude) || 0) * Math.PI / 180,
illumination: Math.max(0, Math.min(1, Number(ill?.phase_fraction) || 0)),
phaseAngle: ((ph % 360) + 360) % 360,
};
} catch { return { altitude: -Infinity, illumination: 0, phaseAngle: 0 }; }
}
_moonVisualState() {
const m = this._moonStateAt(new Date());
const ph = m.phaseAngle ?? 0;
const waxing = ph <= 180;
const r = ph * Math.PI / 180;
return {
illumination: Math.max(0, Math.min(1, m.illumination || 0)),
illuminationPercent: Math.round((m.illumination || 0) * 100),
waxing, phaseAngle: ph, cosPhase: Math.cos(r),
};
}
_renderMoonPhaseDataUrl(mv, size = 64) {
const tx = this._moonTexture; if (!tx) return "";
const c = document.createElement("canvas"); c.width = size; c.height = size;
const ctx = c.getContext("2d"); if (!ctx) return "";
const r = size / 2 - 1, x = size / 2, y = size / 2;
const cp = Number(mv.cosPhase) || 0;
ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = "rgba(8,10,16,0.96)"; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 0.98; ctx.drawImage(tx, x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(8,10,16,0.96)"; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.clip();
if (mv.illumination >= 0.999) {
ctx.drawImage(tx, x - r, y - r, r * 2, r * 2);
} else if (mv.illumination > 0.001) {
ctx.beginPath();
for (let i = 0; i <= 72; i++) {
const t = -r + (2 * r * i) / 72;
const xr = Math.sqrt(Math.max(0, r * r - t * t));
const xt = (mv.waxing ? cp : -cp) * xr;
if (i === 0) ctx.moveTo(x + xt, y + t); else ctx.lineTo(x + xt, y + t);
}
for (let i = 72; i >= 0; i--) {
const t = -r + (2 * r * i) / 72;
const xr = Math.sqrt(Math.max(0, r * r - t * t));
ctx.lineTo(x + (mv.waxing ? xr : -xr), y + t);
}
ctx.closePath(); ctx.clip();
ctx.drawImage(tx, x - r, y - r, r * 2, r * 2);
}
ctx.restore(); ctx.restore();
ctx.strokeStyle = "rgba(255,200,80,0.35)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.stroke();
return c.toDataURL("image/png");
}
_astroState() {
const now = new Date();
const tw = this._calcTwilight(now);
const off = this._isMESZ(now) ? 2 : 1;
const shift = (h) => ((h - 12 + 24) % 24);
const localize = (e) => e ? { dawn: (e.dawn + off + 24) % 24, dusk: (e.dusk + off + 24) % 24 } : null;
const lt = { sun: localize(tw.sun), civil: localize(tw.civil), naut: localize(tw.naut), astro: localize(tw.astro) };
const nowL = shift(now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600);
const segs = [];
const colors = { night: "#0a1320", astro: "#23426d", naut: "#3d6aaf", civil: "#b87a3a", day: "#e8b94c" };
const add = (s, e, k) => {
if (s === null || e === null) return;
const ss = shift(s), ee = shift(e);
if (ee < ss) { segs.push({ start: ss, end: 24, color: colors[k] }); segs.push({ start: 0, end: ee, color: colors[k] }); return; }
if (ee <= ss) return;
segs.push({ start: ss, end: ee, color: colors[k] });
};
add(0, lt.astro?.dawn, "night");
add(lt.astro?.dawn, lt.naut?.dawn, "astro");
add(lt.naut?.dawn, lt.civil?.dawn, "naut");
add(lt.civil?.dawn, lt.sun?.dawn, "civil");
add(lt.sun?.dawn, lt.sun?.dusk, "day");
add(lt.sun?.dusk, lt.civil?.dusk, "civil");
add(lt.civil?.dusk, lt.naut?.dusk, "naut");
add(lt.naut?.dusk, lt.astro?.dusk, "astro");
add(lt.astro?.dusk, 24, "night");
if (!segs.length) add(0, 24, "night");
segs.sort((a, b) => a.start - b.start);
const stops = segs.flatMap((s) => {
const a = `${(s.start / 24) * 100}%`, b = `${(s.end / 24) * 100}%`;
return [`${s.color} ${a}`, `${s.color} ${b}`];
});
const grad = stops.length ? `linear-gradient(90deg, ${stops.join(", ")})` : colors.night;
const toLocal = (st) => {
if (!st?.state || st.state === "unknown" || st.state === "unavailable") return null;
const v = new Date(st.state); if (Number.isNaN(v.getTime())) return null;
return shift(v.getHours() + v.getMinutes() / 60);
};
return {
gradient: grad,
markerLeft: `${(nowL / 24) * 100}%`,
openMarkerLeft: toLocal(this._state(this._scheduleOpenEntityId())),
closeMarkerLeft: toLocal(this._state(this._scheduleCloseEntityId())),
};
}
/* ---------- render ---------- */
render() {
if (!this.shadowRoot || !this._hass) return;
const powerEid = this._powerEntityId();
const powerState = this._state(powerEid)?.state;
const powerOn = powerState === "on";
const powerOff = powerState === "off";
const rainOn = this._state(this.config.rain)?.state === "on";
const movingOn = this._state(this.config.roofMoving)?.state === "on";
const openOn = this._state(this.config.roofOpen)?.state === "on";
const closedOn = this._state(this.config.roofClosed)?.state === "on";
const openSchedEid = this._scheduleOpenEntityId();
const closeSchedEid = this._scheduleCloseEntityId();
const openSched = this._state(openSchedEid);
const closeSched = this._state(closeSchedEid);
const openInputV = this._scheduleInputValue("open", openSched);
const closeInputV = this._scheduleInputValue("close", closeSched);
if (openInputV === this._isoForInput(openSched)) {
this._scheduleFieldState("open").draft = "";
this._scheduleFieldState("open").pending = false;
}
if (closeInputV === this._isoForInput(closeSched)) {
this._scheduleFieldState("close").draft = "";
this._scheduleFieldState("close").pending = false;
}
const pos = this._roofPositionValue();
const wind = this._windValue();
const moonV = this._moonVisualState();
const moonImg = this._renderMoonPhaseDataUrl(moonV, 96);
const astro = this._astroState();
let openDis, closeDis, stopDis;
if (!powerOn) { openDis = closeDis = stopDis = true; }
else if (movingOn) { openDis = closeDis = true; stopDis = false; }
else if (openOn && !closedOn) { openDis = true; closeDis = false; stopDis = true; }
else if (closedOn && !openOn) { openDis = false; closeDis = true; stopDis = true; }
else if (closedOn && openOn) { openDis = closeDis = true; stopDis = false; }
else { openDis = closeDis = stopDis = false; }
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
const dateStr = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${String(now.getFullYear()).slice(-2)}`;
/* analog gauge for roof position */
const gaugeAngle = pos === null ? -135 : -135 + (pos / 100) * 270;
const positionStr = pos === null ? " --" : String(pos).padStart(3, " ");
const windStr = wind.num === null ? " --" : String(wind.num).padStart(3, " ");
const moonStr = String(moonV.illuminationPercent).padStart(3, " ");
/* status string for main display */
let statusStr = "READY";
if (!powerOn && powerOff) statusStr = " STBY";
else if (movingOn) statusStr = " RUN ";
else if (openOn && !closedOn) statusStr = "OPEN ";
else if (closedOn && !openOn) statusStr = "SHUT ";
else if (rainOn) statusStr = "RAIN ";
const css = this._renderStyles();
this.shadowRoot.innerHTML = `
<style>${css}</style>
<ha-card>
<div class="console">
<div class="rivets r-tl"></div><div class="rivets r-tr"></div>
<div class="rivets r-bl"></div><div class="rivets r-br"></div>
<header class="header">
<div class="dymo">SONNENWEG OBSERVATORY &middot; DOME CONTROL UNIT MK&minus;II</div>
<div class="header-right">
<div class="serial">SER. №&nbsp;1966&minus;A</div>
<div class="pwr-led ${powerOn ? "on" : powerOff ? "off" : "unk"}" title="${powerState ?? "unbekannt"}"></div>
</div>
</header>
${rainOn ? `
<div class="alarm-strip">
<span class="alarm-lamp"></span>
<span class="alarm-text">⚠ REGEN ERKANNT &middot; DACH GESPERRT ⚠</span>
<span class="alarm-lamp"></span>
</div>
` : ""}
<section class="row row-displays">
<!-- Status panel: 7-seg readouts -->
<div class="bezel display-bezel">
<div class="bezel-label">PRIMARY READOUT</div>
<div class="display-grid">
<div class="seg-row">
<span class="seg-cap">UHR</span>
${seg7(timeStr, { color: "red", size: 0.75 })}
</div>
<div class="seg-row">
<span class="seg-cap">DAT</span>
${seg7(dateStr, { color: "amber", size: 0.55 })}
</div>
<div class="seg-row">
<span class="seg-cap">POS</span>
${seg7(positionStr, { color: "red", size: 0.75 })}
<span class="seg-unit">%</span>
</div>
<div class="seg-row">
<span class="seg-cap">WND</span>
${seg7(windStr, { color: wind.level === 2 ? "red" : wind.level === 1 ? "amber" : "green", size: 0.75 })}
<span class="seg-unit">${wind.unit}</span>
</div>
<div class="seg-row">
<span class="seg-cap">MND</span>
${seg7(moonStr, { color: "amber", size: 0.55 })}
<span class="seg-unit">%</span>
</div>
<div class="seg-row status-row">
<span class="seg-cap">STA</span>
${seg7(statusStr, { color: powerOn ? "green" : "amber", size: 0.55 })}
</div>
</div>
</div>
<!-- Indicator lamps + analog gauge column -->
<div class="col-stack">
<div class="bezel lamp-bezel">
<div class="bezel-label">DOME STATUS</div>
<div class="lamps">
${this._jewel("DACH ZU", "green", closedOn)}
${this._jewel("OFFEN", "green", openOn)}
${this._jewel("BEWEGT", "amber", movingOn, true)}
${this._jewel("REGEN", "red", rainOn, true)}
${this._jewel("POWER", "white", powerOn)}
${this._jewel("FEHLER", "red", powerState === undefined || powerState === "unavailable")}
</div>
</div>
<div class="bezel gauge-bezel" id="pos-gauge" role="button" tabindex="0" aria-label="Dachposition">
<div class="bezel-label">POSITION</div>
<svg class="gauge" viewBox="0 0 200 130" aria-hidden="true">
<defs>
<radialGradient id="gFace" cx="50%" cy="60%" r="80%">
<stop offset="0%" stop-color="#f4ecd2"/>
<stop offset="60%" stop-color="#e7dab5"/>
<stop offset="100%" stop-color="#bfae7e"/>
</radialGradient>
<linearGradient id="gNeedle" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#1a1a1a"/>
<stop offset="100%" stop-color="#444"/>
</linearGradient>
</defs>
<path d="M10,110 A95,95 0 0 1 190,110 L190,125 L10,125 Z" fill="url(#gFace)" stroke="#3a2c12" stroke-width="1.5"/>
<!-- arc band -->
<path d="M30,110 A75,75 0 0 1 170,110" fill="none" stroke="#3a2c12" stroke-width="1"/>
${this._gaugeTicks()}
<!-- needle -->
<g transform="translate(100,110) rotate(${gaugeAngle})">
<polygon points="0,-72 -3,-4 0,4 3,-4" fill="url(#gNeedle)" stroke="#000" stroke-width="0.4"/>
<circle r="9" fill="#2b2b2b" stroke="#000" stroke-width="1"/>
<circle r="3" fill="#888"/>
</g>
<text x="100" y="100" text-anchor="middle" font-size="9" font-family="'Special Elite',serif" fill="#3a2c12" letter-spacing="2">DOME&nbsp;%</text>
<text x="35" y="118" text-anchor="middle" font-size="9" font-family="'Special Elite',serif" fill="#3a2c12">0</text>
<text x="100" y="40" text-anchor="middle" font-size="9" font-family="'Special Elite',serif" fill="#3a2c12">50</text>
<text x="165" y="118" text-anchor="middle" font-size="9" font-family="'Special Elite',serif" fill="#3a2c12">100</text>
</svg>
</div>
</div>
<!-- Moon scope -->
<div class="bezel moon-bezel" id="moon-scope">
<div class="bezel-label">MOON SCOPE</div>
<div class="scope">
<div class="scope-glass">
${moonImg ? `<img class="moon-img" src="${moonImg}" alt="Mond">` : `<div class="moon-img placeholder"></div>`}
<div class="scope-cross"></div>
</div>
</div>
<div class="scope-readout">
${seg7(moonStr, { color: "amber", size: 0.5 })}
<span class="seg-unit">% ILL</span>
</div>
</div>
</section>
<!-- Twilight strip chart -->
<section class="bezel twilight-bezel">
<div class="bezel-label">DIURNAL CHART &middot; 12 → 12</div>
<div class="strip">
<div class="strip-paper" style="background:${astro.gradient}"></div>
<div class="strip-grid"></div>
${astro.openMarkerLeft === null ? "" : `<div class="strip-mark open" style="left:${(astro.openMarkerLeft / 24) * 100}%" title="Öffnen geplant"></div>`}
${astro.closeMarkerLeft === null ? "" : `<div class="strip-mark close" style="left:${(astro.closeMarkerLeft / 24) * 100}%" title="Schließen geplant"></div>`}
<div class="strip-now" style="left:${astro.markerLeft}"></div>
<div class="strip-ticks">
<span style="left:0%">12</span>
<span style="left:25%">18</span>
<span style="left:50%">00</span>
<span style="left:75%">06</span>
<span style="left:100%">12</span>
</div>
</div>
</section>
<!-- Master power toggle + push buttons -->
<section class="row row-controls">
<div class="bezel toggle-bezel">
<div class="bezel-label">MASTER POWER</div>
<div class="toggle-area">
<div class="guard ${powerOn ? "lifted" : ""}"></div>
<div class="toggle ${powerOn ? "up" : "down"}" id="power-toggle" role="switch" aria-checked="${powerOn}" tabindex="0" title="Dachsteuerung umschalten">
<div class="toggle-frame">
<div class="toggle-base"></div>
<div class="toggle-handle">
<div class="toggle-tip"></div>
</div>
<div class="toggle-marks">
<span class="mk top">EIN</span>
<span class="mk bot">AUS</span>
</div>
</div>
</div>
<div class="power-stat">
${seg7(powerOn ? " ON" : powerOff ? "OFF" : "---", { color: powerOn ? "green" : "red", size: 0.5 })}
</div>
</div>
</div>
<div class="bezel buttons-bezel">
<div class="bezel-label">DOME COMMAND</div>
<div class="buttons">
<button type="button" class="pbtn pbtn-green" id="open-btn" ${openDis ? "disabled" : ""}>
<span class="pbtn-cap">ÖFFNEN</span>
</button>
<button type="button" class="pbtn pbtn-mushroom" id="stop-btn" ${stopDis ? "disabled" : ""}>
<span class="pbtn-cap">STOP</span>
</button>
<button type="button" class="pbtn pbtn-amber" id="close-btn" ${closeDis ? "disabled" : ""}>
<span class="pbtn-cap">SCHLIESSEN</span>
</button>
</div>
</div>
</section>
<!-- Schedule programmer -->
<section class="bezel sched-bezel">
<div class="bezel-label">SCHEDULE PROGRAMMER</div>
<div class="sched-grid">
<div class="sched-row">
<span class="sched-cap">ÖFFNEN</span>
<div class="sched-readout">${seg7(this._seg7DateTime(openSched), { color: "amber", size: 0.45 })}</div>
<input id="open-schedule-input" type="datetime-local" value="${openInputV}">
<button type="button" class="kbtn" id="open-schedule-btn">SET</button>
</div>
<div class="sched-row">
<span class="sched-cap">SCHLIESSEN</span>
<div class="sched-readout">${seg7(this._seg7DateTime(closeSched), { color: "amber", size: 0.45 })}</div>
<input id="close-schedule-input" type="datetime-local" value="${closeInputV}">
<button type="button" class="kbtn" id="close-schedule-btn">SET</button>
</div>
<div class="sched-actions">
<button type="button" class="kbtn kbtn-secondary" id="nautical-btn">NAUTISCH</button>
<button type="button" class="kbtn kbtn-secondary" id="clear-schedule-btn">LÖSCHEN</button>
<button type="button" class="kbtn kbtn-info" id="wind-btn">WIND&nbsp;DETAIL</button>
</div>
</div>
</section>
<footer class="footer">
<span>SONNENWEG · SCHWARZENBACH AM WALD</span>
<span>50.298° N &middot; 11.048° E</span>
<span>SW&minus;CTRL/2.0 — RETRO PANEL</span>
</footer>
</div>
</ha-card>
`;
this._bindClick("open-btn", () => this._callEntity("cover", "open_cover", this.config.roof));
this._bindClick("stop-btn", () => this._callEntity("cover", "stop_cover", this.config.roof));
this._bindClick("close-btn", () => this._callEntity("cover", "close_cover", this.config.roof));
this._bindClick("nautical-btn", () => this._call("sternwarte", "set_nautical_schedule"));
this._bindClick("clear-schedule-btn", () => {
this._resetScheduleDrafts();
this._call("sternwarte", "clear_schedule");
this._requestRender();
});
this._bindClick("open-schedule-btn", () => this._setDateTime(openSchedEid, "open-schedule-input", "open"));
this._bindClick("close-schedule-btn", () => this._setDateTime(closeSchedEid, "close-schedule-input", "close"));
this._bindMoreInfo("wind-btn", this.config.wind);
this._bindMoreInfo("pos-gauge", this.config.roofPosition);
this._bindMoreInfo("moon-scope", this.config.roofStatus);
this._bindScheduleInput("open", "open-schedule-input", openSched);
this._bindScheduleInput("close", "close-schedule-input", closeSched);
/* power toggle */
const tog = this.shadowRoot.getElementById("power-toggle");
if (tog) {
const flip = () => {
const svc = powerOn ? "turn_off" : "turn_on";
this._callEntity("switch", svc, powerEid);
};
tog.addEventListener("click", (e) => { e.preventDefault(); flip(); });
tog.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); flip(); }
});
}
}
_jewel(label, color, on, blink = false) {
return `
<div class="jewel">
<div class="jewel-bezel">
<div class="jewel-lens lens-${color} ${on ? "on" : "off"} ${blink && on ? "blink" : ""}"></div>
</div>
<div class="jewel-label">${label}</div>
</div>
`;
}
_gaugeTicks() {
let s = "";
for (let i = 0; i <= 10; i++) {
const ang = -135 + (i / 10) * 270;
const major = i % 5 === 0;
const r1 = 75, r2 = major ? 64 : 69;
const rad = ang * Math.PI / 180;
const x1 = 100 + r1 * Math.sin(rad), y1 = 110 - r1 * Math.cos(rad);
const x2 = 100 + r2 * Math.sin(rad), y2 = 110 - r2 * Math.cos(rad);
s += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#3a2c12" stroke-width="${major ? 1.6 : 0.8}"/>`;
}
return s;
}
_renderStyles() {
return `
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&family=Share+Tech+Mono&display=swap');
:host {
display: block;
font-family: 'Share Tech Mono', 'Courier New', monospace;
}
ha-card {
background: transparent;
border: none;
box-shadow: none;
overflow: hidden;
}
/* === overall console === */
.console {
position: relative;
padding: 22px 22px 18px;
border-radius: 14px;
color: #f1e4c5;
background:
radial-gradient(circle at 30% 20%, rgba(255,235,180,0.06), transparent 60%),
radial-gradient(circle at 80% 90%, rgba(0,0,0,0.35), transparent 70%),
repeating-linear-gradient(90deg, rgba(255,255,255,0.025) 0 1px, transparent 1px 3px),
linear-gradient(160deg, #4d5a3e 0%, #3b4731 35%, #2c361f 100%);
box-shadow:
inset 0 0 0 2px #1c2210,
inset 0 0 0 4px #6b7a55,
inset 0 0 60px rgba(0,0,0,0.5),
0 8px 24px rgba(0,0,0,0.45);
text-shadow: 0 1px 0 rgba(0,0,0,0.4);
}
.rivets {
position: absolute;
width: 14px; height: 14px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 35%, #2a2515 75%, #000 100%);
box-shadow:
inset -1px -1px 2px rgba(0,0,0,0.7),
0 1px 2px rgba(0,0,0,0.6);
}
.r-tl { top: 8px; left: 8px; }
.r-tr { top: 8px; right: 8px; }
.r-bl { bottom: 8px; left: 8px; }
.r-br { bottom: 8px; right: 8px; }
/* === header / dymo === */
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.dymo {
font-family: 'Special Elite', 'Courier New', serif;
font-size: 14px;
letter-spacing: 0.18em;
color: #2a200f;
background:
repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 2px),
linear-gradient(180deg, #d8c98a 0%, #bba85f 100%);
padding: 8px 18px;
border-radius: 4px;
border: 1px solid #6b5a25;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.4),
inset 0 -2px 0 rgba(0,0,0,0.2),
0 2px 4px rgba(0,0,0,0.45);
text-shadow: none;
}
.header-right {
display: flex; align-items: center; gap: 12px;
}
.serial {
font-size: 10px;
letter-spacing: 0.25em;
opacity: 0.6;
font-family: 'Special Elite', serif;
}
.pwr-led {
width: 14px; height: 14px;
border-radius: 50%;
background: #2a0a0a;
box-shadow:
inset 0 0 0 2px #1a1a1a,
inset 0 0 4px rgba(0,0,0,0.8),
0 0 0 3px #4a3f1f;
}
.pwr-led.on {
background: radial-gradient(circle at 30% 30%, #fff 0%, #6cf06c 40%, #1f8a1f 100%);
box-shadow:
inset 0 0 0 2px #1a1a1a,
inset 0 0 4px rgba(0,0,0,0.4),
0 0 12px rgba(108,240,108,0.7),
0 0 0 3px #4a3f1f;
}
.pwr-led.off {
background: radial-gradient(circle at 30% 30%, #ffd9d9 0%, #c43030 50%, #4a0000 100%);
box-shadow:
inset 0 0 0 2px #1a1a1a,
inset 0 0 4px rgba(0,0,0,0.4),
0 0 8px rgba(196,48,48,0.5),
0 0 0 3px #4a3f1f;
}
/* === alarm strip === */
.alarm-strip {
display: flex; align-items: center; justify-content: center;
gap: 14px;
margin-bottom: 14px;
padding: 8px 14px;
background:
repeating-linear-gradient(45deg, #1a1305 0 12px, #5a2c0c 12px 24px);
border: 2px solid #1a1305;
border-radius: 4px;
animation: alarm-flash 1s linear infinite;
}
.alarm-text {
font-weight: 700;
font-size: 14px;
letter-spacing: 0.25em;
color: #ffeacc;
}
.alarm-lamp {
width: 14px; height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #fff 0%, #ff5a30 50%, #6a1500 100%);
box-shadow: 0 0 12px rgba(255,90,48,0.9);
animation: alarm-flash 0.7s linear infinite alternate;
}
@keyframes alarm-flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* === bezels (inset metal panels) === */
.bezel {
position: relative;
padding: 22px 14px 14px;
border-radius: 6px;
background:
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0 1px, transparent 1px 2px),
linear-gradient(160deg, #2a2418 0%, #1b170e 100%);
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 -1px 0 rgba(255,255,255,0.05),
0 1px 0 rgba(255,255,255,0.06);
border: 1px solid #0a0805;
}
.bezel-label {
position: absolute;
top: -8px; left: 12px;
padding: 2px 8px;
font-family: 'Special Elite', serif;
font-size: 10px;
letter-spacing: 0.22em;
color: #f1e4c5;
background: linear-gradient(180deg, #6b7a55 0%, #4d5a3e 100%);
border: 1px solid #1c2210;
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
text-shadow: 0 1px 0 rgba(0,0,0,0.4);
}
/* === rows === */
.row { display: grid; gap: 14px; margin-bottom: 14px; }
.row-displays {
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.4fr) minmax(0, 1fr);
}
.row-controls {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
}
.col-stack { display: grid; grid-template-rows: auto auto; gap: 14px; }
/* === 7-seg === */
.seg7 {
display: inline-flex;
align-items: flex-end;
gap: 2px;
padding: 4px 6px;
--d7-base-w: 30px;
--d7-base-h: 50px;
}
.d7, .d7-colon, .d7-dot {
width: calc(var(--d7-base-w) * var(--d7-size, 1));
height: calc(var(--d7-base-h) * var(--d7-size, 1));
display: inline-block;
}
.seg {
fill: rgba(0,0,0,0.5);
opacity: 0.10;
transition: opacity 80ms linear;
}
.seg.on { opacity: 1; }
.col-red .seg.on {
fill: #ff2a18;
filter: drop-shadow(0 0 2px #ff5538) drop-shadow(0 0 4px rgba(255,40,20,0.6));
}
.col-amber .seg.on {
fill: #ffaa1a;
filter: drop-shadow(0 0 2px #ffc04a) drop-shadow(0 0 4px rgba(255,170,30,0.6));
}
.col-green .seg.on {
fill: #4cff60;
filter: drop-shadow(0 0 2px #80ff90) drop-shadow(0 0 4px rgba(76,255,96,0.5));
}
.col-red .dot { fill: #ff2a18; filter: drop-shadow(0 0 2px #ff5538); }
.col-amber .dot { fill: #ffaa1a; filter: drop-shadow(0 0 2px #ffc04a); }
.col-green .dot { fill: #4cff60; filter: drop-shadow(0 0 2px #80ff90); }
/* === display panel === */
.display-bezel {
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 3px),
linear-gradient(160deg, #100a04 0%, #1c130a 100%);
}
.display-grid {
display: grid;
gap: 6px;
}
.seg-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background:
radial-gradient(ellipse at center, rgba(255,255,255,0.03) 0%, transparent 70%),
#050200;
border-radius: 3px;
box-shadow:
inset 0 2px 6px rgba(0,0,0,0.9),
inset 0 0 0 1px #2a1f0a;
}
.seg-cap {
font-family: 'Special Elite', serif;
font-size: 10px;
letter-spacing: 0.2em;
color: #c5a960;
min-width: 36px;
}
.seg-unit {
font-family: 'Special Elite', serif;
font-size: 11px;
letter-spacing: 0.15em;
color: #c5a960;
margin-left: auto;
}
/* === lamps === */
.lamp-bezel {
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.02) 0 2px, transparent 2px 4px),
linear-gradient(160deg, #2c2618 0%, #1b170e 100%);
}
.lamps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 6px;
}
.jewel {
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.jewel-bezel {
width: 38px; height: 38px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, #fff 0%, #c0b89a 25%, #5a4f30 70%, #1a1408 100%);
padding: 4px;
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.4),
inset 0 -2px 4px rgba(0,0,0,0.6),
0 2px 4px rgba(0,0,0,0.6);
}
.jewel-lens {
width: 100%; height: 100%;
border-radius: 50%;
position: relative;
}
.jewel-lens::after {
content: "";
position: absolute; top: 4px; left: 6px;
width: 8px; height: 6px;
border-radius: 50%;
background: rgba(255,255,255,0.45);
filter: blur(1px);
}
.lens-green.off { background: radial-gradient(circle at 35% 35%, #2d4a30 0%, #0c1a0e 70%); }
.lens-green.on { background: radial-gradient(circle at 35% 35%, #c4ffc8 0%, #4cff60 35%, #1a8a28 100%); box-shadow: 0 0 16px rgba(76,255,96,0.7); }
.lens-amber.off { background: radial-gradient(circle at 35% 35%, #4a3a14 0%, #1a1408 70%); }
.lens-amber.on { background: radial-gradient(circle at 35% 35%, #fff5c4 0%, #ffc14a 35%, #b07b10 100%); box-shadow: 0 0 16px rgba(255,193,74,0.7); }
.lens-red.off { background: radial-gradient(circle at 35% 35%, #4a1414 0%, #1a0808 70%); }
.lens-red.on { background: radial-gradient(circle at 35% 35%, #ffd0d0 0%, #ff4030 35%, #8a0a0a 100%); box-shadow: 0 0 16px rgba(255,64,48,0.75); }
.lens-white.off { background: radial-gradient(circle at 35% 35%, #3a3528 0%, #14110a 70%); }
.lens-white.on { background: radial-gradient(circle at 35% 35%, #fff 0%, #fce8b4 50%, #b08020 100%); box-shadow: 0 0 16px rgba(255,232,180,0.7); }
.blink { animation: lamp-blink 0.9s linear infinite; }
@keyframes lamp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.jewel-label {
font-family: 'Special Elite', serif;
font-size: 9px;
letter-spacing: 0.18em;
color: #d8c98a;
text-align: center;
}
/* === gauge === */
.gauge-bezel {
background:
radial-gradient(ellipse at 50% 0%, rgba(255,255,255,0.04), transparent 60%),
linear-gradient(160deg, #1f1a0e 0%, #100c06 100%);
cursor: pointer;
outline: none;
}
.gauge-bezel:focus-visible { box-shadow: inset 0 2px 4px rgba(0,0,0,0.7), 0 0 0 2px #c5a960; }
.gauge { width: 100%; height: auto; display: block; }
/* === moon scope === */
.moon-bezel {
display: flex; flex-direction: column; align-items: center; gap: 8px;
cursor: pointer;
}
.scope {
width: 96px; height: 96px;
border-radius: 50%;
padding: 6px;
background:
radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 30%, #2a2515 80%, #000 100%);
box-shadow:
inset 0 2px 4px rgba(255,255,255,0.4),
inset 0 -3px 6px rgba(0,0,0,0.7),
0 3px 6px rgba(0,0,0,0.6);
}
.scope-glass {
width: 100%; height: 100%;
border-radius: 50%;
position: relative;
overflow: hidden;
background: #050810;
box-shadow: inset 0 0 0 2px #1a1408;
}
.moon-img {
width: 100%; height: 100%;
display: block;
border-radius: 50%;
}
.moon-img.placeholder {
background: radial-gradient(circle at 30% 30%, #aaa 0%, #444 60%, #111 100%);
}
.scope-cross {
position: absolute; inset: 0;
background:
linear-gradient(90deg, transparent 49.5%, rgba(255,200,80,0.18) 49.5% 50.5%, transparent 50.5%),
linear-gradient(0deg, transparent 49.5%, rgba(255,200,80,0.18) 49.5% 50.5%, transparent 50.5%);
pointer-events: none;
}
.scope-readout {
display: flex; align-items: center; gap: 4px;
}
/* === twilight strip === */
.twilight-bezel {
background:
linear-gradient(160deg, #1f1a0e 0%, #100c06 100%);
}
.strip {
position: relative;
height: 38px;
border-radius: 3px;
overflow: hidden;
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 0 0 1px #2a1f0a;
}
.strip-paper {
position: absolute; inset: 0;
opacity: 0.92;
}
.strip-grid {
position: absolute; inset: 0;
background:
repeating-linear-gradient(90deg, transparent 0 calc(100%/24 - 1px), rgba(0,0,0,0.35) calc(100%/24 - 1px) calc(100%/24)),
repeating-linear-gradient(0deg, transparent 0 8px, rgba(255,255,255,0.04) 8px 9px);
pointer-events: none;
}
.strip-mark {
position: absolute;
top: -2px; bottom: -2px;
width: 3px;
margin-left: -1px;
z-index: 2;
box-shadow: 0 0 6px currentColor;
}
.strip-mark.open { background: #4cff60; color: rgba(76,255,96,0.8); }
.strip-mark.close { background: #ffaa1a; color: rgba(255,170,26,0.8); }
.strip-now {
position: absolute;
top: -3px; bottom: -3px;
width: 4px;
margin-left: -2px;
background: #ff2a18;
box-shadow: 0 0 8px rgba(255,42,24,0.85);
z-index: 3;
}
.strip-ticks {
position: absolute; inset: 0;
pointer-events: none;
}
.strip-ticks span {
position: absolute;
top: 50%; transform: translate(-50%, -50%);
font-family: 'Special Elite', serif;
font-size: 10px;
color: rgba(255,255,255,0.85);
text-shadow: 0 0 3px rgba(0,0,0,0.8);
}
/* === toggle switch (bat-handle) === */
.toggle-bezel {
display: flex; flex-direction: column; align-items: center;
}
.toggle-area {
position: relative;
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding-top: 6px;
}
.toggle-frame {
position: relative;
width: 70px; height: 130px;
display: flex; align-items: center; justify-content: center;
}
.toggle-base {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 46px; height: 46px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 35%, #1a1408 100%);
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.5),
inset 0 -3px 6px rgba(0,0,0,0.6),
0 3px 6px rgba(0,0,0,0.6);
}
.toggle-base::after {
content: "";
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 16px; height: 16px;
border-radius: 50%;
background: radial-gradient(circle, #1a1408 0%, #000 100%);
box-shadow: inset 0 2px 3px rgba(0,0,0,0.9);
}
.toggle-handle {
position: absolute;
top: 50%; left: 50%;
width: 14px; height: 70px;
margin-left: -7px;
margin-top: -35px;
transform-origin: 50% 50%;
transition: transform 220ms cubic-bezier(0.5, 1.6, 0.4, 1);
}
.toggle.up .toggle-handle { transform: translateY(-22px) rotate(0deg); }
.toggle.down .toggle-handle { transform: translateY(22px) rotate(180deg); }
.toggle-handle::before {
content: "";
position: absolute;
left: 50%; top: 30px;
width: 10px; height: 40px;
margin-left: -5px;
background: linear-gradient(90deg, #4a4a4a 0%, #c8c8c8 35%, #f4f4f4 50%, #c8c8c8 65%, #4a4a4a 100%);
border-radius: 2px;
box-shadow: 0 0 4px rgba(0,0,0,0.5);
}
.toggle-tip {
position: absolute;
left: 50%; top: 0;
width: 22px; height: 30px;
margin-left: -11px;
background:
radial-gradient(ellipse at 35% 30%, #fff 0%, #d8d8d8 30%, #6a6a6a 70%, #2a2a2a 100%);
border-radius: 50% 50% 45% 45%;
box-shadow:
inset 0 -3px 6px rgba(0,0,0,0.5),
0 2px 4px rgba(0,0,0,0.6);
}
.toggle-marks {
position: absolute; inset: 0;
pointer-events: none;
}
.toggle-marks .mk {
position: absolute;
left: 50%; transform: translateX(-50%);
font-family: 'Special Elite', serif;
font-size: 11px;
letter-spacing: 0.2em;
color: #d8c98a;
}
.toggle-marks .mk.top { top: 4px; color: #4cff60; }
.toggle-marks .mk.bot { bottom: 4px; color: #ff6040; }
.toggle { cursor: pointer; outline: none; }
.toggle:focus-visible .toggle-base { box-shadow: 0 0 0 3px #c5a960, inset 0 1px 2px rgba(255,255,255,0.5), inset 0 -3px 6px rgba(0,0,0,0.6); }
/* guard bracket */
.guard {
position: absolute;
top: -2px; left: 50%; transform: translateX(-50%);
width: 60px; height: 110px;
border: 3px solid #c43030;
border-bottom: none;
border-radius: 30px 30px 0 0;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.15),
0 2px 4px rgba(0,0,0,0.5);
background: transparent;
pointer-events: none;
transition: transform 280ms ease;
z-index: 2;
}
.guard.lifted {
transform: translateX(-50%) translateY(-12px) rotateX(-65deg);
transform-origin: 50% 100%;
}
.power-stat {
text-align: center;
}
/* === push buttons === */
.buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
align-items: center;
justify-items: center;
padding: 8px 0;
}
.pbtn {
position: relative;
width: 90px; height: 90px;
border-radius: 50%;
border: none;
cursor: pointer;
font-family: 'Special Elite', serif;
font-size: 11px;
letter-spacing: 0.2em;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.7);
background:
radial-gradient(circle at 30% 30%, var(--btn-hi, #fff) 0%, var(--btn-mid, #888) 35%, var(--btn-lo, #222) 100%);
box-shadow:
inset 0 2px 4px rgba(255,255,255,0.4),
inset 0 -4px 8px rgba(0,0,0,0.6),
0 0 0 6px #1c1610,
0 0 0 7px #6b5a25,
0 5px 10px rgba(0,0,0,0.6);
transition: transform 80ms ease, box-shadow 80ms ease;
}
.pbtn:active:not(:disabled) {
transform: translateY(2px);
box-shadow:
inset 0 4px 8px rgba(0,0,0,0.6),
inset 0 -1px 2px rgba(255,255,255,0.2),
0 0 0 6px #1c1610,
0 0 0 7px #6b5a25,
0 2px 4px rgba(0,0,0,0.6);
}
.pbtn:disabled {
cursor: not-allowed;
filter: grayscale(0.7) brightness(0.55);
}
.pbtn-cap {
display: block;
margin-top: 4px;
font-weight: 700;
}
.pbtn-green {
--btn-hi: #d8ffd8; --btn-mid: #4cd060; --btn-lo: #0a4a18;
box-shadow:
inset 0 2px 4px rgba(255,255,255,0.5),
inset 0 -4px 8px rgba(0,0,0,0.5),
0 0 0 6px #1c1610,
0 0 0 7px #6b5a25,
0 0 18px rgba(76,208,96,0.45),
0 5px 10px rgba(0,0,0,0.6);
}
.pbtn-amber {
--btn-hi: #fff0c8; --btn-mid: #e69a2a; --btn-lo: #5a3208;
box-shadow:
inset 0 2px 4px rgba(255,255,255,0.5),
inset 0 -4px 8px rgba(0,0,0,0.5),
0 0 0 6px #1c1610,
0 0 0 7px #6b5a25,
0 0 18px rgba(230,154,42,0.45),
0 5px 10px rgba(0,0,0,0.6);
}
.pbtn-mushroom {
width: 110px; height: 110px;
--btn-hi: #ffdada; --btn-mid: #d62020; --btn-lo: #4a0606;
box-shadow:
inset 0 3px 6px rgba(255,255,255,0.5),
inset 0 -6px 12px rgba(0,0,0,0.6),
0 0 0 8px #1c1610,
0 0 0 9px #c43030,
0 0 22px rgba(214,32,32,0.55),
0 6px 12px rgba(0,0,0,0.7);
}
.pbtn-mushroom .pbtn-cap {
font-size: 13px;
letter-spacing: 0.3em;
}
.pbtn-mushroom:disabled {
filter: grayscale(0.5) brightness(0.6);
}
/* === schedule programmer === */
.sched-bezel {
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 3px),
linear-gradient(160deg, #1f1a0e 0%, #100c06 100%);
}
.sched-grid {
display: grid; gap: 10px;
}
.sched-row {
display: grid;
grid-template-columns: 90px minmax(0, 1fr) minmax(150px, 200px) 70px;
gap: 10px;
align-items: center;
}
.sched-cap {
font-family: 'Special Elite', serif;
font-size: 11px;
letter-spacing: 0.18em;
color: #c5a960;
}
.sched-readout {
padding: 4px 8px;
background: #050200;
border-radius: 3px;
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.9),
inset 0 0 0 1px #2a1f0a;
min-height: 28px;
display: flex; align-items: center;
}
input[type="datetime-local"] {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border-radius: 3px;
border: 1px solid #2a1f0a;
background:
repeating-linear-gradient(0deg, transparent 0 4px, rgba(255,255,255,0.03) 4px 5px),
#1a1408;
color: #f1e4c5;
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
letter-spacing: 0.05em;
box-shadow: inset 0 2px 3px rgba(0,0,0,0.6);
}
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.85) sepia(1) saturate(2) hue-rotate(-10deg);
cursor: pointer;
}
.kbtn {
font-family: 'Special Elite', serif;
font-size: 11px;
letter-spacing: 0.18em;
padding: 9px 12px;
border: 1px solid #1a1408;
border-radius: 3px;
background:
linear-gradient(180deg, #d8c98a 0%, #a89559 50%, #76632a 100%);
color: #1a1408;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.5),
inset 0 -2px 0 rgba(0,0,0,0.25),
0 2px 3px rgba(0,0,0,0.5);
text-shadow: 0 1px 0 rgba(255,255,255,0.3);
transition: transform 80ms ease, box-shadow 80ms ease;
}
.kbtn:active {
transform: translateY(1px);
box-shadow:
inset 0 2px 3px rgba(0,0,0,0.4),
inset 0 -1px 0 rgba(255,255,255,0.15),
0 1px 1px rgba(0,0,0,0.5);
}
.kbtn-secondary {
background: linear-gradient(180deg, #6b7a55 0%, #4d5a3e 50%, #2c361f 100%);
color: #f1e4c5;
text-shadow: 0 1px 0 rgba(0,0,0,0.4);
}
.kbtn-info {
background: linear-gradient(180deg, #5a8ac4 0%, #3d6aaf 50%, #234783 100%);
color: #f1e4c5;
text-shadow: 0 1px 0 rgba(0,0,0,0.4);
}
.sched-actions {
display: flex; gap: 10px; flex-wrap: wrap;
margin-top: 4px;
}
/* === footer === */
.footer {
margin-top: 16px;
padding-top: 10px;
display: flex;
justify-content: space-between;
gap: 12px;
font-family: 'Special Elite', serif;
font-size: 10px;
letter-spacing: 0.2em;
color: rgba(241,228,197,0.55);
border-top: 1px dashed rgba(241,228,197,0.2);
flex-wrap: wrap;
}
/* === responsive === */
@media (max-width: 880px) {
.row-displays { grid-template-columns: 1fr; }
.row-controls { grid-template-columns: 1fr; }
.lamps { grid-template-columns: repeat(3, 1fr); }
.sched-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.console { padding: 16px; }
.dymo { font-size: 11px; padding: 6px 12px; letter-spacing: 0.12em; }
.lamps { grid-template-columns: repeat(2, 1fr); }
.pbtn { width: 72px; height: 72px; font-size: 9px; }
.pbtn-mushroom { width: 90px; height: 90px; }
.buttons { gap: 8px; }
}
`;
}
}
if (!customElements.get("sternwarte-card-retro")) {
customElements.define("sternwarte-card-retro", SternwarteCardRetro);
}
window.customCards = window.customCards || [];
if (!window.customCards.some((c) => c?.type === "sternwarte-card-retro")) {
window.customCards.push({
type: "sternwarte-card-retro",
name: "Sternwarte Card · Retro",
description: "Vintage Maschinen-Bedienpaneel mit 7-Segment-Anzeigen, Kippschaltern und beleuchteten Druckknöpfen",
});
}