All files / src/pages/flowsheet-page/pinch-analysis/hen-generation/utils henLayoutUtils.ts

68.06% Statements 179/263
55.81% Branches 72/129
60% Functions 21/35
70.74% Lines 162/229

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525          37x 37x 37x     37x     37x 37x                   491x 491x 491x 491x         143x                                       469x 469x 469x   469x 469x   469x 301x 301x 168x 146x 146x     22x       22x 22x 22x 22x 20x       447x 447x 447x 32x 32x 2x 2x 2x 2x 2x 2x   30x 30x 30x 30x 30x 30x 30x           447x 447x 447x   441x                                   48x 48x 48x                                                                               167x   167x 167x 330x     167x 992x 163x   167x   167x 326x 163x 163x         167x 4x     4x 4x   4x 1x 1x 1x   3x 4x 3x     4x 4x 4x   4x 4x 4x     4x   4x 4x 4x 4x 4x 4x 3x 3x     4x 4x 4x         167x     167x 167x                                                                                                                                                             48x 48x 48x 48x       48x 48x         48x                                                     48x 48x 48x 48x 48x 48x 48x   48x 48x 48x 48x 48x 48x 48x         48x   48x 48x   48x                                                                                                     129x 129x     129x                           129x 129x 129x 129x 129x   129x       129x         129x 129x 129x 96x 96x 33x 6x 6x     129x 48x 48x 48x   81x 81x 81x       129x 27x         27x 27x 27x         129x 129x 129x 129x           37x 653x                       653x 653x 651x 651x  
import { bisectCenter, range } from "d3-array";
import { scaleLinear, scalePoint } from "d3-scale";
import { Bounds } from "../henTypes";
 
// constants for HENdiagram
export const MARGIN_X = 100; // The left/right margins for HEN diagram svg.
export const PINCH_GAP = 50; // small gap between stream line and pinch line.
export const MIN_SIDE_REGION = 80; // the minimum width a region should be (i.e. smallest "left" region is 80, so that stream doesn't look too cramped.)
 
// stream block height (i.e. the height of a stream row).
export const STREAM_BLOCK_HEIGHT = 60; // height of a stream block (the rectangle encapsulating the stream)
 
// collision sizing for the  unitop circles so they dont overlap
export const UNITOP_R = 16; // this is set in the unitopcircles component as well. maybe just pass this as props to unitop circle instead? then we cpould have zoom controls?
export const MIN_NODE_GAP = UNITOP_R * 2 + 6; // minimum gap there should be for drawing circles.
 
/**
 * Figures out whether a stream crsses the pinch temperature or not.
 * @param t1 supply temp
 * @param t2 target temp
 * @param pinch pinch temp
 * @returns "true" if no pinch temp calculated, or if stream crosses pinch line, and "false" otherwise.
 */
export function streamCrossesPinch(t1: number, t2: number, pinch?: number) {
  Iif (pinch == null) return true; // if pinch line isnt calculated, all streams should just use the same width (svgWidth) so return true.
  const lo = Math.min(t1, t2);
  const hi = Math.max(t1, t2);
  return lo < pinch && hi > pinch;
}
 
// get width of Bounds object
export function widthOf(b?: Bounds) {
  return !b ? 0 : Math.max(0, b.right - b.left);
}
 
/**
 * Slices a row of bounds into a LEFT/MIDDLE/RIGHT placement regions for nodes.
 * Basically a SUB region for HENNODES rather than stream/segments.
 * - LEFT/RIGHT: clamps to one side of the pinch, wrt margins and PINCH_GAP.
 * - MIDDLE: make a centred window around pinchX (approx 35% of the row, min 180px). If too tiny, fallback to full row.
 * @param row bounds (left/right/full?)
 * @param pinchX x position of pinch line
 * @param svgWidth width of the full diagram svg
 * @param side stream/segment should be in left/right/full?
 * @returns
 */
