From b93cdba8527a65ae5d9eba934460406850961fd7 Mon Sep 17 00:00:00 2001
From: jayjojayson <2683358+jayjojayson@users.noreply.github.com>
Date: Sat, 7 Feb 2026 12:12:01 +0100
Subject: [PATCH] v_2.0
---
.github/FUNDING.yml | 2 +
.github/workflows/build_card.yml | 46 +
.github/workflows/release.yml | 26 +
.github/workflows/static.yml | 43 +
.github/workflows/validate.yml | 22 +
build.js | 67 ++
docs/README-de.md | 290 ++++++
hacs.json | 6 +
src/power-flux-card.js | 1652 ++++++++++++++++++++++++++++++
9 files changed, 2154 insertions(+)
create mode 100644 .github/FUNDING.yml
create mode 100644 .github/workflows/build_card.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .github/workflows/static.yml
create mode 100644 .github/workflows/validate.yml
create mode 100644 build.js
create mode 100644 docs/README-de.md
create mode 100644 hacs.json
create mode 100644 src/power-flux-card.js
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..24336fc
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+---
+custom: ["https://www.paypal.me/quadFlyerFW"]
diff --git a/.github/workflows/build_card.yml b/.github/workflows/build_card.yml
new file mode 100644
index 0000000..d1609e7
--- /dev/null
+++ b/.github/workflows/build_card.yml
@@ -0,0 +1,46 @@
+name: Build Sun Position Card
+
+# Wann soll der Workflow starten?
+on:
+ # 1. Automatisch bei Änderungen im Main-Branch
+ push:
+ branches:
+ - main
+ paths:
+ - 'src/**' # Nur wenn sich Quellcode ändert
+ - 'build.js' # Oder das Build-Skript
+
+ # 2. Manuell per Button ausführbar
+ workflow_dispatch:
+
+# Berechtigungen setzen, damit der Bot die Datei speichern darf
+permissions:
+ contents: write
+
+jobs:
+ build-and-commit:
+ runs-on: ubuntu-latest
+
+ steps:
+ # 1. Den Code aus dem Repo holen
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # 2. Node.js installieren (Version 20)
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ # 3. Das Build-Skript ausführen
+ - name: Run Build Script
+ run: node build.js
+
+ # 4. Prüfen, ob sich die Datei geändert hat und committen
+ # Wenn keine Änderung erkannt wird, macht dieser Schritt einfach nichts (kein Fehler)
+ - name: Commit and Push changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "power-flux-card Auto-build"
+ file_pattern: dist/power-flux-card.js
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..f4932a1
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,26 @@
+name: Upload Release Asset
+
+# Startet nur, wenn ein neues Release veröffentlicht wird
+on:
+ release:
+ types: [published]
+
+# Schreibrechte sind notwendig für den Upload
+permissions:
+ contents: write
+
+jobs:
+ upload-asset:
+ runs-on: ubuntu-latest
+
+ steps:
+ # 1. Code auschecken (damit die Datei dist/sun-position-card.js verfügbar ist)
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # 2. Die existierende Datei aus dem dist-Ordner an das Release anhängen
+ - name: Upload Release Asset
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/power-flux-card.js
+
\ No newline at end of file
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 0000000..ea1a7f5
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,43 @@
+# Simple workflow for deploying static content to GitHub Pages
+name: Deploy static content to Pages
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Single deploy job since we're just deploying
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ # Upload entire repository
+ path: '.' # docs erstmal herausgenommen
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..7d65ee9
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,22 @@
+name: Validate with HACS
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ workflow_dispatch:
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # 1. HACS-Validierung
+ # Prüft hacs.json und Struktur
+ - name: Run HACS validation
+ uses: hacs/action@main
+ with:
+ category: plugin
diff --git a/build.js b/build.js
new file mode 100644
index 0000000..923e3df
--- /dev/null
+++ b/build.js
@@ -0,0 +1,67 @@
+const fs = require('fs');
+const path = require('path');
+
+const SRC_DIR = 'src';
+const DIST_DIR = 'dist';
+const OUTPUT_FILE = path.join(DIST_DIR, 'power-flux-card.js');
+
+// Ensure dist dir exists
+if (!fs.existsSync(DIST_DIR)){
+ fs.mkdirSync(DIST_DIR);
+}
+
+// Process Languages
+console.log('Processing languages...');
+const langFiles = fs.readdirSync(SRC_DIR).filter(file => file.startsWith('lang-') && file.endsWith('.js'));
+let langsScript = '';
+
+langFiles.forEach(file => {
+ const langCode = file.replace('lang-', '').replace('.js', '');
+
+ let content = fs.readFileSync(path.join(SRC_DIR, file), 'utf8');
+ // Extract object
+ content = content.replace('export default', '').trim();
+ if (content.endsWith(';')) {
+ content = content.slice(0, -1);
+ }
+
+ const varName = langCode;
+
+ langsScript += `const ${varName} = ${content};\n`;
+});
+
+// Process Editor
+console.log('Processing editor...');
+let editorContent = fs.readFileSync(path.join(SRC_DIR, 'power-flux-card-editor.js'), 'utf8');
+// Remove imports
+editorContent = editorContent.replace(/import .* from .*/g, '');
+
+// Process Main Card
+console.log('Processing main card...');
+let mainContent = fs.readFileSync(path.join(SRC_DIR, 'power-flux-card.js'), 'utf8');
+// Remove imports
+mainContent = mainContent.replace(/import .* from .*/g, '');
+
+// Replace getConfigElement
+mainContent = mainContent.replace(
+ /static async getConfigElement\(\) \{[\s\S]*?return document\.createElement\("power-flux-card-editor"\);\s*\}/,
+ `static async getConfigElement() { return document.createElement("power-flux-card-editor"); }`
+);
+
+// 5. Combine everything
+console.log('Writing output...');
+const finalContent = `
+/**
+ * Power Flux Card (Bundled)
+ * Generated by build.js
+ */
+${langsScript}
+${imagesScript}
+
+${editorContent}
+
+${mainContent}
+`;
+
+fs.writeFileSync(OUTPUT_FILE, finalContent);
+console.log(`Build complete: ${OUTPUT_FILE}`);
diff --git a/docs/README-de.md b/docs/README-de.md
new file mode 100644
index 0000000..0131dd0
--- /dev/null
+++ b/docs/README-de.md
@@ -0,0 +1,290 @@
+[](https://github.com/hacs/plugin)
+[](https://github.com/jayjojayson/Sun-Position-Card/actions?query=workflow%3Avalidate)
+
+[](https://github.com/jayjojayson/Sun-Position-Card/releases/)
+[](https://github.com/jayjojayson/Sun-Position-Card/blob/main/docs/README-de.md)
+[](https://www.paypal.me/quadFlyerFW)
+
+
+
+
+
+[](https://community.home-assistant.io/t/sun-moon-position-card-another-implementation/965201)
+[](https://community-smarthome.com/t/custom-sun-position-card-fuer-home-assistant-sonnenstand-card-hacs/9389)
+
+
+---
+
+Die 🔆 Sun Position Card ist eine benutzerdefinierte Lovelace-Karte zur Visualisierung der aktuellen Sonnenposition mit verschiedenen Optionen, sowie der aktuellen Mondphase und Anzeige weiterer relevanter Sonnenzeiten.
+
+Die Karte ist vollständig über die Benutzeroberfläche des Karteneditors konfigurierbar.
+Du benötigst die sun.sun-Entität, die von Home Assistant bereitgestellt wird, sobald dein Home-Standort konfiguriert ist. Die moon.phase-Entität ist optional und wird nur benötigt, um die aktuelle Mondphase anzuzeigen.
+Um den Mond-Sensor zu erhalten, gehe zu Einstellungen → Geräte & Dienste → Integration hinzufügen und suche nach Mond. Dies ist die integrierte Mond-Integration von Home Assistant.
+
+Wenn euch die custom Card gefällt, würde ich mich sehr über eine Sternebewertung ⭐ freuen. 🤗
+
+## Features
+
+- ### 🔆 **Sonnestand klassische Darstellung**
+- ### 🌅 **Sonnestand berechnete Darstellung**
+- ### 🌄 **Sonnestand berechneter Bogen**
+- ### 🌙 **Mondphasen – visuelle Darstellung**
+- ### 🎞️ **Animierter Sonnenstand**
+- ### ⏰ **Anpassbare Zeiten**
+- ### 🌤️ **Wetter Status** - NEU
+- ### 📐 **Flexibles Layout**
+- ### 📍 **Anpassbare Schwellenwerte**
+- ### ⚙️ **UI-Konfiguration**
+
+
+
+
+
+
+ Animierte Card anschauen
+
+
+
+
+
+---
+
+## Installation
+
+### HACS (Empfohlen)
+
+- Das github über den Link in Home Assistant einfügen.
+
+ [](https://my.home-assistant.io/redirect/hacs_repository/?owner=jayjojayson&repository=Sun-Position-Card&category=plugin)
+
+- Die "Sun Position Card" sollte nun in HACS verfügbar sein. Klicke auf "INSTALLIEREN" ("INSTALL").
+- Die Ressource wird automatisch zu deiner Lovelace-Konfiguration hinzugefügt.
+
+
+ Manuelle Installation über Hacs
+
+### Manuelle Installation über Hacs
+Öffne HACS in Home Assistant.
+
+- Gehe zu "Frontend" und klicke auf die drei Punkte in der oberen rechten Ecke.
+- Wähle "Benutzerdefinierte Repositories" ("Custom repositories") aus.
+- Füge die URL zu Ihrem GitHub-Repository hinzu und wähle "Lovelace" als Kategorie.
+- Klicke auf "HINZUFÜGEN" ("ADD").
+- Die "Sun Position Card" sollte nun in HACS verfügbar sein. Klicke auf "INSTALLIEREN" ("INSTALL").
+- Die Ressource wird automatisch zu deiner Lovelace-Konfiguration hinzugefügt.
+
+
+
+ Manuelle Installation in HA
+
+### Manuelle Installation in HA
+1. **Dateien herunterladen:**
+ * Lade die `sun-position-card.js`, `sun-position-card-editor.js` und die PNG-Bilddateien aus diesem Repository herunter.
+
+2. **Dateien in Home Assistant hochladen:**
+ * Erstelle einen neuen Ordner namens `Sun-Position-Card` im `www`-Verzeichnis deiner Home Assistant-Konfiguration. (Das `www`-Verzeichnis befindet sich im selben Ordner wie deine `configuration.yaml`).
+ * Kopiere **alle heruntergeladenen Dateien** in diesen neuen Ordner. Deine Ordnerstruktur sollte wie folgt aussehen:
+ ```
+ /config/www/Sun-Position-Card/sun-position-card.js
+ /config/www/Sun-Position-Card/sun-position-card-editor.js
+ /config/www/Sun-Position-Card/images/morgen.png
+ /config/www/Sun-Position-Card/images/mittag.png
+ ... (alle anderen Bilder)
+ ```
+
+3. **Ressource zu Home Assistant hinzufügen:**
+ * Gehe in Home Assistant zu **Einstellungen > Dashboards**.
+ * Klicke auf das Menü mit den drei Punkten oben rechts und wähle **Ressourcen**.
+ * Klicke auf **+ Ressource hinzufügen**.
+ * Gebe als URL `/local/Sun-Position-Card/sun-position-card.js` ein.
+ * Wähle als Ressourcentyp **JavaScript-Modul**.
+ * Klicke auf **Erstellen**.
+
+
+---
+
+## Konfiguration
+
+Nach der Installation kannst du die Karte zu deinem Dashboard hinzufügen:
+
+1. **Bearbeitungsmodus aktivieren:**
+ * Öffne das Dashboard, zu dem die Karte hinzufügt werden soll, und klicke auf **Bearbeiten**.
+
+2. **Karte hinzufügen:**
+ * Klicke auf **+ Karte hinzufügen** und suche nach der **"Sun Position Card"**.
+
+3. **Optionen konfigurieren:**
+ * Ein Konfigurationsfenster wird angezeigt, in dem alle Einstellungen bequem über Dropdown-Menüs, Kontrollkästchen und Eingabefelder angepasst werden können.
+ * **Sun Entity:** Die Entität Sonne (normalerweise `sun.sun`).
+ * **Times to Display:** Wähle die Zeiten aus, die du anzeigen möchtest.
+ * **Time Position:** Lege fest, wo die Zeiten angezeigt werden sollen.
+ * **Thresholds (Advanced):** Passe bei Bedarf die Azimut- und Höhenwerte an.
+
+
+
+---
+
+## YAML-Modus (Alternative)
+
+Obwohl die UI-Konfiguration empfohlen wird, kann die Karte auch manuell über den YAML-Editor konfiguriert werden:
+
+### Optionen
+
+| name | typ | required | description | standard |
+| --------------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
+| `type` | `string` | Yes | `custom:sun-position-card` | |
+| `entity` | `string` | Yes | Die Entität Sonne, normalerweise `sun.sun`. | |
+| `moon_entity` | `string` | No | Die Entität Mond, normalerweise `sensor.moon_phase`. | |
+| `moon_phase_position` | `string` | No | Position Text Mondphase im Verhältnis zum Bild. | `above`, `in_list` |
+| `times_to_show` | `list` | No | Eine Liste von Zeiten, die angezeigt werden sollen. | `daylight_duration, next_rising`, `next_setting`, `next_dawn`, `next_dusk`, `next_noon`, `next_midnight` |
+| `time_position` | `string` | No | Position der Zeitangaben im Verhältnis zum Bild. | `above`, `below`, `right` |
+| `time_list_format` | `string` | No | Format der Zeitangaben Blocksatz oder Zentriert | `block`, `centered` |
+| `state_position` | `string` | No | Position des aktuellen Status (über dem Bild, in der Time-Liste). | `above`, `in_list` |
+| `show_degrees` | `boolean` | No | Zeige Gradzahlen für Azimuth und Elevation. | `true`, `false` |
+| `show_degrees_in_list`| `boolean` | No | Zeige Gradzahlen in der Timeliste. | `true`, `false` |
+| `show_dividers` | `boolean` | No | Zeige Trennlinien zwischen den Zeiten. | `true`, `false` |
+| `animate_images` | `boolean` | No | Animiere die Sonnenstandsbilder. | `true`, `false` |
+| `view_mode` | `string` | No | Ansichtsoption klassich mit Bildern oder berechneter Sonnenstand. | `classic`, `calculated`, `arc` |
+| `morning_azimuth` | `number` | No | Azimut-Grenzwert für den Morgen. | `150` |
+| `noon_azimuth` | `number` | No | Azimut-Grenzwert für den Mittag. | `200` |
+| `afternoon_azimuth` | `number` | No | Azimut-Grenzwert für den Nachmittag. | `255` |
+| `dusk_elevation` | `number` | No | Höhen-Grenzwert für die Dämmerung. | `10` |
+
+
+### Beispielkonfiguration
+
+einfaches Beispiel:
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+times_to_show:
+ - next_rising
+ - next_setting
+time_position: right
+show_image: false
+```
+
+erweitertes Beispiel:
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+moon_entity: sensor.moon_phase
+moon_phase_position: above
+state_position: above
+show_dividers: true
+show_degrees: true
+show_degrees_in_list: false
+times_to_show:
+ - next_rising
+ - next_setting
+ - daylight_duration
+ - moon_phase
+time_position: right
+show_image: true
+morning_azimuth: 155
+dusk_elevation: 5
+noon_azimuth: 200
+afternoon_azimuth: 255
+animate_images: false
+time_list_format: block
+view_mode: arc
+```
+
+---
+
+## CSS Elemente die bearbeitet werden können:
+
+| Selector | Description |
+| ----------------------- | --------------------------------------------------------------------------- |
+| `ha-card` | The entire card container. |
+| `.card-content` | The main container wrapping all elements inside the card. |
+| `.sun-image-container` | The container `
` for the sun image. |
+| `.sun-image` | The image `
![]()
` element itself. |
+| `.times-container` | The container for the list of times. |
+| `.time-entry` | An individual row/entry in the times list (e.g., "Aufgang: 06:30"). |
+| `.state` | The current state text (e.g., "Mittag") when positioned above the image. |
+| `.moon-phase-state` | The current state text (e.g., "Full-Moon") when positioned above the image. |
+| `.degrees` | The Azimuth/Elevation text when positioned above the image. |
+| `.degrees-in-list` | The Azimuth/Elevation text when positioned inside the times list. |
+| `.divider` | The horizontal line `
` used as a separator between time entries. |
+
+### Beispiele
+
+Hier sind einige Beispiele, wie du `card-mod` in der YAML-Konfiguration deiner Card verwenden kannst.
+
+#### Schriftgröße und Farbe ändern
+
+Macht den Hauptstatus-Text größer und blau und die Zeiteinträge etwas kleiner und grau.
+
+

+
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+state_position: above # State must be 'above' to see the effect on .state
+card_mod:
+ style: |
+ .state {
+ font-size: 24px;
+ color: dodgerblue;
+ }
+ .time-entry {
+ font-size: 14px;
+ color: #888;
+ }
+```
+
+#### Bild bearbeiten
+
+Fügt dem Bild einen Rahmen hinzu und macht es leicht transparent.
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+card_mod:
+ style: |
+ .sun-image {
+ border: 2px solid var(--primary-color);
+ border-radius: 10px;
+ opacity: 0.9;
+ }
+```
+
+#### Background ändern und Shadows entfernen
+
+Setzt einen hellgelben Hintergrund für die Card und entfernt den standardmäßigen Schatten (Box Shadow).
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+card_mod:
+ style: |
+ ha-card {
+ background: #FFFACD;
+ box-shadow: none;
+ }
+```
+
+#### Trennlinien bearbeiten
+
+Macht die Trennlinie dicker und formatiert sie als gestrichelte rote Linie.
+
+```yaml
+type: custom:sun-position-card
+entity: sun.sun
+show_dividers: true
+card_mod:
+ style: |
+ .divider {
+ border-top: 2px dashed red;
+ }
+```
+
+---
+
+## Forum-Discussion
+
+[](https://community-smarthome.com/t/custom-sun-position-card-fuer-home-assistant-sonnenstand-card-hacs/9389) [](https://community.simon42.com/t/custom-sun-position-card-fuer-home-assistant-sonnenstand-card-hacs/72199)
diff --git a/hacs.json b/hacs.json
new file mode 100644
index 0000000..17ef65d
--- /dev/null
+++ b/hacs.json
@@ -0,0 +1,6 @@
+{
+ "name": "Power Flux Card",
+ "render_readme": true,
+ "content_in_root": false,
+ "filename": "power-flux-card.js"
+}
diff --git a/src/power-flux-card.js b/src/power-flux-card.js
new file mode 100644
index 0000000..41102e2
--- /dev/null
+++ b/src/power-flux-card.js
@@ -0,0 +1,1652 @@
+console.log(
+ "%c⚡ Power-Flux-Card v_2.0 ready",
+ "background: #2ecc71; color: #000; padding: 2px 6px; border-radius: 4px; font-weight: bold;"
+);
+
+const fireEvent = (node, type, detail, options) => {
+ options = options || {};
+ detail = detail === null || detail === undefined ? {} : detail;
+ const event = new Event(type, {
+ bubbles: options.bubbles === undefined ? true : options.bubbles,
+ cancelable: Boolean(options.cancelable),
+ composed: options.composed === undefined ? true : options.composed,
+ });
+ event.detail = detail;
+ node.dispatchEvent(event);
+ return event;
+};
+
+const LitElement = customElements.get("ha-lit-element") || Object.getPrototypeOf(customElements.get("home-assistant-main"));
+const html = LitElement.prototype.html;
+const css = LitElement.prototype.css;
+
+// --- EDITOR TRANSLATIONS ---
+const editorTranslations = {
+ de: {
+ "card.label_import": "Import",
+ "card.label_export": "Export",
+ "editor.main_title": "Haupt Entitäten",
+ "editor.solar_section": "Solar/PV",
+ "editor.grid_section": "Netz Import/Export",
+ "editor.battery_section": "Batterie",
+ "editor.consumers_section": "Zusätzliche Verbraucher",
+ "editor.options_section": "Darstellung & Optionen",
+ "editor.flow_rate_title": "Flussraten (W) an Röhren anzeigen",
+ "editor.label_toggle": "Label im Kreis anzeigen",
+ "editor.compact_view": "Kompakte Ansicht (evcc)",
+ "editor.hide_inactive": "Inaktive Röhren ausblenden",
+ "editor.entity": "Entität (Watt)",
+ "editor.label": "Beschriftung",
+ "editor.icon": "Icon",
+ },
+ en: {
+ "card.label_import": "Import",
+ "card.label_export": "Export",
+ "editor.main_title": "Main Entities",
+ "editor.solar_section": "Solar",
+ "editor.grid_section": "Grid Connection",
+ "editor.battery_section": "Battery",
+ "editor.consumers_section": "Additional Consumers",
+ "editor.options_section": "Appearance & Options",
+ "editor.flow_rate_title": "Show Flow Rates (W) on pipes",
+ "editor.label_toggle": "Show Label in Bubble",
+ "editor.compact_view": "Compact View (evcc)",
+ "editor.hide_inactive": "Hide Inactive Pipes",
+ "editor.entity": "Entity (Watt)",
+ "editor.label": "Label",
+ "editor.icon": "Icon",
+ }
+};
+
+class PowerFluxCardEditor extends LitElement {
+
+ static get properties() {
+ return {
+ hass: {},
+ _config: { state: true },
+ _subView: { state: true } // Controls which sub-page is open (null = main)
+ };
+ }
+
+ setConfig(config) {
+ this._config = config;
+ }
+
+ _localize(key) {
+ const lang = this.hass && this.hass.language ? this.hass.language : 'en';
+ const dict = editorTranslations[lang] || editorTranslations['en'];
+ return dict[key] || editorTranslations['en'][key] || key;
+ }
+
+ _valueChanged(ev) {
+ if (!this._config || !this.hass) return;
+
+ const target = ev.target;
+ const key = target.configValue || this._currentConfigValue;
+
+ let value;
+ if (target.tagName === 'HA-SWITCH') {
+ value = target.checked;
+ } else if (ev.detail && 'value' in ev.detail) {
+ value = ev.detail.value;
+ } else {
+ value = target.value;
+ }
+
+ if (value === null || value === undefined) {
+ value = "";
+ }
+
+ if (key) {
+ const entityKeys = [
+ 'solar', 'grid', 'grid_export',
+ 'battery', 'battery_soc',
+ 'consumer_1', 'consumer_2', 'consumer_3'
+ ];
+
+ let newConfig = { ...this._config };
+
+ if (entityKeys.includes(key)) {
+ const currentEntities = newConfig.entities || {};
+ const newEntities = { ...currentEntities, [key]: value };
+ newConfig.entities = newEntities;
+ } else {
+ newConfig[key] = value;
+
+ if (key === 'show_comet_tail' && value === true) {
+ newConfig.show_dashed_line = false;
+ }
+ if (key === 'show_dashed_line' && value === true) {
+ newConfig.show_comet_tail = false;
+ }
+ }
+
+ this._config = newConfig;
+ fireEvent(this, "config-changed", { config: this._config });
+ }
+ }
+
+ _goSubView(view) {
+ this._subView = view;
+ }
+
+ _goBack() {
+ this._subView = null;
+ }
+
+ static get styles() {
+ return css`
+ .card-config {
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 24px;
+ }
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ }
+ .back-btn {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: bold;
+ color: var(--primary-color);
+ }
+ .menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--divider-color);
+ margin-bottom: 13px;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
+ .menu-item:hover {
+ background: rgba(var(--rgb-primary-text-color), 0.05);
+ }
+ .menu-icon {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-weight: bold;
+ }
+ .switch-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 0;
+ margin-top: 8px;
+ }
+ .switch-label {
+ font-weight: bold;
+ }
+ .section-title {
+ font-size: 1.1em;
+ font-weight: bold;
+ margin-top: 15px;
+ margin-bottom: 15px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--divider-color);
+ }
+ ha-selector {
+ width: 100%;
+ display: block;
+ margin-bottom: 12px;
+ }
+ .consumer-group {
+ padding: 10px;
+ border-radius: 8px;
+ border-bottom: 1px solid var(--divider-color);
+ margin-bottom: 12px;
+ }
+ .consumer-title {
+ font-weight: bold;
+ margin-bottom: 8px;
+ color: var(--primary-text-color);
+ }
+ .separator {
+ border-bottom: 1px solid var(--divider-color);
+ margin: 10px 0;
+ }
+ `;
+ }
+
+ // --- SUBVIEW RENDERING ---
+
+ _renderSolarView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this._localize('editor.label_toggle')}
+
+
+
+
+
${this._localize('editor.flow_rate_title')}
+
+ `;
+ }
+
+ _renderGridView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this._localize('editor.label_toggle')}
+
+
+
+
+
${this._localize('editor.flow_rate_title')}
+
+ `;
+ }
+
+ _renderBatteryView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this._localize('editor.label_toggle')}
+
+
+
+
+
${this._localize('editor.flow_rate_title')}
+
+ `;
+ }
+
+ _renderConsumersView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
+ return html`
+
+
+
+
🚗 Links (Lila)
+
+
+
+
+
+
+
+
+
♨️ Mitte (Orange)
+
+
+
+
+
+
+
+
+
🏊 Rechts (Türkis)
+
+
+
+
+
+
+ `;
+ }
+
+ render() {
+ if (!this.hass || !this._config) {
+ return html``;
+ }
+
+ const entities = this._config.entities || {};
+
+ const entitySelectorSchema = { entity: { domain: ["sensor", "input_number"] } };
+ const textSelectorSchema = { text: {} };
+ const iconSelectorSchema = { icon: {} };
+
+ // SUBVIEW ROUTING
+ if (this._subView === 'solar') return this._renderSolarView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
+ if (this._subView === 'grid') return this._renderGridView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
+ if (this._subView === 'battery') return this._renderBatteryView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
+ if (this._subView === 'consumers') return this._renderConsumersView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
+
+
+ // MAIN MENU VIEW
+ return html`
+
+
+
${this._localize('editor.main_title')}
+
+
+
+
+
+
+
+
+
+
${this._localize('editor.options_section')}
+
+
+
+
+
+
+
+
+
+
Donut Chart (Grid/Haus)
+
+
+
+
+
+
+
Dashed Line Animation
+
+
+
+
+
+
+
Icons unten ausblenden
+
+
+
+
+
${this._localize('editor.hide_inactive')}
+
+
+
+
+
${this._localize('editor.compact_view')}
+
+
+
+ `;
+ }
+}
+
+customElements.define("power-flux-card-editor", PowerFluxCardEditor);
+
+// --- CARD TRANSLATIONS ---
+const cardTranslations = {
+ de: {
+ "card.label_solar": "Solar",
+ "card.label_grid": "Netz",
+ "card.label_battery": "Batterie",
+ "card.label_house": "Verbrauch",
+ "card.label_car": "E-Auto",
+ "card.label_import": "Import",
+ "card.label_export": "Export",
+ },
+ en: {
+ "card.label_solar": "Solar",
+ "card.label_grid": "Grid",
+ "card.label_battery": "Battery",
+ "card.label_house": "Consumption",
+ "card.label_car": "Car",
+ "card.label_import": "Import",
+ "card.label_export": "Export",
+ }
+};
+
+class PowerFluxCard extends LitElement {
+
+ static get properties() {
+ return {
+ hass: {},
+ config: {},
+ _cardWidth: { state: true },
+ };
+ }
+
+ _localize(key) {
+ const lang = this.hass && this.hass.language ? this.hass.language : 'en';
+ const dict = cardTranslations[lang] || cardTranslations['en'];
+ return dict[key] || cardTranslations['en'][key] || key;
+ }
+
+ static async getConfigElement() {
+ return document.createElement("power-flux-card-editor");
+ }
+
+ static getStubConfig() {
+ return {
+ zoom: 0.9,
+ compact_view: false,
+ show_donut_border: false,
+ show_neon_glow: true,
+ show_comet_tail: false,
+ show_dashed_line: false,
+ show_tinted_background: false,
+ hide_inactive_flows: true,
+ show_flow_rate_solar: true,
+ show_flow_rate_grid: true,
+ show_flow_rate_battery: true,
+ show_label_solar: false,
+ show_label_grid: false,
+ show_label_battery: false,
+ show_label_house: false,
+ use_colored_values: false,
+ hide_consumer_icons: false,
+ entities: {
+ solar: "",
+ grid: "",
+ grid_export: "",
+ battery: "",
+ battery_soc: "",
+ consumer_1: "",
+ consumer_2: "",
+ consumer_3: ""
+ }
+ };
+ }
+
+ setConfig(config) {
+ if (!config.entities) {
+ // Init allow
+ }
+ this.config = config;
+ }
+
+ firstUpdated() {
+ this._resizeObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ if (entry.contentRect.width > 0) {
+ this._cardWidth = entry.contentRect.width;
+ }
+ }
+ });
+ this._resizeObserver.observe(this);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this._resizeObserver) {
+ this._resizeObserver.disconnect();
+ }
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ --neon-yellow: #ffdd00;
+ --neon-blue: #3b82f6;
+ --neon-green: #00ff88;
+ --neon-pink: #ff0080;
+ --neon-red: #ff3333;
+ --grid-grey: #9e9e9e;
+ --export-purple: #a855f7;
+ --flow-dasharray: 0 380;
+ }
+ ha-card {
+ padding: 0;
+ position: relative;
+ overflow: hidden;
+ transition: height 0.3s ease;
+ }
+
+ /* --- COMPACT VIEW STYLES --- */
+ .compact-container {
+ padding: 16px 20px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 120px;
+ box-sizing: border-box;
+ }
+
+ .compact-bracket {
+ height: 24px;
+ width: 100%;
+ position: relative;
+ }
+ .bracket-svg {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: visible; /* Important for icons */
+ }
+ .bracket-line {
+ fill: none;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ transition: d 0.5s ease;
+ }
+ .compact-icon-wrapper {
+ position: absolute;
+ top: -6px; /* Default top, overridden inline */
+ padding: 0 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: left 0.5s ease;
+ }
+ .compact-icon {
+ --mdc-icon-size: 20px;
+ }
+
+ .compact-bar-wrapper {
+ height: 36px;
+ width: 100%;
+ background: #333;
+ border-radius: 5px;
+ margin: 4px 0;
+ display: flex;
+ overflow: hidden;
+ position: relative;
+ }
+
+ .bar-segment {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: bold;
+ color: black;
+ transition: width 0.5s ease;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ /* Source Colors */
+ .src-solar { background: var(--neon-green); color: black; }
+ .src-grid { background: var(--grid-grey); color: black; }
+ .src-battery { background: var(--neon-yellow); color: black; }
+
+ /* --- STANDARD VIEW STYLES --- */
+ .scale-wrapper {
+ width: 420px;
+ transform-origin: top center;
+ transition: transform 0.1s linear;
+ margin: 0 auto;
+ }
+
+ .absolute-container {
+ position: relative;
+ width: 100%;
+ transition: top 0.3s ease;
+ }
+
+ .bubble {
+ width: 90px;
+ height: 90px;
+ border-radius: 50%;
+ background: transparent;
+ border: 2px solid var(--divider-color, #333);
+ display: block;
+ position: absolute;
+ z-index: 2;
+ transition: all 0.3s ease;
+ box-sizing: border-box;
+ }
+
+ .bubble.tinted { background: rgba(255, 255, 255, 0.05); }
+ .bubble.tinted.solar { background: color-mix(in srgb, var(--neon-yellow), transparent 85%); }
+ .bubble.tinted.grid { background: color-mix(in srgb, var(--neon-blue), transparent 85%); }
+ .bubble.tinted.battery { background: color-mix(in srgb, var(--neon-green), transparent 85%); }
+ .bubble.tinted.c1 { background: color-mix(in srgb, #a855f7, transparent 85%); }
+ .bubble.tinted.c2 { background: color-mix(in srgb, #f97316, transparent 85%); }
+ .bubble.tinted.c3 { background: color-mix(in srgb, #06b6d4, transparent 85%); }
+
+ .bubble.house { border-color: var(--neon-pink); }
+ .bubble.house.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); }
+ .bubble.house.donut { border: none !important; --house-gradient: var(--neon-pink); background: transparent; }
+ .bubble.house.donut.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); }
+ .bubble.house.donut::before {
+ content: ""; position: absolute; inset: 0; border-radius: 50%; padding: 4px;
+ background: var(--house-gradient);
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor; mask-composite: exclude; z-index: -1; pointer-events: none;
+ }
+
+ .icon-svg, .icon-custom {
+ width: 34px; height: 34px; position: absolute; top: 13px; left: 50%; margin-left: -17px; z-index: 2; display: block;
+ }
+ .icon-custom { --mdc-icon-size: 34px; }
+
+ .sub {
+ font-size: 9px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px;
+ line-height: 1.1; z-index: 2; position: absolute; top: 48px; left: 0; width: 100%; text-align: center; margin: 0; pointer-events: none;
+ }
+
+ .value {
+ font-weight: bold; font-size: 15px; white-space: nowrap; z-index: 2; transition: color 0.3s ease;
+ line-height: 1.2; position: absolute; bottom: 11px; left: 0; width: 100%; text-align: center; margin: 0;
+ }
+
+ @keyframes spin { 100% { transform: rotate(360deg); } }
+ .spin-slow { animation: spin 12s linear infinite; transform-origin: center; }
+
+ @keyframes pulse-opacity { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
+ .pulse { animation: pulse-opacity 2s ease-in-out infinite; }
+
+ @keyframes float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
+ .float { animation: float-y 3s ease-in-out infinite; }
+
+ .solar { border-color: var(--neon-yellow); }
+ .battery { border-color: var(--neon-green); }
+ .grid { border-color: var(--neon-blue); }
+ .c1 { border-color: #a855f7; }
+ .c2 { border-color: #f97316; }
+ .c3 { border-color: #06b6d4; }
+ .inactive { border-color: var(--secondary-text-color); }
+
+ .glow.solar { box-shadow: 0 0 15px rgba(255, 221, 0, 0.4); }
+ .glow.battery { box-shadow: 0 0 15px rgba(0, 255, 136, 0.4); }
+ .glow.grid { box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); }
+ .glow.c1 { box-shadow: 0 0 15px rgba(168, 85, 247, 0.4); }
+ .glow.c2 { box-shadow: 0 0 15px rgba(249, 115, 22, 0.4); }
+ .glow.c3 { box-shadow: 0 0 15px rgba(6, 182, 212, 0.4); }
+
+ .node-solar { top: 70px; left: 5px; }
+ .node-grid { top: 70px; left: 165px; }
+ .node-battery { top: 70px; left: 325px; }
+ .node-house { top: 220px; left: 165px; }
+ .node-c1 { top: 370px; left: 5px; }
+ .node-c2 { top: 370px; left: 165px; }
+ .node-c3 { top: 370px; left: 325px; }
+
+ svg { position: absolute; top: 7; left: 25; width: 100%; height: 100%; z-index: 1; pointer-events: none; }
+
+ .bg-path { fill: none; stroke-width: 6; transition: opacity 0.3s ease; }
+ .bg-solar { stroke: var(--neon-yellow); }
+ .bg-grid { stroke: var(--neon-blue); }
+ .bg-battery { stroke: var(--neon-green); }
+ .bg-export { stroke: var(--neon-red); }
+ .bg-c1 { stroke: #a855f7; }
+ .bg-c2 { stroke: #f97316; }
+ .bg-c3 { stroke: #06b6d4; }
+
+ .flow-line {
+ fill: none; stroke-width: var(--flow-stroke-width, 8px); stroke-linecap: round; stroke-dasharray: var(--flow-dasharray);
+ animation: dash linear infinite; opacity: 0; transition: opacity 0.5s;
+ }
+ .flow-solar { stroke: var(--neon-yellow); }
+ .flow-grid { stroke: var(--neon-blue); }
+ .flow-battery { stroke: var(--neon-green); }
+ .flow-export { stroke: var(--neon-red); }
+
+ @keyframes dash { to { stroke-dashoffset: -1500; } }
+
+ .flow-text {
+ font-size: 10px; font-weight: bold; text-anchor: middle; fill: #fff; filter: drop-shadow(0px 1px 2px rgba(0,0,0,0.8)); transition: opacity 0.3s ease;
+ }
+ .flow-text.no-shadow { filter: none; }
+ .text-solar { fill: var(--neon-yellow); }
+ .text-grid { fill: var(--neon-blue); }
+ .text-export { fill: var(--neon-red); }
+ .text-battery { fill: var(--neon-green); }
+ `;
+ }
+
+ // --- SVG ICON RENDERER ---
+ _renderIcon(type, val = 0, colorOverride = null) {
+ if (type === 'solar') {
+ const animate = Math.round(val) > 0 ? 'spin-slow' : '';
+ const color = colorOverride || 'var(--neon-yellow)';
+ return html`
`;
+ }
+ if (type === 'grid') {
+ const animate = Math.round(val) > 0 ? 'pulse' : '';
+ const color = colorOverride || 'var(--neon-blue)';
+ return html`
`;
+ }
+ if (type === 'battery') {
+ const soc = Math.min(Math.max(val, 0), 100) / 100;
+ const rectHeight = 14 * soc;
+ const rectY = 18 - rectHeight;
+ const rectColor = soc > 0.2 ? 'var(--neon-green)' : 'var(--neon-red)';
+ return html`
`;
+ }
+ if (type === 'house') {
+ const strokeColor = colorOverride || 'var(--neon-pink)';
+ return html`
`;
+ }
+ if (type === 'car') {
+ return html`
`;
+ }
+ if (type === 'heater') {
+ return html`
`;
+ }
+ if (type === 'pool') {
+ return html`
`;
+ }
+ return html``;
+ }
+
+ _formatPower(val) {
+ if (val === 0) return "0";
+ if (Math.abs(val) >= 1000) {
+ return (val / 1000).toFixed(1) + " kW";
+ }
+ return Math.round(val) + " W";
+ }
+
+ // --- DOM NODE SVG GENERATOR ---
+ _renderSVGPath(d, color) {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", d);
+ path.setAttribute("class", "bracket-line");
+ path.setAttribute("stroke", color);
+ path.setAttribute("stroke-width", "1.5");
+ path.setAttribute("fill", "none");
+ path.style.stroke = color;
+ path.style.fill = "none";
+ return path;
+ }
+
+ // --- SQUARE BRACKET GENERATOR ---
+ _createBracketPath(startPx, widthPx, direction) {
+ if (widthPx < 5) return "";
+
+ const r = 5;
+ const startX = startPx;
+ const endX = startPx + widthPx;
+
+ let yBase, yLine;
+
+ if (direction === 'down') {
+ yBase = 24;
+ yLine = 4;
+ } else {
+ yBase = 0;
+ yLine = 20;
+ }
+
+ const height = Math.abs(yBase - yLine);
+ const rEff = Math.min(r, height / 2, widthPx / 2);
+
+ const yCorner = direction === 'down' ? yLine + rEff : yLine - rEff;
+
+ return `
+ M ${startX} ${yBase}
+ L ${startX} ${yCorner}
+ Q ${startX} ${yLine} ${startX + rEff} ${yLine}
+ L ${endX - rEff} ${yLine}
+ Q ${endX} ${yLine} ${endX} ${yCorner}
+ L ${endX} ${yBase}
+ `;
+ }
+
+ // --- RENDER COMPACT VIEW ---
+ _renderCompactView(entities) {
+ // 1. Get Values
+ const getVal = (entity) => {
+ const state = this.hass.states[entity];
+ return state ? parseFloat(state.state) || 0 : 0;
+ };
+
+ const solar = entities.solar ? Math.max(0, getVal(entities.solar)) : 0;
+ const gridMain = entities.grid ? getVal(entities.grid) : 0;
+ const gridExportSensor = entities.grid_export ? getVal(entities.grid_export) : 0;
+ const battery = entities.battery ? getVal(entities.battery) : 0;
+ const c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0; // EV Value
+
+ // 2. Logic Calculation
+ let gridImport = 0;
+ let gridExport = 0;
+
+ if (entities.grid_export && entities.grid_export !== "") {
+ gridImport = gridMain > 0 ? gridMain : 0;
+ gridExport = Math.abs(gridExportSensor);
+ } else {
+ gridImport = gridMain > 0 ? gridMain : 0;
+ gridExport = gridMain < 0 ? Math.abs(gridMain) : 0;
+ }
+
+ const batteryCharge = battery > 0 ? battery : 0;
+ const batteryDischarge = battery < 0 ? Math.abs(battery) : 0;
+
+ let solarToBatt = 0;
+ let gridToBatt = 0;
+
+ if (batteryCharge > 0) {
+ if (solar >= batteryCharge) {
+ solarToBatt = batteryCharge;
+ gridToBatt = 0;
+ } else {
+ solarToBatt = solar;
+ gridToBatt = batteryCharge - solar;
+ }
+ }
+
+ const solarTotalToCons = Math.max(0, solar - solarToBatt - gridExport);
+ const gridTotalToCons = Math.max(0, gridImport - gridToBatt);
+ const battTotalToCons = batteryDischarge;
+
+ const totalCons = solarTotalToCons + gridTotalToCons + battTotalToCons;
+
+ // Calculate Splits
+ let evPower = 0;
+ let housePower = totalCons;
+
+ if (c1Val > 0 && totalCons > 0) {
+ evPower = Math.min(c1Val, totalCons);
+ housePower = totalCons - evPower;
+ }
+
+ // Calculate Total Bar Width (Flux)
+ // The Bar represents: Battery Discharge + Solar + Grid Import
+ // This MUST equal: House + EV + Export + Battery Charge
+
+ // SOURCES (for Bar Segments)
+ const srcBattery = batteryDischarge;
+ const srcSolar = solar; // Solar includes Export + Charge + Cons
+ const srcGrid = gridImport;
+
+ const totalFlux = srcBattery + srcSolar + srcGrid;
+
+ // DESTINATIONS (for Bottom Brackets)
+ const destHouse = housePower;
+ const destEV = evPower;
+ const destExport = gridExport;
+ // Note: Battery Charge is also a destination (internal flow), but usually not bracketed if we only want "Consumers"
+ // If we don't bracket Charge, there will be a gap. We can accept that or add a Charge bracket.
+ // Given user request "Only EV... and Grid Export", we stick to those.
+
+ const threshold = 0.1;
+ const availableWidth = (this._cardWidth && this._cardWidth > 0) ? this._cardWidth : (this.offsetWidth || 400);
+ const fullWidth = availableWidth - 40;
+
+ if (totalFlux <= threshold) {
+ return html`
Waiting for data...
`;
+ }
+
+ // --- GENERATE BAR SEGMENTS (Aggregated by Source) ---
+ // Order: Battery -> Solar -> Grid
+ const barSegments = [];
+ let currentX = 0;
+
+ const addSegment = (val, color, type, label) => {
+ if (val <= threshold) return;
+ const pct = val / totalFlux;
+ const width = pct * fullWidth;
+ barSegments.push({
+ val,
+ color,
+ widthPct: pct * 100,
+ widthPx: width,
+ startPx: currentX,
+ type,
+ label
+ });
+ currentX += width;
+ }
+
+ addSegment(srcBattery, 'var(--neon-yellow)', 'battery', 'battery');
+ addSegment(srcSolar, 'var(--neon-green)', 'solar', 'solar');
+ addSegment(srcGrid, 'var(--grid-grey)', 'grid', 'grid');
+
+ // --- GENERATE TOP BRACKETS (Based on Bar Segments) ---
+ const topBrackets = barSegments.map(s => {
+ const path = this._createBracketPath(s.startPx, s.widthPx, 'down');
+ let icon = '';
+ let iconColor = '';
+ if (s.type === 'solar') { icon = 'mdi:weather-sunny'; iconColor = 'var(--neon-green)'; }
+ if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--grid-grey)'; }
+ if (s.type === 'battery') { icon = 'mdi:battery-high'; iconColor = 'var(--neon-yellow)'; }
+
+ return { path, width: s.widthPx, center: s.startPx + (s.widthPx/2), icon, iconColor };
+ });
+
+ // --- GENERATE BOTTOM BRACKETS (Independent Calculation) ---
+ // Order: House -> EV -> Export
+ const bottomBrackets = [];
+ let bottomX = 0;
+
+ const addBottomBracket = (val, type) => {
+ if (val <= threshold) return;
+ const pct = val / totalFlux;
+ const width = pct * fullWidth;
+
+ let icon = '';
+ let iconColor = '';
+
+ if (type === 'house') { icon = 'mdi:home'; iconColor = 'var(--primary-text-color)'; }
+ if (type === 'car') { icon = 'mdi:car-electric'; iconColor = '#a855f7'; }
+ if (type === 'export') { icon = 'mdi:arrow-right-box'; iconColor = 'var(--export-purple)'; }
+
+ const path = this._createBracketPath(bottomX, width, 'up');
+ bottomBrackets.push({
+ path,
+ width: width,
+ center: bottomX + (width/2),
+ icon,
+ iconColor
+ });
+ bottomX += width;
+ };
+
+ addBottomBracket(destHouse, 'house');
+ addBottomBracket(destEV, 'car');
+ addBottomBracket(destExport, 'export');
+
+ // Note: If there is Battery Charging happening, bottomX will not reach fullWidth.
+ // This leaves a gap at the end (or between segments depending on logic), which is visually correct
+ // as "Internal/Stored Energy" is not an external output.
+
+ return html`
+
+
+
+
+
+ ${topBrackets.map(b => b.width > 20 ? html`
+
+
+
` : '')}
+
+
+
+
+ ${barSegments.map(s => html`
+
+ ${s.widthPx > 35 ? this._formatPower(s.val) : ''}
+
+ `)}
+
+
+
+
+
+ ${bottomBrackets.map(b => b.width > 20 ? html`
+
+
+
` : '')}
+
+
+
+ `;
+ }
+
+ // --- RENDER STANDARD VIEW ---
+ _renderStandardView(entities) {
+ // FIX: Default to hidden unless explicitly set to false
+ const hideInactive = this.config.hide_inactive_flows !== false;
+
+ const globalFlowRate = this.config.show_flow_rates !== false;
+
+ // FLOW RATE TOGGLES
+ const showFlowSolar = this.config.show_flow_rate_solar !== undefined ? this.config.show_flow_rate_solar : globalFlowRate;
+ const showFlowGrid = this.config.show_flow_rate_grid !== undefined ? this.config.show_flow_rate_grid : globalFlowRate;
+ const showFlowBattery = this.config.show_flow_rate_battery !== undefined ? this.config.show_flow_rate_battery : globalFlowRate;
+
+ // LABEL TOGGLES
+ const showLabelSolar = this.config.show_label_solar === true;
+ const showLabelGrid = this.config.show_label_grid === true;
+ const showLabelBattery = this.config.show_label_battery === true;
+ const showLabelHouse = this.config.show_label_house === true;
+
+ const useColoredValues = this.config.use_colored_values === true;
+ const showDonut = this.config.show_donut_border === true;
+ const showTail = this.config.show_comet_tail === true;
+ const showDashedLine = this.config.show_dashed_line === true;
+ const showTint = this.config.show_tinted_background === true;
+ const hideConsumerIcons = this.config.hide_consumer_icons === true;
+ const showNeonGlow = this.config.show_neon_glow !== false;
+
+ // CUSTOM LABELS
+ const labelSolarText = this.config.solar_label || this._localize('card.label_solar');
+ const labelGridText = this.config.grid_label || this._localize('card.label_import');
+ const labelBatteryText = this.config.battery_label || (entities.battery && this.hass.states[entities.battery] && this.hass.states[entities.battery].state > 0 ? '+' : '-') + " " + this._localize('card.label_battery');
+ const labelHouseText = this.config.house_label || this._localize('card.label_house');
+
+ // CUSTOM ICONS
+ const iconSolar = this.config.solar_icon;
+ const iconGrid = this.config.grid_icon;
+ const iconBattery = this.config.battery_icon;
+
+ // Determine existence of main entities
+ const hasSolar = !!(entities.solar && entities.solar !== "");
+ const hasGrid = !!(entities.grid && entities.grid !== "");
+ const hasBattery = !!(entities.battery && entities.battery !== "");
+
+ const styleSolar = hasSolar ? '' : 'display: none;';
+ const styleSolarBatt = (hasSolar && hasBattery) ? '' : 'display: none;';
+ const styleGrid = hasGrid ? '' : 'display: none;';
+ const styleGridBatt = (hasGrid && hasBattery) ? '' : 'display: none;';
+ const styleBattery = hasBattery ? '' : 'display: none;';
+
+ const textClass = showNeonGlow ? 'flow-text' : 'flow-text no-shadow';
+
+ // Custom Labels for Consumers
+ const labelC1 = this.config.consumer_1_label || "E-Auto";
+ const labelC2 = this.config.consumer_2_label || "Heizung";
+ const labelC3 = this.config.consumer_3_label || "Pool";
+
+ const getVal = (entity) => {
+ const state = this.hass.states[entity];
+ return state ? parseFloat(state.state) || 0 : 0;
+ };
+
+ const c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0;
+ const c2Val = entities.consumer_2 ? getVal(entities.consumer_2) : 0;
+ const c3Val = entities.consumer_3 ? getVal(entities.consumer_3) : 0;
+
+ const showC1 = (entities.consumer_1 && Math.round(c1Val) > 0);
+ const showC2 = (entities.consumer_2 && Math.round(c2Val) > 0);
+ const showC3 = (entities.consumer_3 && Math.round(c3Val) > 0);
+ const anyBottomVisible = showC1 || showC2 || showC3;
+
+ const solar = hasSolar ? getVal(entities.solar) : 0;
+ const gridMain = hasGrid ? getVal(entities.grid) : 0;
+ const gridExpSensor = (hasGrid && entities.grid_export) ? getVal(entities.grid_export) : 0;
+ const battery = hasBattery ? getVal(entities.battery) : 0;
+ const battSoc = (hasBattery && entities.battery_soc) ? getVal(entities.battery_soc) : 0;
+
+ const solarVal = Math.max(0, solar);
+
+ let gridImport = 0;
+ let gridExport = 0;
+
+ if (hasGrid) {
+ if (entities.grid_export && entities.grid_export !== "") {
+ gridImport = gridMain > 0 ? gridMain : 0;
+ gridExport = Math.abs(gridExpSensor);
+ } else {
+ gridImport = gridMain > 0 ? gridMain : 0;
+ gridExport = gridMain < 0 ? Math.abs(gridMain) : 0;
+ }
+ }
+
+ const batteryCharge = battery > 0 ? battery : 0;
+ const batteryDischarge = battery < 0 ? Math.abs(battery) : 0;
+
+ let solarToBatt = 0;
+ let gridToBatt = 0;
+
+ if (hasBattery && batteryCharge > 0) {
+ if (solarVal >= batteryCharge) {
+ solarToBatt = batteryCharge;
+ gridToBatt = 0;
+ } else {
+ solarToBatt = solarVal;
+ gridToBatt = batteryCharge - solarVal;
+ }
+ }
+
+ const solarToHouse = Math.max(0, solarVal - solarToBatt - gridExport);
+ const gridToHouse = Math.max(0, gridImport - gridToBatt);
+ const house = solarToHouse + gridToHouse + batteryDischarge;
+
+ const isTopArcActive = (solarToBatt > 0);
+ const topShift = isTopArcActive ? 0 : 50;
+ let baseHeight = anyBottomVisible ? 480 : 340;
+ const contentHeight = baseHeight - topShift;
+
+ const designWidth = 420;
+ const availableWidth = this._cardWidth || designWidth;
+ let scale = availableWidth / designWidth;
+ const userZoom = this.config.zoom !== undefined ? this.config.zoom : 0.9;
+ scale = scale * userZoom;
+
+ if (scale < 0.5) scale = 0.5;
+ if (scale > 1.5) scale = 1.5;
+
+ const finalCardHeightPx = contentHeight * scale;
+
+ let houseGradientVal = '';
+ let houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
+ const tintClass = showTint ? 'tinted' : '';
+ const glowClass = showNeonGlow ? 'glow' : '';
+
+ let houseDominantColor = 'var(--neon-pink)';
+ if (house > 0) {
+ if (solarToHouse >= gridToHouse && solarToHouse >= batteryDischarge) {
+ houseDominantColor = 'var(--neon-yellow)';
+ } else if (gridToHouse >= solarToHouse && gridToHouse >= batteryDischarge) {
+ houseDominantColor = 'var(--neon-blue)';
+ } else if (batteryDischarge >= solarToHouse && batteryDischarge >= gridToHouse) {
+ houseDominantColor = 'var(--neon-green)';
+ }
+ }
+
+ if (showDonut) {
+ if (house > 0) {
+ const pctSolar = (solarToHouse / house) * 100;
+ const pctGrid = (gridToHouse / house) * 100;
+ const pctBatt = (batteryDischarge / house) * 100;
+
+ let stops = [];
+ let current = 0;
+ if (pctSolar > 0) { stops.push(`var(--neon-yellow) ${current}% ${current + pctSolar}%`); current += pctSolar; }
+ if (pctGrid > 0) { stops.push(`var(--neon-blue) ${current}% ${current + pctGrid}%`); current += pctGrid; }
+ if (pctBatt > 0) { stops.push(`var(--neon-green) ${current}% ${current + pctBatt}%`); current += pctBatt; }
+ if (current < 99.9) { stops.push(`var(--neon-pink) ${current}% 100%`); }
+
+ houseGradientVal = `conic-gradient(${stops.join(', ')})`;
+
+ if (useColoredValues) {
+ const maxVal = Math.max(solarToHouse, gridToHouse, batteryDischarge);
+ if (maxVal > 0) {
+ if (maxVal === solarToHouse) houseTextCol = 'var(--neon-yellow)';
+ else if (maxVal === gridToHouse) houseTextCol = 'var(--neon-blue)';
+ else if (maxVal === batteryDischarge) houseTextCol = 'var(--neon-green)';
+ } else {
+ houseTextCol = 'var(--neon-pink)';
+ }
+ }
+ } else {
+ houseGradientVal = `var(--neon-pink)`;
+ houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
+ }
+ } else {
+ houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
+ }
+
+ const houseTintStyle = showTint
+ ? `background: color-mix(in srgb, ${houseDominantColor}, transparent 85%);`
+ : '';
+
+ const houseGlowStyle = showNeonGlow
+ ? `box-shadow: 0 0 15px color-mix(in srgb, ${houseDominantColor}, transparent 60%);`
+ : `box-shadow: none;`;
+
+ const houseBubbleStyle = `${showDonut ? `--house-gradient: ${houseGradientVal};` : ''} ${houseTintStyle} ${houseGlowStyle}`;
+
+ const isSolarActive = Math.round(solarVal) > 0;
+ const isGridActive = Math.round(gridImport) > 0;
+
+ const solarColor = isSolarActive ? 'var(--neon-yellow)' : 'var(--secondary-text-color)';
+ const gridColor = isGridActive ? 'var(--neon-blue)' : 'var(--secondary-text-color)';
+
+ const getAnimStyle = (val) => {
+ if (val <= 1) return "opacity: 0;";
+ const userMinDuration = 7;
+ const userMaxDuration = 11;
+ const userFactor = 20000;
+ let duration = userFactor / val;
+ duration = Math.max(userMinDuration, Math.min(userMaxDuration, duration));
+
+ // Adjust speed for dashed line (Factor to slow down: 5x)
+ if (showDashedLine) {
+ duration = duration * 5;
+ }
+
+ return `opacity: 1; animation-duration: ${duration}s;`;
+ };
+
+ const getPipeStyle = (val) => {
+ if (!hideInactive) return "opacity: 0.2;";
+ return val > 1 ? "opacity: 0.2;" : "opacity: 0;";
+ };
+
+ const getTextStyle = (val, type) => {
+ let isVisible = false;
+ if (type === 'solar') isVisible = showFlowSolar;
+ else if (type === 'grid') isVisible = showFlowGrid;
+ else if (type === 'battery') isVisible = showFlowBattery;
+
+ if (!isVisible) return "display: none;";
+ return val > 5 ? "opacity: 1;" : "opacity: 0;";
+ };
+
+ const getColorStyle = (colorVar) => {
+ return useColoredValues ? `color: var(${colorVar});` : '';
+ };
+ const getConsumerColorStyle = (hex) => {
+ return useColoredValues ? `color: ${hex};` : '';
+ }
+
+ const renderLabel = (text, isVisible) => {
+ if (!isVisible) return html``;
+ return html`
${text}
`;
+ };
+
+ const renderMainIcon = (type, val, customIcon, color = null) => {
+ if (customIcon) {
+ const style = color ? `color: ${color};` : (type === 'solar' ? 'color: var(--neon-yellow);' : (type === 'grid' ? 'color: var(--neon-blue);' : (type === 'battery' ? 'color: var(--neon-green);' : '')));
+ return html`
`;
+ }
+ return this._renderIcon(type, val, color);
+ };
+
+ const getCustomClass = (icon) => icon ? 'has-custom-icon' : '';
+
+ const renderConsumer = (isVisible, cssClass, configKey, label, iconType, val, hexColor) => {
+ if (!isVisible) return html``;
+
+ const customIcon = this.config[`${configKey}_icon`];
+ let iconContent;
+
+ const isCustom = !hideConsumerIcons && !!customIcon;
+ const dynamicClass = isCustom ? 'has-custom-icon' : '';
+
+ if (hideConsumerIcons) {
+ iconContent = html``;
+ } else if (customIcon) {
+ iconContent = html`
`;
+ } else {
+ iconContent = this._renderIcon(iconType, val);
+ }
+
+ return html`
+
+ ${iconContent}
+ ${renderLabel(label, true)}
+
${this._formatPower(val)}
+
+ `;
+ };
+
+ const getConsumerPipeStyle = (isActive, val) => {
+ if (!isActive) return "display: none;";
+ return getPipeStyle(val);
+ };
+
+ const getConsumerAnimStyle = (isActive, val) => {
+ if (!isActive) return "display: none;";
+ return getAnimStyle(val);
+ };
+
+ const pathSolarHouse = "M 50 160 Q 50 265 165 265";
+ const pathSolarBatt = "M 50 70 Q 210 -20 370 70";
+ const pathGridImport = "M 210 160 L 210 220";
+ const pathGridExport = "M 165 115 Q 130 145 95 115";
+ const pathGridToBatt = "M 255 115 Q 290 145 325 115";
+ const pathBattHouse = "M 370 160 Q 370 265 255 265";
+ const pathHouseC1 = "M 165 265 Q 50 265 50 370";
+ const pathHouseC2 = "M 210 310 L 210 370";
+ const pathHouseC3 = "M 255 265 Q 370 265 370 370";
+
+ const houseTextStyle = houseTextCol ? `color: ${houseTextCol};` : '';
+ const dashArrayVal = showTail ? '30 360' : (showDashedLine ? '13 13' : '0 380');
+ const strokeWidthVal = showDashedLine ? 4 : 8;
+
+ return html`
+
+
+
+
+
+
+
+ ${hasSolar ? html`
+
+ ${renderMainIcon('solar', solarVal, iconSolar, solarColor)}
+ ${renderLabel(labelSolarText, showLabelSolar)}
+
${this._formatPower(solarVal)}
+
` : ''}
+
+ ${hasGrid ? html`
+
+ ${renderMainIcon('grid', gridImport, iconGrid, gridColor)}
+ ${renderLabel(labelGridText, showLabelGrid)}
+
${this._formatPower(gridImport)}
+
` : ''}
+
+ ${hasBattery ? html`
+
+ ${renderMainIcon('battery', battSoc, iconBattery)}
+ ${renderLabel(labelBatteryText, showLabelBattery)}
+
${Math.round(battSoc)}%
+
` : ''}
+
+
+ ${renderMainIcon('house', 0, null, houseDominantColor)}
+ ${renderLabel(labelHouseText, showLabelHouse)}
+
${this._formatPower(house)}
+
+
+ ${renderConsumer(showC1, 'c1', 'consumer_1', labelC1, 'car', c1Val, '#a855f7')}
+ ${renderConsumer(showC2, 'c2', 'consumer_2', labelC2, 'heater', c2Val, '#f97316')}
+ ${renderConsumer(showC3, 'c3', 'consumer_3', labelC3, 'pool', c3Val, '#06b6d4')}
+
+
+
+
+ `;
+ }
+
+ render() {
+ if (!this.config || !this.hass) return html``;
+
+ // SWITCH VIEW BASED ON CONFIG
+ if (this.config.compact_view === true) {
+ return this._renderCompactView(this.config.entities || {});
+ } else {
+ return this._renderStandardView(this.config.entities || {});
+ }
+ }
+}
+
+customElements.define("power-flux-card", PowerFluxCard);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "power-flux-card",
+ name: "Power Flux Card",
+ description: "Advanced Animated Energy Flow Card",
+});