///|
/// Symmetric H0/H1/H2 bottleneck distances for one bounded batch study.
/// None means no finite match because alive interval counts differ.
/// H2 is None when either case did not analyze that dimension.
pub struct BatchDistanceMatrix {
  names : Array[String]
  kinds : Array[String]
  cutoffs : Array[Double]
  h0 : Array[Array[Double?]]
  h1 : Array[Array[Double?]]
  h2 : Array[Array[Double?]]
  h2_analyzed : Array[Bool]
  conditions_aligned : Array[Array[Bool]]
} derive(@debug.Debug)

///|
pub extend BatchDistanceMatrix with @debug.Debug::{to_repr}

///|
/// Compute every unordered pair once. Agreement on kind and cutoff is only
/// a necessary metadata check; scale units and data meaning remain user choices.
pub fn batch_distance_matrix(
  plan : BatchPlan,
  analyses : Array[Analysis],
) -> BatchDistanceMatrix raise TopologyError {
  let n = plan.cases.length()
  if n < 1 || n > 8 || analyses.length() != n {
    raise TopologyError(
      "batch matrix requires 1..8 matching cases and analyses",
    )
  }
  let seen : Map[String, Bool] = Map([])
  for case in plan.cases {
    batch_name(case.name)
    if seen.contains(case.name) {
      raise TopologyError("duplicate batch matrix case name: " + case.name)
    }
    seen[case.name] = true
  }
  let names = plan.cases.map(fn(case) { case.name })
  let kinds = analyses.map(fn(result) { result.filtration.kind })
  let cutoffs = analyses.map(fn(result) { result.filtration.cutoff })
  let h0 = Array::makei(n, fn(_) { Array::make(n, None) })
  let h1 = Array::makei(n, fn(_) { Array::make(n, None) })
  let h2 = Array::makei(n, fn(_) { Array::make(n, None) })
  let h2_analyzed = analyses.map(fn(result) { result.homology_dimension == 2 })
  let conditions_aligned = Array::makei(n, fn(_) { Array::make(n, false) })
  for i = 0; i < n; i = i + 1 {
    h0[i][i] = Some(0.0)
    h1[i][i] = Some(0.0)
    if h2_analyzed[i] {
      h2[i][i] = Some(0.0)
    }
    conditions_aligned[i][i] = true
    for j = i + 1; j < n; j = j + 1 {
      let d0 = compare_analyses(analyses[i], analyses[j], dimension=0)
      let d1 = compare_analyses(analyses[i], analyses[j], dimension=1)
      h0[i][j] = d0
      h0[j][i] = d0
      h1[i][j] = d1
      h1[j][i] = d1
      if h2_analyzed[i] && h2_analyzed[j] {
        let d2 = compare_analyses(analyses[i], analyses[j], dimension=2)
        h2[i][j] = d2
        h2[j][i] = d2
      }
      let aligned = kinds[i] == kinds[j] && cutoffs[i] == cutoffs[j]
      conditions_aligned[i][j] = aligned
      conditions_aligned[j][i] = aligned
    }
  }
  { names, kinds, cutoffs, h0, h1, h2, h2_analyzed, conditions_aligned, }
}

///|
fn matrix_value(value : Double?) -> Json {
  match value {
    Some(number) => number.to_json()
    None => Json::null()
  }
}

///|
/// Portable matrix schema; null is deliberately distinct from a zero distance.
pub fn batch_matrix_json(matrix : BatchDistanceMatrix) -> Json {
  let h0 : Array[Array[Json]] = []
  let h1 : Array[Array[Json]] = []
  let h2 : Array[Array[Json]] = []
  for row in matrix.h0 {
    h0.push(row.map(matrix_value))
  }
  for row in matrix.h1 {
    h1.push(row.map(matrix_value))
  }
  for row in matrix.h2 {
    h2.push(row.map(matrix_value))
  }
  {
    "schema_version": 1,
    "metric": "bottleneck",
    "names": matrix.names,
    "kinds": matrix.kinds,
    "cutoffs": matrix.cutoffs,
    "h0": h0,
    "h1": h1,
    "h2": h2,
    "h2_analyzed": matrix.h2_analyzed,
    "conditions_aligned": matrix.conditions_aligned,
    "null_meaning": "H0/H1: unequal counts of classes alive at cutoff; H2: either not analyzed or unequal censored counts; inspect h2_analyzed to distinguish",
    "comparison_note": "matching kind and cutoff is necessary but not sufficient; verify units, grayscale transforms and data meaning before interpreting distances",
  }
}

