Star Trek LCARS Dashboard

Been working to build a Star Trek LCARS style Dashboard. I'm very happy with it so far but would love to get some feedback on things that could be done to improve it. All the sections are touch compatible and break down into the specific sections data when clicked. The "EPS Lines" turn off when no activity is happening and speed up and slow down depending on how much energy is being requested vs the maximum energy allowed.

Very nice. Can you post the flow? Would like to see how you implemented the EPS line animation


I currently have something like this and would love to take a look at the flow for what you have done. It does look great :clap:t3::clap:t3::clap:t3:

Thanks! Happy to share how the lines work. I'm still tidying the full flow, so I'm not posting it yet, but the animation is simple enough to explain here.

Each line is an SVG path drawn in a few layers on the same d: a wide translucent glow, a dark housing, a coloured dashed body, and a thin white dashed core. The dark gaps between the dashes are what make it look like a segmented conduit. The movement is pure CSS, sliding stroke-dashoffset, and animation-direction: reverse flips the direction. vector-effect: non-scaling-stroke keeps the dash sizes in screen pixels when the SVG is scaled.

Speed and the number of lit segments come from the live reading. Vue sets CSS variables on each line:

plVars(w, full) {
  const f = Math.min(1, Math.abs(parseFloat(w) || 0) / full), fs = Math.max(0.06, f);
  const lit = 14, gap = 4 + lit * (1 / fs - 1), per = lit + gap, d0 = 1.8 - 1.45 * f;
  return { '--pl-lit': lit + 'px', '--pl-gap': gap.toFixed(1) + 'px', '--pl-per': per.toFixed(1), '--pl-dur': (d0 * per / 18).toFixed(2) + 's' };
}
.pl-wave { stroke-dasharray: var(--pl-lit) var(--pl-gap); animation: pl-run var(--pl-dur) linear infinite; }
@keyframes pl-run { from { stroke-dashoffset: var(--pl-per); } to { stroke-dashoffset: 0; } }

full is the breaker rating, so a line at 13% load shows a few lone segments, and one near its limit is solid, fast and turns red. For lines that have to reach HTML elements, I measure the element positions with getBoundingClientRect and rebuild the paths on resize, since Dashboard 2's updated() didn't fire reliably for me.

I like your layout also! It is similar to my classic layout but way more in depth.It won't let me paste a json but the basic flow is below.

