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.