export function getNodePlacementRegion(
  row: Bounds,
  pinchX: number,
  svgWidth: number,
  side: "LEFT" | "MIDDLE" | "RIGHT"
): Bounds {
  const SAFE = PINCH_GAP;
  const fullLeft = MARGIN_X;
  const fullRight = (svgWidth || 0) - MARGIN_X;
 
  let left = row.left;
  let right = row.right;
 
  if (side === "LEFT") {
    left = Math.max(row.left, fullLeft);
    right = Math.min(row.right, pinchX - SAFE);
  } else if (side === "RIGHT") {
    left = Math.max(row.left, pinchX + SAFE);
    right = Math.min(row.right, fullRight);
  } else {
    // MIDDLE: create a centered window near the pinch
    const targetWidth = Math.min(
      Math.max(180, widthOf(row) * 0.35),
      widthOf(row)
    );
    const half = targetWidth / 2;
    const l = Math.max(row.left, pinchX - half);
    const r = Math.min(row.right, pinchX + half);
    if (r - l < 20) return row; // too narrow to be useful; fall back to full row
    return { left: l, right: r };
  }
 
  // If the chosen side is too narrow, grow it within the row to at least MIN_SIDE_REGION.
  const want = MIN_SIDE_REGION;
  const w = Math.max(0, right - left);
  if (w < want) {
    const deficit = want - w;
    if (side === "RIGHT") {
      const addRight = Math.min(deficit, row.right - right);
      right += addRight;
      const still = want - (right - left);
      if (still > 0) {
        const addLeft = Math.min(still, left - row.left);
        left -= addLeft;
      }
    } else if (side === "LEFT") {
      const addLeft = Math.min(deficit, left - row.left);
      left -= addLeft;
      const still = want - (right - left);
      if (still > 0) {
        const addRight = Math.min(still, row.right - right);
        right += addRight;
      }
    }
  }
 
  // final clamping
  left = Math.max(row.left, Math.min(left, row.right));
  right = Math.max(row.left, Math.min(right, row.right));
  if (right - left <= 0) return row;
 
  return { left, right };
}
 
/**
 * Ensures a placement region has enough width to fit (hxNodes (reserved placement) + nonHxNodes (to be placed)) with a minimum gap.
 * expands the region symmetrically within the row when needed; falls back to full row if still too small.
 */
// Grow region to fit HX + nonHx. If protectTargetEdge is true (and isHot provided),
// grow only AWAY from the target edge so the target-most pixels stay free.
export function ensureRegionCapacityForHx(
  region: Bounds,
  row: Bounds,
  hxCount: number,
  nonHxCount: number,
  minGap: number,
  protectTargetEdge?: boolean, // optional: keep target edge intact
  isHot?: boolean              // required if protectTargetEdge is true
): Bounds {
  const need = Math.max(0, (hxCount + nonHxCount - 1) * minGap);
  const have = Math.max(0, region.right - region.left);
  if (have >= need) return region;
 
  const deficit = need - have;
 
  Iif (protectTargetEdge && isHot != null) {
    // Hot -> target edge is RIGHT -> grow leftwards
    // Cold -> target edge is LEFT  -> grow rightwards
    if (isHot) {
      const left = Math.max(row.left, region.left - deficit);
      return { left, right: region.right };
    } else {
      const right = Math.min(row.right, region.right + deficit);
      return { left: region.left, right };
    }
  }
 
  // symmetric fallback
  const half = deficit / 2;
  const left = Math.max(row.left, region.left - half);
  const right = Math.min(row.right, region.right + half);
  Iif (right - left >= need) return { left, right };
  return { left: row.left, right: row.right };
}
 
 
// Choose column centers for non-HX nodes using the global grid.
// - Constrains to the given region
// - Keeps at least minGap from reserved HX Xs
// - Avoids the target edge by minGap
// - Picks from the target edge inward so nonHx stay "near target"
// Select K global columns inside a region, biased to target edge, and spaced
// at least minGap from reserved HX positions.
export function pickColumnsForNonHxNodes(
  count: number,
  region: { left: number; right: number },
  isHot: boolean,
  globalColumns: number[],
  reserved: number[],
  minGap: number
): number[] {
  Iif (count <= 0) return [];
 
  const clamp = (x: number) => Math.max(region.left, Math.min(region.right, x));
  const rs = (reserved || []).filter(Number.isFinite).sort((a,b)=>a-b);
  const farEnough = (x: number, arr: number[]) => arr.every(y => Math.abs(x - y) >= minGap);
 
  // take columns inside region (target-edge inward)
  const colsInRegion = (globalColumns || [])
    .filter(x => x >= region.left && x <= region.right)
    .sort((a,b) => (isHot ? b - a : a - b));
 
  const chosen: number[] = [];
 
  for (const col of colsInRegion) {
    if (chosen.length >= count) break;
    if (farEnough(col, rs) && farEnough(col, chosen)) {
      chosen.push(col);
    }
  }
 
  // if we still need more (or had zero columns), fill with even spacing
  if (chosen.length < count) {
    const missing = count - chosen.length;
 
    // guard band away from target edge by minGap and from reserved by minGap
    let bandLeft  = region.left;
    let bandRight = region.right;
 
    if (isHot) {
      const edgeCap = region.right - minGap;
      const resCap  = rs.length ? Math.min(...rs.map(x => x - minGap)) : +Infinity;
      bandRight = Math.min(edgeCap, resCap);
    } else {
      const edgeCap = region.left + minGap;
      const resCap  = rs.length ? Math.max(...rs.map(x => x + minGap)) : -Infinity;
      bandLeft = Math.max(edgeCap, resCap);
    }
 
    bandLeft  = clamp(bandLeft);
    bandRight = clamp(bandRight);
    if (bandRight - bandLeft <= 0) { bandLeft = region.left; bandRight = region.right; }
 
    const step = (bandRight - bandLeft) / (missing + 1);
    const seeds = Array.from({ length: missing }, (_, i) =>
      isHot ? bandRight - (i + 1) * step : bandLeft + (i + 1) * step
    );
 
    for (const s of seeds) {
      // Single-pass nudge inward from target if a reserved is too close
      let x = clamp(s);
      const j = bisectCenter(rs, x);
      const L = j > 0 ? rs[j - 1] : -Infinity;
      const R = j < rs.length ? rs[j] : Infinity;
      const gap = Math.min(Math.abs(x - L), Math.abs(R - x));
      if (Number.isFinite(gap) && gap < minGap) {
        const delta = (minGap - gap);
        x = clamp(isHot ? x - delta : x + delta);
      }
      // If still colliding with a previously placed nonHxNode, push one more minGap inward
      Iif (!farEnough(x, chosen)) x = clamp(isHot ? x - minGap : x + minGap);
      chosen.push(x);
      if (chosen.length >= count) break;
    }
  }
 
  // Keep a stable visual order from target edge outward
  chosen.sort((a,b) => (isHot ? b - a : a - b));
    
  // Hard guarantee: exactly `count`
  Iif (chosen.length > count) chosen.length = count;
  return chosen;
}
 
 
 
// Evenly space nonHx nodes in collapsed mode, with:
// - a guard gap to the target edge (minGap),
// - at least minGap clearance from ALL reserved HX Xs,
// - no while loops.
// If the derived interior band collapses, falls back to even spacing in the raw region.
export function placeNonHxNodesEvenTargetGuarded(
  count: number,
  region: Bounds,
  isHot: boolean,
  minGap: number,
  reserved: number[]
): number[] {
  const W = Math.max(0, region.right - region.left);
  Iif (count <= 0 || W <= 0) return [];
 
  const clamp = (x: number) => Math.max(region.left, Math.min(region.right, x));
 
  // Sort reserved once (not strictly necessary for this method, but cheap)
  const rs = [...reserved].filter(Number.isFinite).sort((a, b) => a - b);
 
  // Build an interior band that is:
  // - minGap away from the target edge
  // - minGap away from ALL reserved Xs
  // For HOT (target at right): x must be <= (reserved_i - minGap) for all i and <= (region.right - minGap)
  // For COLD (target at left): x must be >= (reserved_i + minGap) for all i and >= (region.left  + minGap)
  let interiorLeft  = region.left;
  let interiorRight = region.right;
 
  if (isHot) {
    const capFromEdge = region.right - minGap;
    const capFromReserved = rs.length ? Math.min(...rs.map(x => x - minGap)) : +Infinity;
    interiorRight = Math.min(capFromEdge, capFromReserved);
    // keep left as region.left (only allow trimming from the target edge in)
  } else {
    const capFromEdge = region.left + minGap;
    const capFromReserved = rs.length ? Math.max(...rs.map(x => x + minGap)) : -Infinity;
    interiorLeft = Math.max(capFromEdge, capFromReserved);
    // keep right as region.right
  }
 
  // clamp interior band to region
  interiorLeft  = clamp(interiorLeft);
  interiorRight = clamp(interiorRight);
 
  const usableW = Math.max(0, interiorRight - interiorLeft);
 
  // choose the band to space within: interior if valid, otherwise original region.
  const bandLeft  = usableW > 0 ? interiorLeft  : region.left;
  const bandRight = usableW > 0 ? interiorRight : region.right;
  const bandW     = Math.max(0, bandRight - bandLeft);
 
  // even spacing inside the chosen band (no strict min gap between nonHx nodes by design)
  const step = bandW / (count + 1);
 
  const xs = Array.from({ length: count }, (_, i) => {
    return isHot
      // place from the interior toward the edge directionally (but still inside the band)
      ? bandRight - (i + 1) * step
      : bandLeft  + (i + 1) * step;
  });
 
  // Final clamp (safe-guard)
  return xs.map(clamp);
}
 
 
 
// place nonHX nodes (i.e. Heater/Cooler) after or before HX nodes if on the same line (depending on isHot).
export function placeNonHxXs(
  count: number,
  region: Bounds,
  isHot: boolean,
  layout: "CENTER" | "EVEN"
): number[] {
  const { left, right } = region,
    w = Math.max(0, right - left);
  Iif (count <= 0 || w === 0) return [];
  Iif (layout === "CENTER") {
    const cx = (left + right) / 2;
    return Array.from({ length: count }, () => cx);
  }
  const domain = isHot ? range(count) : range(count).reverse();
  const sp = scalePoint<number>()
    .domain(domain)
    .range([left, right])
    .align(0.5)
    .padding(0);
  return domain.map((i) => sp(i)!);
}
 
export function placeNonHxXsEdgeAnchored(
  count: number,
  region: { left: number; right: number },
  isHot: boolean,
  minGap: number
): number[] {
  const xs: number[] = [];
  const targetEdge = isHot ? region.right : region.left;
  for (let i = 0; i < count; i++) {
    const x = isHot ? (targetEdge - minGap) : (targetEdge + minGap);
    // clamp to region just in case
    xs.push(Math.max(region.left, Math.min(region.right, x)));
  }
  return xs;
}
 
 
// make sure nonhxnodes dont overlap with hxnodes.
export function spreadAwayFromReserved(
  xs: number[],
  region: Bounds,
  reserved: number[],
  minGap: number
): number[] {
  const out: number[] = [];
  const all = [...reserved].sort((a, b) => a - b); // keep sorted
  const clamp = (x: number) => Math.max(region.left, Math.min(region.right, x));
  for (const x0 of xs) {
    let x = clamp(x0);
    let tries = 0;
    while (tries < 60) {
      // locate nearest neighbor in
      const i = bisectCenter(all, x);
      const left = i > 0 ? all[i - 1] : -Infinity;
      const right = i < all.length ? all[i] : Infinity;
      const dxL = Math.abs(x - left);
      const dxR = Math.abs(right - x);
      const nearest = dxL <= dxR ? left : right;
      if (Math.abs(nearest - x) >= minGap) break;
      const dir = x >= nearest ? 1 : -1;
      x = clamp(x + dir * minGap);
      tries++;
    }
    out.push(x);
    // insert while keeping array sorted
    const j = bisectCenter(all, x);
    all.splice(j, 0, x);
  }
  return out;
}
 
// Only nudges inward (away from the target edge) to avoid HX collisions.
export function spreadAwayFromReservedOneSided(
  xs: number[],
  region: Bounds,
  reserved: number[],
  minGap: number,
  isHot: boolean
): number[] {
  const clamp = (x: number) => Math.max(region.left, Math.min(region.right, x));
  const targetEdge = isHot ? region.right : region.left;
 
  // Resolve conflicts closest to the target first
  const rs = reserved.slice().sort(
    (a, b) => Math.abs(a - targetEdge) - Math.abs(b - targetEdge)
  );
 
  return xs.map((x0) => {
    let x = clamp(x0);
    for (const r of rs) {
      Iif (Math.abs(x - r) < minGap) {
        // always move inward (away from target)
        x = clamp(isHot ? r - minGap : r + minGap);
      }
    }
    return x;
  });
}
 
 
/**
 * Clamps a segment’s [left,right] so it stays on the correct side of the pinch,
 * respects a minimum segment width, and advances in stream direction (via startX).
 * @param params
 * @returns
 */
export function clampSegmentToPinchSide(params: {
  isHot: boolean;
  tSupply: number;
  tTarget: number;
  pinch?: number;
  pinchX: number;
  parent: Bounds;
  left: number;
  right: number;
  MIN_SEG_W: number;
  startX: number;
}): Bounds {
  const { isHot, tSupply, tTarget, pinch, pinchX, parent, MIN_SEG_W, startX } =
    params;
  let { left, right } = params;
 
  // No pinch: just tile in stream direction with min width.
  Iif (pinch == null || !Number.isFinite(pinch)) {
    if (isHot) {
      left = Math.max(parent.left, startX);
      right = Math.max(left + MIN_SEG_W, right);
      right = Math.min(parent.right, right);
    } else {
      right = Math.min(parent.right, startX);
      left = Math.min(right - MIN_SEG_W, left);
      left = Math.max(parent.left, left);
    }
    return { left, right };
  }
 
  // With pinch: determine allowed side
  const lo = Math.min(tSupply, tTarget);
  const hi = Math.max(tSupply, tTarget);
  const crosses = lo <= pinch && hi >= pinch;
  const above = tSupply > pinch && tTarget > pinch;
  const below = tSupply < pinch && tTarget < pinch;
 
  const leftMax = Math.max(
    parent.left,
    Math.min(parent.right, params.pinchX - PINCH_GAP)
  );
  const rightMin = Math.max(
    parent.left,
    Math.min(parent.right, params.pinchX + PINCH_GAP)
  );
 
  let allowedLeft = parent.left,
    allowedRight = parent.right;
  if (above) {
    allowedLeft = parent.left;
    allowedRight = leftMax;
  } else if (below) {
    allowedLeft = rightMin;
    allowedRight = parent.right;
  }
 
  if (isHot) {
    left = Math.max(allowedLeft, startX);
    right = Math.max(right, left + MIN_SEG_W);
    right = Math.min(allowedRight, right);
  } else {
    right = Math.min(allowedRight, startX);
    left = Math.min(left, right - MIN_SEG_W);
    left = Math.max(allowedLeft, left);
  }
 
  // If the segment crosses the pinch, ensure it protrudes slightly across the gap.
  if (crosses) {
    Iif (isHot) {
      right = Math.max(right, params.pinchX + 2);
      right = Math.max(right, left + MIN_SEG_W);
      right = Math.min(parent.right, right);
    } else {
      left = Math.min(left, params.pinchX - 2);
      left = Math.min(left, right - MIN_SEG_W);
      left = Math.max(parent.left, left);
    }
  }
 
  // Final clamping and order
  left = Math.max(allowedLeft, Math.min(left, allowedRight));
  right = Math.max(allowedLeft, Math.min(right, allowedRight));
  Iif (right < left) right = left;
  return { left, right };
}
 
/**
 * Clamp an absolute X into the drawable canvas width to keep x within [MARGIN_X, width - MARGIN_X].
 */
const xCanvas = (width: number) =>
  scaleLinear()
    .domain([0, 1])
    .range([MARGIN_X, (width || 0) - MARGIN_X])
    .clamp(true);
 
/**
 * Normalises an x-position that may be a fraction of the canvas or an absolute pixel.
 *  - (0,1] -> treat as fraction of drawable width (between margins)
 *  - otherwise -> clamp as absolute pixel
 * allows stored positions to be resolution-independent when saved as fractions.
 */
export function toCanvasX(raw: number, width: number) {
  const s = xCanvas(width);
  if (raw > 0 && raw <= 1) return s(raw);//fraction
  const [L, R] = s.range() as [number, number];
  return Math.max(L, Math.min(R, raw));
}