///|
/// Escape arbitrary text for JSON string values.
pub fn escape_json(text : String) -> String {
text
.replace_all(old="\\", new="\\\\")
.replace_all(old="\"", new="\\\"")
.replace_all(old="\n", new="\\n")
.replace_all(old="\r", new="\\r")
.replace_all(old="\t", new="\\t")
}
///|
/// Escape arbitrary text for XML and SVG text nodes.
pub fn escape_xml(text : String) -> String {
text
.replace_all(old="&", new="&")
.replace_all(old="<", new="<")
.replace_all(old=">", new=">")
.replace_all(old="\"", new=""")
.replace_all(old="'", new="'")
}
///|
fn bool_json(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
fn json_string(value : String) -> String {
"\"\{escape_json(value)}\""
}
///|
fn join_json_strings(values : Array[String]) -> String {
let mut text = "["
for i = 0; i < values.length(); i = i + 1 {
if i > 0 {
text = text + ","
}
text = text + json_string(values[i])
}
text + "]"
}
///|
fn join_json_ints(values : Array[Int]) -> String {
let mut text = "["
for i = 0; i < values.length(); i = i + 1 {
if i > 0 {
text = text + ","
}
text = text + values[i].to_string()
}
text + "]"
}
///|
fn severity_icon(severity : Severity) -> String {
match severity {
Info => "ℹ️"
Warning => "⚠️"
Error => "❌"
}
}
///|
/// Render validation diagnostics as a compact Markdown document.
pub fn validation_markdown(
plan : EvacuationPlan,
diagnostics : Array[Diagnostic],
) -> String {
let errors = diagnostic_count(diagnostics, Error)
let warnings = diagnostic_count(diagnostics, Warning)
let infos = diagnostic_count(diagnostics, Info)
let mut text = "# MoonEvac validation report\n\n"
text = text + "- Plan: **\{plan.name}**\n"
text = text + "- Status: **\{if errors == 0 { "PASS" } else { "FAIL" }}**\n"
text = text + "- Places: \{plan.places.length()}\n"
text = text + "- Passages: \{plan.passages.length()}\n"
text = text + "- Population: \{plan.population_count()}\n"
text = text + "- Errors: \{errors}\n"
text = text + "- Warnings: \{warnings}\n"
text = text + "- Information: \{infos}\n\n"
if diagnostics.length() == 0 {
text = text + "No validation problems were found.\n"
return text
}
text = text + "## Findings\n\n"
for item in diagnostics {
text = text + "### \{severity_icon(item.severity)} `\{item.code}`\n\n"
text = text + "- Subject: `\{item.subject}`\n"
text = text + "- Severity: \{item.severity.code()}\n"
text = text + "- Finding: \{item.message}\n"
text = text + "- Suggested action: \{item.hint}\n\n"
}
text
}
///|
/// Render a full simulation result as Markdown.
pub fn simulation_markdown(result : SimulationResult) -> String {
let mut text = "# MoonEvac simulation report\n\n"
text = text + "## Summary\n\n"
text = text + "| Metric | Value |\n"
text = text + "|---|---:|\n"
text = text + "| Plan | \{result.plan_name} |\n"
text = text + "| Completed | \{result.completed} |\n"
text = text + "| Simulated steps | \{result.steps} |\n"
text = text + "| Initial population | \{result.total_people} |\n"
text = text + "| Evacuated | \{result.evacuated_people} |\n"
text = text + "| Remaining | \{result.remaining_people} |\n"
text = text + "| Peak stalled population | \{result.peak_stalled_people} |\n"
text = text + "| Peak risk | \{result.peak_risk} |\n"
text = text + "| Mean evacuation step | \{result.mean_evacuation_step} |\n\n"
text = text + "## Population groups\n\n"
text = text +
"| Group | Initial | Evacuated | Remaining | First exit | Last exit | Reroutes | Exposure |\n"
text = text + "|---|---:|---:|---:|---:|---:|---:|---:|\n"
for outcome in result.group_outcomes {
text = text + "| \{outcome.group_id} | \{outcome.initial_count} | "
text = text + "\{outcome.evacuated_count} | \{outcome.remaining_count} | "
text = text + "\{outcome.first_exit_step} | \{outcome.last_exit_step} | "
text = text + "\{outcome.reroutes} | \{outcome.accumulated_exposure} |\n"
}
text = text + "\n## Timeline\n\n"
text = text +
"| Step | Remaining | Evacuated | Stalled | Maximum risk | Mean risk |\n"
text = text + "|---:|---:|---:|---:|---:|---:|\n"
for snapshot in result.snapshots {
text = text + "| \{snapshot.step} | \{snapshot.remaining} | "
text = text + "\{snapshot.evacuated} | \{snapshot.stalled} | "
text = text + "\{snapshot.maximum_risk} | \{snapshot.mean_risk} |\n"
}
if result.event_log.length() > 0 {
text = text + "\n## Scenario events\n\n"
for event in result.event_log {
text = text + "- \{event}\n"
}
}
text
}
///|
fn diagnostic_json(item : Diagnostic) -> String {
"{" +
"\"code\":" +
json_string(item.code) +
"," +
"\"severity\":" +
json_string(item.severity.code()) +
"," +
"\"subject\":" +
json_string(item.subject) +
"," +
"\"message\":" +
json_string(item.message) +
"," +
"\"hint\":" +
json_string(item.hint) +
"}"
}
///|
/// Render diagnostics as stable JSON without external dependencies.
pub fn validation_json(
plan : EvacuationPlan,
diagnostics : Array[Diagnostic],
) -> String {
let mut items = "["
for i = 0; i < diagnostics.length(); i = i + 1 {
if i > 0 {
items = items + ","
}
items = items + diagnostic_json(diagnostics[i])
}
items = items + "]"
"{" +
"\"schema\":\"moonevac.validation.v1\"," +
"\"plan\":" +
json_string(plan.name) +
"," +
"\"valid\":" +
bool_json(validation_ok(diagnostics)) +
"," +
"\"place_count\":\{plan.places.length()}," +
"\"passage_count\":\{plan.passages.length()}," +
"\"population\":\{plan.population_count()}," +
"\"diagnostics\":" +
items +
"}"
}
///|
fn group_outcome_json(outcome : GroupOutcome) -> String {
"{" +
"\"group_id\":" +
json_string(outcome.group_id) +
"," +
"\"initial\":\{outcome.initial_count}," +
"\"evacuated\":\{outcome.evacuated_count}," +
"\"remaining\":\{outcome.remaining_count}," +
"\"first_exit_step\":\{outcome.first_exit_step}," +
"\"last_exit_step\":\{outcome.last_exit_step}," +
"\"exposure\":\{outcome.accumulated_exposure}," +
"\"reroutes\":\{outcome.reroutes}" +
"}"
}
///|
fn snapshot_json(snapshot : SimulationSnapshot) -> String {
"{" +
"\"step\":\{snapshot.step}," +
"\"remaining\":\{snapshot.remaining}," +
"\"evacuated\":\{snapshot.evacuated}," +
"\"stalled\":\{snapshot.stalled}," +
"\"maximum_risk\":\{snapshot.maximum_risk}," +
"\"mean_risk\":\{snapshot.mean_risk}," +
"\"passage_queues\":" +
join_json_ints(snapshot.passage_queues) +
"," +
"\"place_occupancy\":" +
join_json_ints(snapshot.place_occupancy) +
"}"
}
///|
/// Render a simulation result as stable JSON.
pub fn simulation_json(result : SimulationResult) -> String {
let mut groups = "["
for i = 0; i < result.group_outcomes.length(); i = i + 1 {
if i > 0 {
groups = groups + ","
}
groups = groups + group_outcome_json(result.group_outcomes[i])
}
groups = groups + "]"
let mut snapshots = "["
for i = 0; i < result.snapshots.length(); i = i + 1 {
if i > 0 {
snapshots = snapshots + ","
}
snapshots = snapshots + snapshot_json(result.snapshots[i])
}
snapshots = snapshots + "]"
"{" +
"\"schema\":\"moonevac.simulation.v1\"," +
"\"plan\":" +
json_string(result.plan_name) +
"," +
"\"completed\":" +
bool_json(result.completed) +
"," +
"\"steps\":\{result.steps}," +
"\"total_people\":\{result.total_people}," +
"\"evacuated_people\":\{result.evacuated_people}," +
"\"remaining_people\":\{result.remaining_people}," +
"\"peak_stalled_people\":\{result.peak_stalled_people}," +
"\"peak_risk\":\{result.peak_risk}," +
"\"mean_evacuation_step\":\{result.mean_evacuation_step}," +
"\"groups\":" +
groups +
"," +
"\"snapshots\":" +
snapshots +
"," +
"\"passage_peak_queues\":" +
join_json_ints(result.passage_peak_queues) +
"," +
"\"events\":" +
join_json_strings(result.event_log) +
"}"
}
///|
/// Render timeline data as CSV for spreadsheet analysis.
pub fn simulation_timeline_csv(result : SimulationResult) -> String {
let mut text = "step,remaining,evacuated,stalled,maximum_risk,mean_risk\n"
for snapshot in result.snapshots {
text = text + "\{snapshot.step},\{snapshot.remaining},"
text = text + "\{snapshot.evacuated},\{snapshot.stalled},"
text = text + "\{snapshot.maximum_risk},\{snapshot.mean_risk}\n"
}
text
}
///|
/// Render group outcomes as CSV.
pub fn simulation_groups_csv(result : SimulationResult) -> String {
let mut text = "group,initial,evacuated,remaining,first_exit,last_exit,reroutes,exposure\n"
for outcome in result.group_outcomes {
text = text + "\{outcome.group_id},\{outcome.initial_count},"
text = text + "\{outcome.evacuated_count},\{outcome.remaining_count},"
text = text + "\{outcome.first_exit_step},\{outcome.last_exit_step},"
text = text + "\{outcome.reroutes},\{outcome.accumulated_exposure}\n"
}
text
}
///|
fn svg_point(place : Place, floor_offset : Double) -> Point {
{ x: place.x + floor_offset, y: place.y }
}
///|
fn risk_color(risks : Array[Double], index : Int) -> String {
if index < 0 || index >= risks.length() {
"#607d8b"
} else {
risk_band(risks[index]).color()
}
}
///|
fn place_shape(place : Place) -> String {
match place.kind {
Room => "rect"
Corridor => "circle"
Stair => "diamond"
Assembly => "hexagon"
Exit => "exit"
}
}
///|
fn svg_place_node(
place : Place,
risk : String,
floor_offset : Double,
) -> String {
let point = svg_point(place, floor_offset)
let label = escape_xml(place.label)
let id = escape_xml(place.id)
let shape = place_shape(place)
let mut body = ""
match place.kind {
Room =>
body = body +
""
Corridor =>
body = body +
""
Stair =>
body = body +
""
Assembly =>
body = body +
""
Exit =>
body = body +
""
}
body = body +
"\{label}"
body + ""
}
///|
fn svg_passage(
plan : EvacuationPlan,
passage : Passage,
floor_gap : Double,
) -> String {
let from_index = plan.place_index(passage.from_id)
let to_index = plan.place_index(passage.to_id)
if from_index < 0 || to_index < 0 {
return ""
}
let from = plan.places[from_index]
let to = plan.places[to_index]
let a = svg_point(from, (from.floor - 1).to_double() * floor_gap)
let b = svg_point(to, (to.floor - 1).to_double() * floor_gap)
let dash = if passage.kind == Stairway || passage.kind == Ramp {
" stroke-dasharray=\"7 5\""
} else {
""
}
let width = if passage.width_m < 1.0 { 2.0 } else { passage.width_m * 2.0 }
""
}
///|
/// Render the plan and an optional risk state as a standalone SVG document.
pub fn plan_svg(plan : EvacuationPlan, risks : Array[Double]) -> String {
let floor_gap = 540.0
let mut maximum_floor = 1
for place in plan.places {
if place.floor > maximum_floor {
maximum_floor = place.floor
}
}
let width = maximum_floor.to_double() * floor_gap
let mut text = ""
}
///|
fn chart_x(index : Int, count : Int, width : Double) -> Double {
if count <= 1 {
60.0
} else {
60.0 + index.to_double() * (width - 100.0) / (count - 1).to_double()
}
}
///|
fn chart_y(value : Double, maximum : Double, height : Double) -> Double {
if maximum <= 0.0 {
height - 40.0
} else {
height - 40.0 - value / maximum * (height - 90.0)
}
}
///|
/// Render remaining and evacuated population as an SVG timeline.
pub fn timeline_svg(result : SimulationResult) -> String {
let width = 900.0
let height = 360.0
let maximum = if result.total_people > 0 {
result.total_people.to_double()
} else {
1.0
}
let mut remaining_points = ""
let mut evacuated_points = ""
for i = 0; i < result.snapshots.length(); i = i + 1 {
let snapshot = result.snapshots[i]
let x = chart_x(i, result.snapshots.length(), width)
let remaining_y = chart_y(snapshot.remaining.to_double(), maximum, height)
let evacuated_y = chart_y(snapshot.evacuated.to_double(), maximum, height)
if i > 0 {
remaining_points = remaining_points + " "
evacuated_points = evacuated_points + " "
}
remaining_points = remaining_points + "\{x},\{remaining_y}"
evacuated_points = evacuated_points + "\{x},\{evacuated_y}"
}
let mut text = ""
}