///|
/// Square CSV with blanks for null distances. Batch names are CSV-safe ASCII.
pub fn batch_matrix_csv(
  matrix : BatchDistanceMatrix,
  dimension : Int,
) -> String raise TopologyError {
  if dimension < 0 || dimension > 2 {
    raise TopologyError("matrix dimension must be 0, 1 or 2")
  }
  let rows = if dimension == 0 {
    matrix.h0
  } else if dimension == 1 {
    matrix.h1
  } else {
    matrix.h2
  }
  let mut csv = "case"
  for name in matrix.names {
    csv += "," + name
  }
  csv += "\n"
  for i, name in matrix.names {
    csv += name
    for value in rows[i] {
      let field = match value {
        Some(number) => number.to_string()
        None => ""
      }
      csv += "," + field
    }
    csv += "\n"
  }
  csv
}

///|
fn matrix_max(rows : Array[Array[Double?]]) -> Double {
  let mut greatest = 0.0
  for row in rows {
    for value in row {
      if value is Some(number) && number > greatest {
        greatest = number
      }
    }
  }
  greatest
}

///|
fn matrix_table(
  matrix : BatchDistanceMatrix,
  rows : Array[Array[Double?]],
  dimension : Int,
) -> String {
  let greatest = matrix_max(rows)
  let mut html = "

H\{dimension} 瓶颈距离

" for name in matrix.names { html += "" } html += "" for i, name in matrix.names { html += "" for j = 0; j < matrix.names.length(); j = j + 1 { let warning = if matrix.conditions_aligned[i][j] { "" } else { " caution" } let content = match rows[i][j] { None => if dimension == 2 && (!matrix.h2_analyzed[i] || !matrix.h2_analyzed[j]) { "—" } else { "—" } Some(value) => { let opacity = if greatest == 0.0 { 0.12 } else { 0.12 + 0.65 * value / greatest } "\{value}" } } html += "" } html += "" } html + "
案例\{name}
\{name}\{content}
" } ///| fn matrix_html_escape(value : String) -> String { value .replace_all(old="&", new="&") .replace_all(old="<", new="<") .replace_all(old=">", new=">") .replace_all(old="\"", new=""") } ///| /// Static offline heatmap with no JavaScript or network dependency. pub fn batch_matrix_html(matrix : BatchDistanceMatrix) -> String { let mut html = #|MoonTopoLens · 批量距离热图

MoonTopoLens · 批量距离热图

同一批案例的 H0/H1 瓶颈距离;每个无序案例对只计算一次。点击行标题查看案例报告。

输入条件

for i, name in matrix.names { html += "" } html += "
案例复形类型截止值
\{name}\{matrix_html_escape(matrix.kinds[i])}\{matrix.cutoffs[i]}

橙框表示两侧复形类型或截止值不同。相同类型和截止值也不保证尺度单位、灰度变换和数据含义可比。

破折号表示没有有限匹配;H2 也可能是未计算。它不是距离 0。颜色只按本维度的有限距离归一化,用于浏览,数值以 CSV/JSON 为准。

查看 H0 分组树 · 查看 H1 分组树" if matrix.h2_analyzed.contains(true) { html += " · 查看 H2 分组树" } html += "(仅用有限且输入条件一致的距离)

" html += matrix_table(matrix, matrix.h0, 0) html += matrix_table(matrix, matrix.h1, 1) if matrix.h2_analyzed.contains(true) { html += "

H2 空格表示至少一侧未计算 H2,或截止时存活类数量不同;请结合 JSON 中的 h2_analyzed 判断。

" html += matrix_table(matrix, matrix.h2, 2) } html + "
" }