Minimal flow (Menu > Import > Clipboard)
[
  {
    "id": "eps0000000000001",
    "type": "tab",
    "label": "EPS line demo",
    "disabled": false,
    "info": ""
  },
  {
    "id": "eps0000000000002",
    "type": "comment",
    "z": "eps0000000000001",
    "name": "Minimal example: a function makes demo numbers, the template animates them. Choose a Dashboard group on the template node.",
    "info": "",
    "x": 380,
    "y": 60,
    "wires": []
  },
  {
    "id": "eps0000000000003",
    "type": "inject",
    "z": "eps0000000000001",
    "name": "every 3 s",
    "props": [
      {
        "p": "payload"
      }
    ],
    "repeat": "3",
    "crontab": "",
    "once": true,
    "onceDelay": "1",
    "topic": "",
    "payload": "",
    "payloadType": "date",
    "x": 120,
    "y": 140,
    "wires": [
      [
        "eps0000000000004"
      ]
    ]
  },
  {
    "id": "eps0000000000004",
    "type": "function",
    "z": "eps0000000000001",
    "name": "Demo power readings",
    "func": "// EXAMPLE DATA: replace this node with your real inputs (Victron MQTT, Modbus, etc.)\n// and send the same four numbers in msg.payload.\nconst t = Date.now() / 1000;\nconst sun = Math.max(0, Math.sin(t / 40));            // slow day/night cycle\nconst solar = Math.round(5200 * sun);\nconst load = Math.round(1800 + 900 * Math.sin(t / 13) + 400 * Math.sin(t / 5));\nlet battery = Math.max(-2500, Math.min(2500, solar - load)); // + charging, - discharging\nconst grid = Math.max(0, load - solar + Math.min(0, battery)); // import only what solar + battery can't cover\nmsg.payload = { solar, battery, grid, load };\nreturn msg;",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 340,
    "y": 140,
    "wires": [
      [
        "eps0000000000005"
      ]
    ]
  },
  {
    "id": "eps0000000000005",
    "type": "ui-template",
    "z": "eps0000000000001",
    "group": "",
    "page": "",
    "ui": "",
    "name": "Energy flow lines",
    "order": 1,
    "width": "6",
    "height": "0",
    "head": "",
    "format": "<template>\n  <div class=\"eps-demo\">\n    <svg viewBox=\"0 0 400 300\" class=\"eps-svg\" aria-label=\"Energy flow example\">\n      <g v-for=\"l in lines\" :key=\"l.id\" class=\"pl\" :class=\"[l.cls, {on: l.on, reverse: l.rev}]\" :style=\"l.vars\">\n        <path class=\"pl-glow\" :d=\"l.d\"/>\n        <path class=\"pl-hous\" :d=\"l.d\"/>\n        <path class=\"pl-wave\" :d=\"l.d\"/>\n        <path class=\"pl-hot\" :d=\"l.d\"/>\n      </g>\n      <g v-for=\"n in nodes\" :key=\"n.id\">\n        <circle :cx=\"n.x\" :cy=\"n.y\" r=\"30\" class=\"eps-node\" :style=\"{stroke: n.color}\"/>\n        <text :x=\"n.x\" :y=\"n.y - 2\" text-anchor=\"middle\" class=\"eps-name\">{{ n.name }}</text>\n        <text :x=\"n.x\" :y=\"n.y + 14\" text-anchor=\"middle\" class=\"eps-val\">{{ n.watts }} W</text>\n      </g>\n      <circle cx=\"200\" cy=\"150\" r=\"24\" class=\"eps-core\"/>\n      <text x=\"200\" y=\"154\" text-anchor=\"middle\" class=\"eps-name\">CORE</text>\n    </svg>\n  </div>\n</template>\n\n<script>\n// Minimal example: four power readings drive four animated \"conduit\" lines.\n// Expected msg.payload: { solar: W, battery: W (+ charging / - discharging), grid: W (+ importing), load: W }\nexport default {\n  data() {\n    // Rated limit for each line, in watts. Edit these to match your system.\n    return { limits: { solar: 6000, grid: 9600, load: 9600, battery: 5000 } }\n  },\n  computed: {\n    p() { return (this.msg && this.msg.payload) || { solar: 0, battery: 0, grid: 0, load: 0 } },\n    nodes() {\n      const p = this.p, r = (v) => Math.round(Math.abs(Number(v) || 0))\n      return [\n        { id: 's', name: 'SOLAR',   x: 60,  y: 50,  color: '#00ff66', watts: r(p.solar) },\n        { id: 'g', name: 'GRID',    x: 340, y: 50,  color: '#4fa3ff', watts: r(p.grid) },\n        { id: 'b', name: 'BATTERY', x: 60,  y: 250, color: '#ffd700', watts: r(p.battery) },\n        { id: 'h', name: 'HOUSE',   x: 340, y: 250, color: '#ff9900', watts: r(p.load) }\n      ]\n    },\n    lines() {\n      const p = this.p, L = this.limits, n = (v) => Number(v) || 0\n      const bat = n(p.battery)\n      return [\n        this.line('solar', 'M90 66 L178 134',   n(p.solar), L.solar,   false),\n        this.line('grid',  'M310 66 L222 134',  n(p.grid),  L.grid,    false),\n        this.line('house', 'M222 166 L310 234', n(p.load),  L.load,    false),\n        // The battery path is drawn battery -> core; when charging, the line runs the other way\n        this.line('battery', 'M90 234 L178 166', bat,       L.battery, bat > 0)\n      ]\n    }\n  },\n  methods: {\n    line(cls, d, w, full, rev) {\n      const f = Math.min(1, Math.abs(w) / full), fs = Math.max(0.06, f)\n      const lit = 14, gap = 4 + lit * (1 / fs - 1), per = lit + gap, d0 = 1.8 - 1.45 * f\n      return {\n        id: cls, cls: 'pl-' + cls, d, rev, on: Math.abs(w) > 20,\n        vars: { '--pl-lit': lit + 'px', '--pl-gap': gap.toFixed(1) + 'px', '--pl-per': per.toFixed(1), '--pl-dur': (d0 * per / 18).toFixed(2) + 's' }\n      }\n    }\n  }\n}\n</script>\n\n<style>\n.eps-demo { background: #000; padding: 8px; border-radius: 8px; }\n.eps-svg { width: 100%; max-width: 560px; display: block; margin: 0 auto; }\n.eps-node { fill: #05070a; stroke-width: 3; }\n.eps-core { fill: #0a2a40; stroke: #4cc3ff; stroke-width: 3; }\n.eps-name { fill: #cfd8e3; font: 600 9px sans-serif; letter-spacing: .08em; }\n.eps-val { fill: #fff; font: 600 11px sans-serif; }\n\n/* The conduit lines: four stacked paths share one \"d\": glow, dark housing, coloured dashed body, white dashed core. */\n.pl path { fill: none; stroke-linecap: butt; vector-effect: non-scaling-stroke; opacity: 0; }\n.pl.on path { opacity: 1; }\n.pl-glow { stroke-width: 12px; }  .pl.on .pl-glow { opacity: .22; }\n.pl-hous { stroke: #06070b; stroke-width: 9px; }\n.pl-wave { stroke-width: 6px; }\n.pl-hot  { stroke: #fff; stroke-width: 2px; }\n/* Dash length and gap come from the live reading (set per line from the script): more load = denser and faster. */\n.pl-wave, .pl-hot { stroke-dasharray: var(--pl-lit, 14px) var(--pl-gap, 4px); animation: pl-run var(--pl-dur, 1s) linear infinite; }\n.pl.reverse .pl-wave, .pl.reverse .pl-hot { animation-direction: reverse; }\n.pl-solar .pl-glow, .pl-solar .pl-wave { stroke: #00ff66; }\n.pl-grid .pl-glow, .pl-grid .pl-wave { stroke: #4fa3ff; }\n.pl-battery .pl-glow, .pl-battery .pl-wave { stroke: #ffd700; }\n.pl-house .pl-glow, .pl-house .pl-wave { stroke: #ff9900; }\n@keyframes pl-run { from { stroke-dashoffset: var(--pl-per, 18); } to { stroke-dashoffset: 0; } }\n@media (prefers-reduced-motion: reduce) { .pl-wave, .pl-hot { animation: none; } }\n</style>\n",
    "storeOutMessages": true,
    "passthru": false,
    "resendOnRefresh": true,
    "templateScope": "local",
    "className": "",
    "x": 590,
    "y": 140,
    "wires": [
      []
    ]
  }
]

That's very nice indeed

Just imported this and it is cool! I have to figure out how I can use it... my system updates every 5 minutes.

I am currently updating every 5 seconds and should have taken a screenshot last night because i got .my first red alert to popup when i was nearly maxed on the grid overnight while charging my car. Had the charge amps set to high to get my batteries back to the 6am target of 54%. Did just finish the solar view and absolutely love it myself. Didn't have a great solar day today but it was something at least.

How are you getting the string data? I have Tesla solar and Powerwalls and they have shut down the "vitals" local endpoint :frowning: