///|
/// Symmetric H0/H1 bottleneck distances for one bounded batch study.
/// None means no finite match because alive interval counts differ.
pub struct BatchDistanceMatrix {
  names : Array[String]
  kinds : Array[String]
  cutoffs : Array[Double]
  h0 : Array[Array[Double?]]
  h1 : Array[Array[Double?]]
  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 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)
    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
      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, 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]] = []
  for row in matrix.h0 {
    h0.push(row.map(matrix_value))
  }
  for row in matrix.h1 {
    h1.push(row.map(matrix_value))
  }
  {
    "schema_version": 1,
    "metric": "bottleneck",
    "names": matrix.names,
    "kinds": matrix.kinds,
    "cutoffs": matrix.cutoffs,
    "h0": h0,
    "h1": h1,
    "conditions_aligned": matrix.conditions_aligned,
    "null_meaning": "unequal counts of classes alive at cutoff; no finite match",
    "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 != 1 {
    raise TopologyError("matrix dimension must be 0 or 1")
  }
  let rows = if dimension == 0 { matrix.h0 } else { matrix.h1 }
  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 => "—" 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]}

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

破折号表示截止时存活类数量不同,因而没有有限匹配;它不是距离 0。颜色只按本维度的有限距离归一化,用于浏览,数值以 CSV/JSON 为准。

" html += matrix_table(matrix, matrix.h0, 0) html += matrix_table(matrix, matrix.h1, 1) html + "
" }