PageSourceSearch

https://bosch-buderus-wp.github.io/assets/js/heatpump-simulator/ui/heating-curve.js

js bosch-buderus-wp.github.io collected 2026-10-03 08:43:55 UTC 23,243 bytes, 739 lines download raw bytes

1// Heating Curve & Building Load panel (collapsible)
2// Exposes window.HeatpumpHeatingCurve = { initPanel }
3(function () {
4  if (typeof d3 === "undefined") {
5    console.error("d3 not found for HeatpumpHeatingCurve");
6    return;
7  }
8
9  // Configuration constants (extracted from magic numbers)
10  const CONFIG = {
11    WIDTH: 900,
12    HEIGHT: 260,
13    MARGIN: { top: 42, right: 56, bottom: 40, left: 50 },
14    FLOW_MIN: 15,
15    FLOW_MAX: 60,
16    X_EXTRA_MIN: -20,
17    X_MAX: 22,
18    X_TICKS: 8,
19    Y_LEFT_TICKS: 6,
20    Y_RIGHT_TICKS: 6,
21    SAMPLE_POINTS: 64,
22    HOVER_TOL_PX: 7,
23    TIP_PAD_PX: 12,
24    LEGEND_GAP: 18,
25    RIGHT_AXIS_STEP: 0.5,
26
27    COLORS: {
28      CURVE: "#1565c0",
29      LOAD: "#2e7d32",
30      CAP: "#ef6c00",
31      MIN_CAP: "#ad6b00",
32      MOD: "#808080",
33      COP: "#00838f",
34      BV_FILL: "#8e24aa",
35      BV_STROKE: "#4a148c",
36      VLINE: "#222",
37    },
38  };
39
40  function clamp(x, a, b) {
41    return Math.max(a, Math.min(b, x));
42  }
43
44  function flowTargetCurve(Ta, stdOutdoorTempC, flowAt20C, flowAtStdC) {
45    const engine = window.HeatpumpEngine;
46    const y = engine.flowTargetCurve(
47      Ta,
48      stdOutdoorTempC,
49      flowAt20C,
50      flowAtStdC
51    );
52    return clamp(y, 15, 60);
53  }
54
55  function initPanel(containerSel, config = {}) {
56    const container = d3.select(containerSel);
57    if (container.empty()) {
58      console.warn("HeatingCurve container not found");
59      return { render: () => {} };
60    }
61
62    // Collapsible wrapper using <details>
63    const panel = container.append("details").attr("class", "sim-curve-panel");
64    if (config.open) {
65      panel.attr("open", true);
66    }
67
68    const summary = panel
69      .append("summary")
70      .attr("class", "sim-curve-summary")
71      .text("Heizkurve & Gebäudeheizlast");
72
73    const body = panel.append("div").attr("class", "sim-curve-body");
74
75    // SVG setup
76    const width = CONFIG.WIDTH;
77    const height = CONFIG.HEIGHT;
78    const margin = CONFIG.MARGIN;
79    const innerW = width - margin.left - margin.right;
80    const innerH = height - margin.top - margin.bottom;
81
82    const svg = body
83      .append("svg")
84      .attr("class", "sim-curve-svg")
85      .attr("viewBox", `0 0 ${width} ${height}`)
86      .attr("preserveAspectRatio", "xMidYMid meet");
87
88    const g = svg
89      .append("g")
90      .attr("transform", `translate(${margin.left}, ${margin.top})`);
91
92    const gGrid = g.append("g").attr("class", "grid");
93    const gAxes = g.append("g").attr("class", "axes");
94    const gLines = g.append("g").attr("class", "lines");
95    const gLegend = g
96      .append("g")
97      .attr("class", "legend")
98      .attr("transform", `translate(0, -18)`);
99
100    // Overlay for interaction
101    const gOverlay = g.append("g").attr("class", "overlay");
102    const vLine = gOverlay
103      .append("line")
104      .attr("class", "hover-line")
105      .attr("y1", 0)
106      .attr("y2", 0) // set after innerH known
107      .style("opacity", 0);
108
109    const overlayRect = gOverlay.append("rect").attr("class", "hover-capture");
110
111    // HTML tooltip
112    const tip = d3
113      .select("body")
114      .append("div")
115      .attr("class", "sim-curve-tooltip");
116
117    // Hide tooltip and focus line when the panel is collapsed
118    panel.on("toggle", function () {
119      const isOpen = this.open;
120      if (!isOpen) {
121        tip.style("opacity", 0);
122        vLine.style("opacity", 0);
123      }
124    });
125
126    const curveColor = CONFIG.COLORS.CURVE;
127    const loadColor = CONFIG.COLORS.LOAD;
128    const capColor = CONFIG.COLORS.CAP;
129    const minCapColor = CONFIG.COLORS.MIN_CAP;
130    const modColor = CONFIG.COLORS.MOD;
131    const copColor = CONFIG.COLORS.COP;
132
133    const lineCurve = d3
134      .line()
135      .x((d) => x(d.Ta))
136      .y((d) => yLeft(d.flowC))
137      .curve(d3.curveMonotoneX);
138
139    const lineLoad = d3
140      .line()
141      .x((d) => x(d.Ta))
142      .y((d) => yRight(d.loadKw))
143      .curve(d3.curveMonotoneX);
144
145    const lineCap = d3
146      .line()
147      .x((d) => x(d.Ta))
148      .y((d) => yRight(d.capKw))
149      .curve(d3.curveMonotoneX);
150
151    const lineMinCap = d3
152      .line()
153      .x((d) => x(d.Ta))
154      .y((d) => yRight(d.minCapKw))
155      .curve(d3.curveMonotoneX);
156
157    const lineCop = d3
158      .line()
159      .defined((d) => {
160        // Hide COP segments that exceed the current right-axis max (post-render domain)
161        const ymax =
162          yRight && typeof yRight.domain === "function"
163            ? yRight.domain()[1]
164            : Infinity;
165        return d.cop <= ymax;
166      })
167      .x((d) => x(d.Ta))
168      .y((d) => yRight(d.cop))
169      .curve(d3.curveMonotoneX);
170    // Scales (declared here, domains set during render)
171    let x = d3.scaleLinear();
172    let yLeft = d3.scaleLinear(); // °C
173    let yRight = d3.scaleLinear(); // kW
174
175    // Balance point (Bivalenzpunkt) holder
176    let bivalencePt = null;
177    // Axes
178    const xAxisG = gAxes
179      .append("g")
180      .attr("class", "x axis")
181      .attr("transform", `translate(0, ${innerH})`);
182    const yLeftAxisG = gAxes.append("g").attr("class", "y axis left");
183    const yRightAxisG = gAxes
184      .append("g")
185      .attr("class", "y axis right")
186      .attr("transform", `translate(${innerW}, 0)`);
187
188    // Labels
189    g.append("text")
190      .attr("class", "axis-title")
191      .attr("x", innerW / 2)
192      .attr("y", innerH + 34)
193      .attr("text-anchor", "middle")
194      .text("Außentemperatur (°C)");
195
196    g.append("text")
197      .attr("class", "axis-title")
198      .attr("transform", "rotate(-90)")
199      .attr("x", -innerH / 2)
200      .attr("y", -36)
201      .attr("text-anchor", "middle")
202      .text("Vorlauftemperatur (°C)");
203
204    g.append("text")
205      .attr("class", "axis-title")
206      .attr("transform", `translate(${innerW + 36}, ${innerH / 2}) rotate(90)`)
207      .attr("text-anchor", "middle")
208      .text(
209        config.showCOP ? "Leistung / Last (kW) / COP" : "Leistung / Last (kW)"
210      );
211
212    // Legend
213    const legendItems = [
214      { label: "Heizkurve", color: curveColor },
215      { label: "Gebäudeheizlast", color: loadColor },
216      { label: "Max. WP-Leistung", color: capColor },
217      { label: "Min. WP-Leistung", color: minCapColor },
218    ];
219    if (config.showCOP) {
220      legendItems.push({ label: "Optimaler COP", color: copColor });
221    }
222    const legend = gLegend
223      .selectAll("g")
224      .data(legendItems)
225      .enter()
226      .append("g");
227    legend
228      .append("line")
229      .attr("x1", 0)
230      .attr("y1", 0)
231      .attr("x2", 28)
232      .attr("y2", 0)
233      .attr("stroke", (d) => d.color)
234      .attr("stroke-width", 3);
235    legend
236      .append("text")
237      .attr("x", 34)
238      .attr("y", 4)
239      .attr("class", "legend-label")
240      .text((d) => d.label);
241
242    let legendX = 0;
243    legend.each(function () {
244      const item = d3.select(this);
245      item.attr("transform", `translate(${legendX}, 0)`);
246      legendX += this.getBBox().width + CONFIG.LEGEND_GAP;
247    });
248
249    // Lines groups
250    const curvePath = gLines
251      .append("path")
252      .attr("class", "curve-line")
253      .attr("stroke", curveColor)
254      .attr("fill", "none")
255      .attr("stroke-width", 2);
256    const loadPath = gLines
257      .append("path")
258      .attr("class", "load-line")
259      .attr("stroke", loadColor)
260      .attr("fill", "none")
261      .attr("stroke-width", 2);
262    const capPath = gLines
263      .append("path")
264      .attr("class", "cap-line")
265      .attr("stroke", capColor)
266      .attr("fill", "none")
267      .attr("stroke-width", 2);
268    const minCapPath = gLines
269      .append("path")
270      .attr("class", "min-cap-line")
271      .attr("stroke", minCapColor)
272      .attr("fill", "none")
273      .attr("stroke-width", 2)
274      .attr("stroke-dasharray", "5 3");
275    const copPath = gLines
276      .append("path")
277      .attr("class", "cop-line")
278      .attr("stroke", copColor)
279      .attr("fill", "none")
280      .attr("stroke-width", 2);
281
282    const bvGroup = gLines.append("g").attr("class", "bivalence-group");
283    const bvDot = bvGroup
284      .append("circle")
285      .attr("class", "bivalence-dot")
286      .attr("r", 3.5)
287      .attr("fill", CONFIG.COLORS.BV_FILL)
288      .attr("stroke", CONFIG.COLORS.BV_STROKE)
289      .attr("stroke-width", 1.5)
290      .style("opacity", 0);
291    // Gridlines (x only for readability)
292    const gridXG = gGrid.append("g").attr("class", "grid-x");
293    const gridYG = gGrid.append("g").attr("class", "grid-y");
294
295    function render(state) {
296      if (!state) return;
297      const stdOutdoorTempC = +state.stdOutdoorTempC;
298      const flowAt20C = +state.flowTempAt20C;
299      const flowAtStdC = +state.flowTempAtStdOutdoorC;
300      const designKw = +state.buildingHeatLoadAtStdKw;
301
302      // Domain: show a bit wider than configured range
303      const xMin = Math.min(CONFIG.X_EXTRA_MIN, stdOutdoorTempC - 5);
304      const xMax = CONFIG.X_MAX;
305      x = d3.scaleLinear().domain([xMin, xMax]).range([0, innerW]);
306      yLeft = d3
307        .scaleLinear()
308        .domain([CONFIG.FLOW_MIN, CONFIG.FLOW_MAX])
309        .nice()
310        .range([innerH, 0]);
311
312      // Sample points
313      const N = CONFIG.SAMPLE_POINTS;
314      const data = [];
315      let maxKw = Math.max(0.5, designKw);
316      let maxRight = Math.max(7, maxKw);
317      const heatingLimitC = state.heatingLimitC;
318      const capAt = (Ta, Tf) => {
319        try {
320          if (
321            window.HeatpumpEngine &&
322            typeof window.HeatpumpEngine.capacityAt === "function"
323          ) {
324            return Math.max(
325              0,
326              window.HeatpumpEngine.capacityAt(Ta, Tf, state.hpModel)
327            );
328          }
329        } catch (e) {}
330        return 0;
331      };
332      const minCapAt = (Ta, Tf) => {
333        try {
334          if (
335            window.HeatpumpEngine &&
336            typeof window.HeatpumpEngine.minCapacityAt === "function"
337          ) {
338            return Math.max(
339              0,
340              window.HeatpumpEngine.minCapacityAt(Ta, Tf, state.hpModel)
341            );
342          }
343        } catch (e) {}
344        return 0;
345      };
346      for (let i = 0; i < N; i++) {
347        const Ta = xMin + (i / (N - 1)) * (xMax - xMin);
348        const flowC = flowTargetCurve(
349          Ta,
350          stdOutdoorTempC,
351          flowAt20C,
352          flowAtStdC
353        );
354        const loadKw = (function () {
355          const engine = window.HeatpumpEngine;
356          const Q_design_W = Math.max(0, designKw) * 1000;
357          const Q_W = engine.computeBuildingHeatLoad_W(
358            Q_design_W,
359            Ta,
360            heatingLimitC,
361            stdOutdoorTempC
362          );
363          return Math.max(0, Q_W) / 1000;
364        })();
365        const capKw = capAt(Ta, flowC);
366        const minCapKw = minCapAt(Ta, flowC);
367        maxKw = Math.max(maxKw, loadKw, capKw, minCapKw);
368        const cop =
369          window.HeatpumpEngine &&
370          typeof window.HeatpumpEngine.estimateCOP === "function"
371            ? window.HeatpumpEngine.estimateCOP(flowC, Ta, state.hpModel)
372            : 0;
373        data.push({ Ta, flowC, loadKw, capKw, minCapKw, cop });
374      }
375      // Find crossing of load and capacity to mark Bivalenzpunkt
376      bivalencePt = null;
377      for (let i = 0; i < data.length - 1; i++) {
378        const a = data[i],
379          b = data[i + 1];
380        const fa = a.capKw - a.loadKw;
381        const fb = b.capKw - b.loadKw;
382        if (fa === 0) {
383          bivalencePt = { Ta: a.Ta, kW: a.capKw };
384          break;
385        }
386        if ((fa < 0 && fb > 0) || (fa > 0 && fb < 0)) {
387          const t = Math.abs(fa) / (Math.abs(fa) + Math.abs(fb));
388          const Ta_cross = a.Ta + t * (b.Ta - a.Ta);
389          const kW_cross = a.capKw + t * (b.capKw - a.capKw);
390          bivalencePt = { Ta: Ta_cross, kW: kW_cross };
391          break;
392        }
393      }
394      // yRight domain must accommodate both load/capacity (and bivalence y)
395      if (bivalencePt) maxKw = Math.max(maxKw, bivalencePt.kW);
396      const yRightMax = Math.max(
397        1,
398        Math.ceil(
399          Math.max(0.1, Math.max(maxKw, maxRight)) / CONFIG.RIGHT_AXIS_STEP
400        ) * CONFIG.RIGHT_AXIS_STEP
401      );
402      yRight = d3
403        .scaleLinear()
404        .domain([0, yRightMax])
405        .nice()
406        .range([innerH, 0]);
407
408      // Axes
409      const xAxis = d3
410        .axisBottom(x)
411        .ticks(CONFIG.X_TICKS)
412        .tickFormat((v) => `${v}°`);
413      const yLAxis = d3.axisLeft(yLeft).ticks(CONFIG.Y_LEFT_TICKS);
414      const yRAxis = d3.axisRight(yRight).ticks(CONFIG.Y_RIGHT_TICKS);
415
416      xAxisG.call(xAxis);
417      yLeftAxisG.call(yLAxis);
418      yRightAxisG.call(yRAxis);
419
420      // Grid
421      gridXG
422        .attr("transform", `translate(0, ${innerH})`)
423        .call(
424          d3
425            .axisBottom(x)
426            .ticks(CONFIG.X_TICKS)
427            .tickSize(-innerH)
428            .tickFormat("")
429        );
430      gridYG.call(
431        d3
432          .axisLeft(yLeft)
433          .ticks(CONFIG.Y_LEFT_TICKS)
434          .tickSize(-innerW)
435          .tickFormat("")
436      );
437
438      // Lines
439      curvePath.datum(data).attr("d", lineCurve);
440      loadPath.datum(data).attr("d", lineLoad);
441      capPath.datum(data).attr("d", lineCap);
442      minCapPath.datum(data).attr("d", lineMinCap);
443      if (config.showCOP) {
444        copPath.datum(data).attr("d", lineCop).style("opacity", 1);
445      } else {
446        copPath.style("opacity", 0);
447      }
448
449      // Draw/update bivalence point (dot only; label removed per request)
450      if (bivalencePt) {
451        bvDot
452          .attr("cx", x(bivalencePt.Ta))
453          .attr("cy", yRight(bivalencePt.kW))
454          .style("opacity", 1);
455      } else {
456        bvDot.style("opacity", 0);
457      }
458
459      // Interaction overlay sizing and handlers
460      overlayRect
461        .attr("x", 0)
462        .attr("y", 0)
463        .attr("width", innerW)
464        .attr("height", innerH);
465      vLine.attr("y2", innerH);
466
467      function fmt(v, d = 1) {
468        return Number(v).toFixed(d).replace(".", ",");
469      }
470
471      function computeAtTa(Ta) {
472        const flowC = flowTargetCurve(
473          Ta,
474          stdOutdoorTempC,
475          flowAt20C,
476          flowAtStdC
477        );
478        const loadKw = (function () {
479          const engine = window.HeatpumpEngine;
480          const Q_design_W = Math.max(0, designKw) * 1000;
481          const Q_W = engine.computeBuildingHeatLoad_W(
482            Q_design_W,
483            Ta,
484            heatingLimitC,
485            stdOutdoorTempC
486          );
487          return Math.max(0, Q_W) / 1000;
488        })();
489        let capKw = 0;
490        let minCapKw = 0;
491        try {
492          if (
493            window.HeatpumpEngine &&
494            typeof window.HeatpumpEngine.capacityAt === "function"
495          ) {
496            capKw = Math.max(
497              0,
498              window.HeatpumpEngine.capacityAt(Ta, flowC, state.hpModel)
499            );
500          }
501        } catch (e) {}
502        try {
503          if (
504            window.HeatpumpEngine &&
505            typeof window.HeatpumpEngine.minCapacityAt === "function"
506          ) {
507            minCapKw = Math.max(
508              0,
509              window.HeatpumpEngine.minCapacityAt(Ta, flowC, state.hpModel)
510            );
511          }
512        } catch (e) {}
513        const cop =
514          window.HeatpumpEngine &&
515          typeof window.HeatpumpEngine.estimateCOP === "function"
516            ? window.HeatpumpEngine.estimateCOP(flowC, Ta, state.hpModel)
517            : 0;
518        return { Ta, flowC, loadKw, capKw, minCapKw, cop };
519      }
520
521      function renderTip(e, pxt, py) {
522        const Ta = x.invert(pxt);
523        const clampedTa = Math.max(x.domain()[0], Math.min(x.domain()[1], Ta));
524        const d = computeAtTa(clampedTa);
525        vLine
526          .attr("x1", x(clampedTa))
527          .attr("x2", x(clampedTa))
528          .style("opacity", 1);
529
530        // Detect proximity to anchor dots (in pixels)
531        const dist = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
532        const pxStd = x(stdOutdoorTempC),
533          pyStd = yLeft(flowAtStdC);
534        const pxFoot = x(20),
535          pyFoot = yLeft(flowAt20C);
536        const tol = CONFIG.HOVER_TOL_PX; // px radius to count as "over the dot"
537        const isStd = dist(pxt, py, pxStd, pyStd) <= tol;
538        const isFoot = dist(pxt, py, pxFoot, pyFoot) <= tol;
539        const badges = [];
540        if (isStd) badges.push("Normaußentemperatur");
541        if (isFoot) badges.push("Fußpunkt (20°C)");
542        if (bivalencePt) {
543          const pxBv = x(bivalencePt.Ta);
544          const pyBv = yRight(bivalencePt.kW);
545          if (dist(pxt, py, pxBv, pyBv) <= tol) badges.push("Bivalenzpunkt");
546        }
547        const badgeHtml = badges.length
548          ? `<div style=\"margin-top:4px;color:#555\"><i>${badges.join(
549              " · "
550            )}</i></div>`
551          : "";
552
553        const html = `
554          <div class="tip-row"><div><b>Außentemperatur:</b></div><div class="tip-val">${fmt(
555            d.Ta,
556            1
557          )}°C</div></div>
558          <div class="tip-row"><div><span style=\"color:${curveColor}\"><b>Vorlauftemperatur:</b></span></div><div class="tip-val">${fmt(
559          d.flowC,
560          1
561        )}°C</div></div>
562          <div class="tip-row"><div><span style=\"color:${loadColor}\"><b>Gebäudeheizlast:</b></span></div><div class="tip-val">${fmt(
563          d.loadKw,
564          2
565        )} kW</div></div>
566                  <div class="tip-row"><div><span style=\"color:${capColor}\"><b>Max. WP-Leistung:</b></span></div><div class="tip-val">${fmt(
567          d.capKw,
568          2
569        )} kW</div></div>
570          <div class="tip-row"><div><span style=\"color:${minCapColor}\"><b>Min. WP-Leistung:</b></span></div><div class="tip-val">${fmt(
571          d.minCapKw,
572          2
573        )} kW</div></div>
574          <div class="tip-row"><div><span style=\"color:${modColor}\"><b>Rel. Wärmebedarf:</b></span></div><div class="tip-val">${fmt(
575          d.capKw > 0 ? (d.loadKw / d.capKw) * 100 : 0,
576          0
577        )} %</div></div>
578          ${
579            config.showCOP
580              ? `<div class="tip-row"><div><span style=\"color:${copColor}\"><b>Optimaler COP:</b></span></div><div class="tip-val">${fmt(
581                  d.cop,
582                  2
583                )}</div></div>`
584              : ""
585          }
586          ${badgeHtml}
587        `;
588        tip.html(html).style("opacity", 1);
589        const pad = 12;
590        const vw = Math.max(
591          document.documentElement.clientWidth || 0,
592          window.innerWidth || 0
593        );
594        const vh = Math.max(
595          document.documentElement.clientHeight || 0,
596          window.innerHeight || 0
597        );
598        // Position inside the SVG container (absolute positioning relative to page scroll offset)
599        const svgBox = svg.node().getBoundingClientRect();
600        let tx = svgBox.left + window.scrollX + margin.left + pxt + pad;
601        let ty = svgBox.top + window.scrollY + margin.top + py + pad;
602        // Keep inside SVG bounds
603        const bbox = tip.node().getBoundingClientRect();
604        const maxX =
605          svgBox.left +
606          window.scrollX +
607          margin.left +
608          innerW -
609          bbox.width -
610          pad;
611        const maxY =
612          svgBox.top + window.scrollY + margin.top + innerH - bbox.height - pad;
613        const minX = svgBox.left + window.scrollX + margin.left + pad;
614        const minY = svgBox.top + window.scrollY + margin.top + pad;
615        tx = Math.max(minX, Math.min(maxX, tx));
616        ty = Math.max(minY, Math.min(maxY, ty));
617        tip.style("left", `${tx}px`).style("top", `${ty}px`);
618      }
619
620      overlayRect
621        .on("mouseenter", function (e) {
622          vLine.style("opacity", 1);
623          tip.style("opacity", 1);
624        })
625        .on("mousemove", function (e) {
626          const m = d3.pointer(e, this);
627          renderTip(e, m[0], m[1]);
628        })
629        .on("mouseleave", function () {
630          // When leaving the plot area, snap to the currently configured ambient temperature
631          try {
632            const TaCur = +state.ambientTempC;
633            const xt = x(TaCur);
634            const yt = yLeft(
635              flowTargetCurve(TaCur, stdOutdoorTempC, flowAt20C, flowAtStdC)
636            );
637            const svgBox = svg.node().getBoundingClientRect();
638            // Render tip with no DOM event; provide fallback positioning near the reference line
639            renderTip(
640              null,
641              x(TaCur),
642              yLeft(
643                flowTargetCurve(TaCur, stdOutdoorTempC, flowAt20C, flowAtStdC)
644              )
645            );
646            // Place tooltip near the line within the SVG bounds (absolute, scroll-aware)
647            const pad = CONFIG.TIP_PAD_PX;
648            let tx =
649              svgBox.left +
650              window.scrollX +
651              margin.left +
652              Math.max(0, Math.min(innerW, xt)) +
653              pad;
654            let ty =
655              svgBox.top +
656              window.scrollY +
657              margin.top +
658              Math.max(0, Math.min(innerH, yt)) +
659              pad;
660            const bbox = tip.node().getBoundingClientRect();
661            const maxX =
662              svgBox.left +
663              window.scrollX +
664              margin.left +
665              innerW -
666              bbox.width -
667              pad;
668            const maxY =
669              svgBox.top +
670              window.scrollY +
671              margin.top +
672              innerH -
673              bbox.height -
674              pad;
675            const minX = svgBox.left + window.scrollX + margin.left + pad;
676            const minY = svgBox.top + window.scrollY + margin.top + pad;
677            tx = Math.max(minX, Math.min(maxX, tx));
678            ty = Math.max(minY, Math.min(maxY, ty));
679            tip
680              .style("left", `${tx}px`)
681              .style("top", `${ty}px`)
682              .style("opacity", 1);
683            vLine.style("opacity", 1);
684          } catch (e) {
685            vLine.style("opacity", 0);
686            tip.style("opacity", 0);
687          }
688        });
689
690      // Mark anchor points
691      const anchors = [
692        { Ta: stdOutdoorTempC, flowC: flowAtStdC },
693        { Ta: 20, flowC: flowAt20C },
694      ];
695      const anchorSel = gLines
696        .selectAll("circle.anchor")
697        .data(anchors, (d) => d.Ta);
698      anchorSel
699        .enter()
700        .append("circle")
701        .attr("class", "anchor")
702        .attr("r", 3.5)
703        .attr("fill", curveColor)
704        .merge(anchorSel)
705        .attr("cx", (d) => x(d.Ta))
706        .attr("cy", (d) => yLeft(d.flowC));
707      anchorSel.exit().remove();
708
709      // Show focus line and tooltip only when panel is open
710      if (panel.property("open")) {
711        try {
712          const TaCur = +state.ambientTempC;
713          renderTip(
714            null,
715            x(TaCur),
716            yLeft(
717              flowTargetCurve(TaCur, stdOutdoorTempC, flowAt20C, flowAtStdC)
718            )
719          );
720          vLine.style("opacity", 1);
721          tip.style("opacity", 1);
722        } catch (e) {
723          // ignore
724        }
725      } else {
726        vLine.style("opacity", 0);
727        tip.style("opacity", 0);
728      }
729    }
730
731    // Styles moved to assets/css/heatpump-simulator.css
732
733    // Render once in collapsed state too so it has content when opened
734    // Expose minimal API; also publish hover state so parent can query
735    return { render };
736  }
737
738  window.HeatpumpHeatingCurve = { initPanel };
739})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.