battery interactive charts

<style>
.ignis-battery-chart { border-radius: 8px; overflow: hidden; }
html.theme-dark .ignis-battery-chart  { border: 2px solid #a89984; }
html.theme-light .ignis-battery-chart { border: 2px solid #504945; }
</style>
<script>
/* Interactive battery charts — custom hover, theme-aware legend + frame. */
(function () {
  if (typeof window.IGNIS_CHARTS_DONE !== "undefined") return;

  var PLOTLY_SRC = "api/notes/I9ZVK5qMTYMk/download";
  var D = {"soc":[0,5,10,15,20,25,30,35,40,45,50,55,60,65,70,75,80,85,90,95,100],"temps":[-10,0,10,20,30,40],"soc10":[0,10,20,30,40,50,60,70,80,90,100],"temps10":[-10,0,10,20,30,40],"ocv_p":[[3.3557,3.4574,3.4943,3.5299,3.5607,3.5807,3.5961,3.611,3.6271,3.6474,3.6752,3.7131,3.7629,3.8058,3.8502,3.8966,3.9447,3.9952,4.0488,4.105,4.1653],[3.2482,3.4153,3.4508,3.5005,3.5412,3.5672,3.5833,3.5988,3.6148,3.6329,3.6577,3.6973,3.7513,3.7956,3.8428,3.8923,3.9433,3.9969,4.0539,4.1138,4.178],[3.2572,3.4098,3.4421,3.4935,3.54,3.567,3.5839,3.5997,3.616,3.6342,3.6585,3.7018,3.7533,3.7974,3.8456,3.8961,3.9482,4.0031,4.0613,4.1223,4.182],[3.2849,3.4117,3.444,3.4968,3.543,3.5716,3.59,3.6061,3.6219,3.6394,3.6627,3.7074,3.7512,3.7942,3.8414,3.891,3.9424,3.9965,4.0535,4.1132,4.1759],[3.1824,3.3982,3.4352,3.4836,3.5325,3.5646,3.5871,3.604,3.6201,3.6377,3.6604,3.7026,3.7494,3.7927,3.8407,3.8913,3.9436,3.9986,4.0567,4.1175,4.1802],[3.0798,3.3847,3.4264,3.4703,3.522,3.5575,3.5841,3.6018,3.6183,3.636,3.6581,3.6979,3.7475,3.7911,3.8401,3.8917,3.9448,4.0007,4.06,4.1218,4.1845]],"ocv_m":[[2.9627,3.1784,3.4015,3.4565,3.517,3.5559,3.5907,3.6169,3.6419,3.6826,3.7264,3.7915,3.8541,3.8905,3.9338,3.9855,4.035,4.0615,4.0798,4.118,4.1629],[2.9659,3.1819,3.3961,3.4551,3.514,3.5514,3.595,3.6171,3.6471,3.6845,3.7243,3.7894,3.8598,3.8926,3.931,3.9839,4.0364,4.0624,4.0861,4.1201,4.1586],[2.9717,3.1832,3.4007,3.4582,3.5125,3.5563,3.591,3.6221,3.649,3.6848,3.7255,3.7944,3.8591,3.8927,3.93,3.9863,4.0397,4.0627,4.0831,4.1216,4.1635],[2.9762,3.1853,3.4005,3.4591,3.513,3.5531,3.5976,3.6235,3.6498,3.69,3.7273,3.7946,3.8564,3.8928,3.9347,3.9879,4.0373,4.0603,4.0877,4.1265,4.1649],[2.9787,3.1863,3.4001,3.4538,3.5156,3.5565,3.5942,3.6204,3.6477,3.6932,3.734,3.7916,3.8568,3.8979,3.9317,3.9871,4.0375,4.0638,4.0839,4.1214,4.1607],[2.9816,3.192,3.3993,3.4543,3.5159,3.554,3.5989,3.6237,3.6559,3.6913,3.7351,3.7942,3.8594,3.8939,3.9317,3.9888,4.0417,4.0613,4.0894,4.1222,4.1621]],"disc_p":[[0.0,20.0,42.0,48.0,58.0,64.0,68.0,70.0,70.0,70.0,70.0],[0.0,76.0,108.0,144.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,108.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,116.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,108.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,76.0,86.0,96.0,108.0,108.0,108.0,108.0,108.0,108.0,108.0]],"disc_m":[[0.0,1.6,232.8,337.6,379.9,400.6,568.7,631.2,649.5,670.1,691.3],[0.0,40.1,397.2,505.2,568.8,673.1,757.2,820.3,862.3,904.7,946.9],[0.0,127.6,526.4,565.7,712.5,796.9,880.6,946.8,988.9,1027.3,1048.1],[0.0,274.8,568.5,715.3,820.3,902.0,988.3,1066.3,1105.9,1192.7,1213.2],[0.0,408.7,636.3,810.4,894.2,977.8,1059.0,1142.8,1185.1,1300.4,1330.2],[0.0,526.1,715.7,859.6,943.9,1030.2,1114.5,1195.8,1240.6,1366.7,1408.1]],"chg_p":[[5.0,5.0,5.0,5.0,5.0,5.0,5.0,5.0,3.0,2.0,0.0],[15.0,15.0,15.0,15.0,15.0,15.0,15.0,15.0,11.0,6.0,0.0],[46.0,46.0,46.0,46.0,46.0,46.0,46.0,46.0,32.0,20.0,0.0],[86.0,86.0,86.0,86.0,86.0,86.0,86.0,66.0,46.0,28.0,0.0],[100.0,100.0,100.0,100.0,100.0,100.0,100.0,100.0,74.0,46.0,0.0],[86.0,86.0,86.0,86.0,86.0,86.0,86.0,66.0,46.0,28.0,0.0]],"chg_m":[[232.7,229.2,208.9,187.9,187.1,148.8,106.7,40.7,41.0,2.0,0.0],[337.8,337.6,316.6,292.8,274.9,232.2,169.4,127.4,40.8,40.1,0.0],[439.7,418.6,398.0,376.7,337.8,271.1,211.5,166.3,85.5,43.2,0.0],[559.9,508.1,475.0,454.3,410.0,316.8,256.7,199.0,115.9,43.8,0.0],[631.5,568.0,518.5,497.2,446.9,350.6,281.9,216.8,127.9,67.2,0.0],[673.9,610.1,547.1,523.8,463.7,379.9,295.3,233.0,124.4,106.6,0.0]],"disc_m_der":[[0.0,0.0,70.0,70.0,70.0,70.0,70.0,70.0,70.0,70.0,70.0],[0.0,42.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,125.1,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0,150.0],[0.0,108.0,108.0,108.0,108.0,108.0,108.0,108.0,108.0,108.0,108.0]],"chg_m_der":[[5.0,5.0,5.0,5.0,5.0,5.0,4.9,2.1,2.1,0.0,0.0],[15.0,15.0,15.0,15.0,15.0,15.0,15.0,15.0,6.3,6.3,0.0],[46.0,46.0,46.0,46.0,46.0,46.0,46.0,46.0,38.5,19.3,0.0],[86.0,86.0,86.0,86.0,86.0,86.0,86.0,86.0,85.5,36.1,0.0],[100.0,100.0,100.0,100.0,100.0,100.0,100.0,100.0,99.9,65.6,0.0],[86.0,86.0,86.0,86.0,86.0,86.0,86.0,86.0,85.9,84.1,0.0]]};

  function isDark() {
    return document.documentElement.classList.contains("theme-dark");
  }

  function palette() {
    return isDark()
      ? { paper: "#282828", plot: "#282828", font: "#ebdbb2",
          grid: "#504945", bg: "#3c3836", legendBg: "#3c3836", legendBorder: "#504945" }
      : { paper: "#fbf1c7", plot: "#fbf1c7", font: "#3c3836",
          grid: "#d5c4a1", bg: "#ebdbb2", legendBg: "#ebdbb2", legendBorder: "#a89984" };
  }

  function buildFigure(x, y, z, name, color, unit) {
    return {
      type: "surface", name: name, x: x, y: y, z: z,
      colorscale: [[0, color], [1, color]], opacity: 0.85,
      showscale: false, hoverinfo: "none",
      showlegend: true,   // surfaces hide their legend by default
      contours: { z: { show: true, usecolormap: false, color: "#ebdbb2", width: 1 } }
    };
  }

  function clipPlane(x, y, zval, color, label) {
    var zz = [];
    for (var i = 0; i < y.length; i++) {
      var row = [];
      for (var j = 0; j < x.length; j++) row.push(zval);
      zz.push(row);
    }
    return { type: "surface", name: label, x: x, y: y, z: zz,
             colorscale: [[0, color], [1, color]], opacity: 0.30,
             showscale: false, hoverinfo: "none",
             showlegend: true };
  }

  function xyz(data, rows, soc) {
    var X = [], Y = [], Z = [];
    for (var ti = 0; ti < data.temps.length; ti++)
      for (var si = 0; si < soc.length; si++) {
        X.push(soc[si]); Y.push(data.temps[ti]); Z.push(rows[ti][si]);
      }
    return { x: X, y: Y, z: Z };
  }

  function xyz10(data, rows, soc) {
    var X = [], Y = [], Z = [];
    for (var ti = 0; ti < data.temps10.length; ti++)
      for (var si = 0; si < soc.length; si++) {
        X.push(soc[si]); Y.push(data.temps10[ti]); Z.push(rows[ti][si]);
      }
    return { x: X, y: Y, z: Z };
  }

  function surfaceFrom(xyz) {
    var xs = [], ys = [], zgrid = [], i;
    xyz.x.forEach(function (v) { if (xs.indexOf(v) < 0) xs.push(v); });
    xyz.y.forEach(function (v) { if (ys.indexOf(v) < 0) ys.push(v); });
    xs.sort(function (a, b) { return a - b; });
    ys.sort(function (a, b) { return a - b; });
    for (var yi = 0; yi < ys.length; yi++) {
      var row = [];
      for (var xi = 0; xi < xs.length; xi++) {
        var val = null;
        for (i = 0; i < xyz.x.length; i++)
          if (xyz.x[i] === xs[xi] && xyz.y[i] === ys[yi]) { val = xyz.z[i]; break; }
        row.push(val);
      }
      zgrid.push(row);
    }
    return { x: xs, y: ys, z: zgrid };
  }

  function zAt(surf, x, y) {
    var xi = surf.x.indexOf(x);
    var yi = surf.y.indexOf(y);
    if (xi < 0 || yi < 0) return null;
    return surf.z[yi][xi];
  }

  function layout(pal) {
    return {
      paper_bgcolor: pal.paper, plot_bgcolor: pal.plot,
      font: { color: pal.font, size: 11 },
      legend: {
        x: 0, y: 0, xanchor: "left", yanchor: "bottom",
        bgcolor: pal.legendBg, bordercolor: pal.legendBorder, borderwidth: 1,
        font: { color: pal.font, size: 11 },
        orientation: "v"
      },
      scene: {
        xaxis: { title: "SoC [%]", gridcolor: pal.grid, backgroundcolor: pal.bg },
        yaxis: { title: "Temp [°C]", gridcolor: pal.grid, backgroundcolor: pal.bg },
        zaxis: { title: "", gridcolor: pal.grid, backgroundcolor: pal.bg },
        camera: { eye: { x: 1.4, y: -1.4, z: 0.9 } }
      },
      margin: { l: 0, r: 0, t: 40, b: 0 }
    };
  }

  var charts = [];   // { el, hoverTraces }

  function render(containerId, traces, title, ztitle, height, hoverTraces) {
    var el = document.getElementById(containerId);
    if (!el) return;
    var l = layout(palette());
    l.title = { text: title, font: { color: palette().font }, x: 0.5 };
    l.scene.zaxis.title = ztitle;
    if (height) l.height = height;
    Plotly.newPlot(el, traces, l, { displaylogo: false, responsive: true });
    charts.push({ el: el, hoverTraces: hoverTraces });
    attachHover(el, hoverTraces);
  }

  function applyTheme() {
    charts.forEach(function (c) {
      var pal = palette();
      Plotly.relayout(c.el, {
        "paper_bgcolor": pal.paper,
        "plot_bgcolor": pal.plot,
        "font.color": pal.font,
        "legend.bgcolor": pal.legendBg,
        "legend.bordercolor": pal.legendBorder,
        "legend.font.color": pal.font,
        "scene.xaxis.gridcolor": pal.grid,
        "scene.xaxis.backgroundcolor": pal.bg,
        "scene.yaxis.gridcolor": pal.grid,
        "scene.yaxis.backgroundcolor": pal.bg,
        "scene.zaxis.gridcolor": pal.grid,
        "scene.zaxis.backgroundcolor": pal.bg
      });
    });
  }

  // Plotly traces are created in exactly the order of hoverTraces, so trace
  // index i corresponds to hoverTraces[i] — update the legend name every hover.
  function updateLegend(el, hoverTraces, x, y) {
    if (!el.data) return;
    var names = [], indices = [];
    for (var i = 0; i < hoverTraces.length; i++) {
      var z = zAt(hoverTraces[i].surf, x, y);
      if (z === null) continue;
      names.push(hoverTraces[i].name + ": " + z.toFixed(2) + " " + hoverTraces[i].unit);
      indices.push(i);
    }
    if (names.length) Plotly.restyle(el, { name: names }, indices);
  }

  function attachHover(el, hoverTraces) {
    if (!el || !hoverTraces || !hoverTraces.length) return;
    el.style.position = "relative";
    var tip = document.createElement("div");
    tip.style.cssText =
      "position:absolute;pointer-events:none;display:none;z-index:1000;" +
      "background:#3c3836;color:#ebdbb2;border:1px solid #504945;border-radius:4px;" +
      "padding:6px 9px;font-size:12px;line-height:1.5;white-space:nowrap;" +
      "box-shadow:0 2px 8px rgba(0,0,0,.5);";
    el.appendChild(tip);

    function moveTip(evt) {
      if (!evt) return;
      var mx = evt.clientX, my = evt.clientY;
      if ((mx === undefined || my === undefined) && evt.points && evt.points[0] && evt.points[0].event) {
        mx = evt.points[0].event.clientX; my = evt.points[0].event.clientY;
      }
      if (mx === undefined || my === undefined) return;
      var rect = el.getBoundingClientRect();
      tip.style.left = (mx - rect.left + 14) + "px";
      tip.style.top = (my - rect.top + 14) + "px";
    }

    el.on("plotly_hover", function (evt) {
      if (!evt.points || !evt.points.length) return;
      var pt = evt.points[0];
      var x = pt.x, y = pt.y;
      if ((x === undefined || y === undefined) && pt.pointNumber !== undefined) {
        var t0 = hoverTraces[0].surf;
        var n = pt.pointNumber;
        var cols = t0.x.length;
        var xi = n % cols, yi = Math.floor(n / cols);
        if (xi < t0.x.length && yi < t0.y.length) { x = t0.x[xi]; y = t0.y[yi]; }
      }
      if (x === undefined || y === undefined) return;
      var lines = ["<b>SoC</b>: " + x + " %", "<b>Temp</b>: " + y + " °C"];
      for (var i = 0; i < hoverTraces.length; i++) {
        var z = zAt(hoverTraces[i].surf, x, y);
        if (z !== null) lines.push("<b>" + hoverTraces[i].name + "</b>: " + z.toFixed(2) + " " + hoverTraces[i].unit);
      }
      tip.innerHTML = lines.join("<br>");
      tip.style.display = "block";
      moveTip(evt);
      updateLegend(el, hoverTraces, x, y);
    });

    el.on("plotly_unhover", function () { tip.style.display = "none"; });
  }

  function replaceSvg(title, containerId) {
    var found = null;
    document.querySelectorAll("figure.image img").forEach(function (img) {
      if (img.src && img.src.indexOf(title) !== -1) found = img;
    });
    if (!found) return false;
    var fig = found.closest("figure");
    var div = document.createElement("div");
    div.id = containerId;
    div.className = "ignis-battery-chart";
    fig.parentNode.replaceChild(div, fig);
    return true;
  }

  function hasAnyChart() {
    return document.querySelector('figure.image img[src*="ocv_3d"], figure.image img[src*="plut_discharge_3d"], figure.image img[src*="plut_charge_3d"], figure.image img[src*="plut_discharge_derated_3d"], figure.image img[src*="plut_charge_derated_3d"]');
  }

  function loadPlotly(cb) {
    if (typeof Plotly !== "undefined") { cb(); return; }
    var s = document.createElement("script");
    s.src = PLOTLY_SRC;
    s.onload = cb;
    s.onerror = function () { console.error("Plotly load failed"); };
    document.head.appendChild(s);
  }

  function build() {
    if (replaceSvg("ocv_3d", "plotly-ocv")) {
      var p = surfaceFrom(xyz(D, D.ocv_p, D.soc));
      var m = surfaceFrom(xyz(D, D.ocv_m, D.soc));
      render("plotly-ocv",
        [ Object.assign(buildFigure(p.x, p.y, p.z, "PHEV1", "#3c6a75", "V"),
                        { colorscale: [[0, "#3c6a75"], [1, "#83a598"]] }),
          Object.assign(buildFigure(m.x, m.y, m.z, "M50L", "#b4530e", "V"),
                        { colorscale: [[0, "#b4530e"], [1, "#fe8019"]] }) ],
        "OCV vs SoC and temperature (per cell)", "OCV [V]", 500,
        [ { name: "PHEV1", surf: p, unit: "V" },
          { name: "M50L", surf: m, unit: "V" } ]);
    }
    if (replaceSvg("plut_discharge_3d", "plotly-discharge")) {
      var p = surfaceFrom(xyz10(D, D.disc_p, D.soc10));
      var m = surfaceFrom(xyz10(D, D.disc_m, D.soc10));
      render("plotly-discharge",
        [ Object.assign(buildFigure(p.x, p.y, p.z, "PHEV1 6S2P", "#3c6a75", "A"),
                        { colorscale: [[0, "#3c6a75"], [1, "#83a598"]] }),
          Object.assign(buildFigure(m.x, m.y, m.z, "M50L 6S21P (30 s)", "#b4530e", "A"),
                        { colorscale: [[0, "#b4530e"], [1, "#fe8019"]] }),
          clipPlane(p.x, p.y, 125, "#fabd2f", "Fuse 125 A"),
          clipPlane(p.x, p.y, 150, "#fb4934", "BMS clip 150 A") ],
        "Max discharge current — PHEV1 vs M50L (30 s), with fuse/BMS limits",
        "Max discharge [A]", 520,
        [ { name: "PHEV1 6S2P", surf: p, unit: "A" },
          { name: "M50L 6S21P (30 s)", surf: m, unit: "A" },
          { name: "Fuse 125 A", surf: clipPlaneSurf(p, 125), unit: "A" },
          { name: "BMS clip 150 A", surf: clipPlaneSurf(p, 150), unit: "A" } ]);
    }
    if (replaceSvg("plut_charge_3d", "plotly-charge")) {
      var p = surfaceFrom(xyz10(D, D.chg_p, D.soc10));
      var m = surfaceFrom(xyz10(D, D.chg_m, D.soc10));
      render("plotly-charge",
        [ Object.assign(buildFigure(p.x, p.y, p.z, "PHEV1 6S2P", "#3c6a75", "A"),
                        { colorscale: [[0, "#3c6a75"], [1, "#83a598"]] }),
          Object.assign(buildFigure(m.x, m.y, m.z, "M50L 6S21P (30 s)", "#b4530e", "A"),
                        { colorscale: [[0, "#b4530e"], [1, "#fe8019"]] }),
          clipPlane(p.x, p.y, 100, "#b8bb26", "BMS charge 100 A") ],
        "Max charge current — PHEV1 vs M50L (30 s), with BMS 100 A",
        "Max charge [A]", 520,
        [ { name: "PHEV1 6S2P", surf: p, unit: "A" },
          { name: "M50L 6S21P (30 s)", surf: m, unit: "A" },
          { name: "BMS charge 100 A", surf: clipPlaneSurf(p, 100), unit: "A" } ]);
    }
    if (replaceSvg("plut_discharge_derated_3d", "plotly-discharge-derated")) {
      var p = surfaceFrom(xyz10(D, D.disc_p, D.soc10));
      var m = surfaceFrom(xyz10(D, D.disc_m_der, D.soc10));
      render("plotly-discharge-derated",
        [ Object.assign(buildFigure(p.x, p.y, p.z, "PHEV1 6S2P", "#3c6a75", "A"),
                        { colorscale: [[0, "#3c6a75"], [1, "#83a598"]] }),
          Object.assign(buildFigure(m.x, m.y, m.z, "M50L 6S21P (derated)", "#b4530e", "A"),
                        { colorscale: [[0, "#b4530e"], [1, "#fe8019"]] }),
          clipPlane(p.x, p.y, 125, "#fabd2f", "Fuse 125 A"),
          clipPlane(p.x, p.y, 150, "#fb4934", "BMS clip 150 A") ],
        "Max discharge — M50L soft-clipped to BMS limits (150 A) + temp derating",
        "Max discharge [A]", 520,
        [ { name: "PHEV1 6S2P", surf: p, unit: "A" },
          { name: "M50L 6S21P (derated)", surf: m, unit: "A" },
          { name: "Fuse 125 A", surf: clipPlaneSurf(p, 125), unit: "A" },
          { name: "BMS clip 150 A", surf: clipPlaneSurf(p, 150), unit: "A" } ]);
    }
    if (replaceSvg("plut_charge_derated_3d", "plotly-charge-derated")) {
      var p = surfaceFrom(xyz10(D, D.chg_p, D.soc10));
      var m = surfaceFrom(xyz10(D, D.chg_m_der, D.soc10));
      render("plotly-charge-derated",
        [ Object.assign(buildFigure(p.x, p.y, p.z, "PHEV1 6S2P", "#3c6a75", "A"),
                        { colorscale: [[0, "#3c6a75"], [1, "#83a598"]] }),
          Object.assign(buildFigure(m.x, m.y, m.z, "M50L 6S21P (derated)", "#b4530e", "A"),
                        { colorscale: [[0, "#b4530e"], [1, "#fe8019"]] }),
          clipPlane(p.x, p.y, 100, "#b8bb26", "BMS charge 100 A") ],
        "Max charge — M50L soft-clipped to BMS limits (100 A) + temp derating",
        "Max charge [A]", 520,
        [ { name: "PHEV1 6S2P", surf: p, unit: "A" },
          { name: "M50L 6S21P (derated)", surf: m, unit: "A" },
          { name: "BMS charge 100 A", surf: clipPlaneSurf(p, 100), unit: "A" } ]);
    }
    // Theme changes: recolour all charts.
    var observer = new MutationObserver(function () { applyTheme(); });
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    window.IGNIS_CHARTS_DONE = true;
  }

  function clipPlaneSurf(base, zval) {
    var zz = [];
    for (var i = 0; i < base.y.length; i++) {
      var row = [];
      for (var j = 0; j < base.x.length; j++) row.push(zval);
      zz.push(row);
    }
    return { x: base.x, y: base.y, z: zz };
  }

  function ready(fn) {
    if (document.readyState === "loading")
      document.addEventListener("DOMContentLoaded", fn);
    else fn();
  }

  ready(function () {
    if (!hasAnyChart()) return;   // lazy: nothing to do on other pages
    loadPlotly(build);            // lazy: load Plotly only now
  });
})();
</script>