use std::collections::VecDeque;
use serde_json::Value as JsonValue;
use crate::parser::{XmlChild, XmlNode};
pub const MAX_GRAPH_NODES: usize = 3000;
pub const MAX_VALUE_CHARS: usize = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueKind {
String,
Number,
Bool,
Null,
ObjectRef,
ArrayRef,
Text,
}
#[derive(Debug, Clone)]
pub struct GraphRow {
pub key: String,
pub value: String,
pub kind: ValueKind,
}
pub type NodeId = usize;
#[derive(Debug, Clone)]
pub struct GraphNode {
pub id: NodeId,
pub label: String,
pub rows: Vec<GraphRow>,
pub depth: usize,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct GraphEdge {
pub from: NodeId,
pub from_row: Option<usize>,
pub to: NodeId,
pub label: String,
}
#[derive(Debug, Default, Clone)]
pub struct Graph {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct FontMetrics {
pub char_width: f64,
pub row_height: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct LayoutConfig {
pub metrics: FontMetrics,
pub node_padding: f64,
pub column_gap: f64,
pub sibling_gap: f64,
pub max_node_width: f64,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
metrics: FontMetrics {
char_width: 8.0,
row_height: 20.0,
},
node_padding: 8.0,
column_gap: 80.0,
sibling_gap: 24.0,
max_node_width: 480.0,
}
}
}
struct Pending<'a, T> {
from: Option<(NodeId, Option<usize>)>,
label: String,
depth: usize,
payload: &'a T,
}
fn is_row_like(value: &JsonValue) -> bool {
match value {
JsonValue::Object(map) => map.is_empty(),
JsonValue::Array(arr) => arr.is_empty(),
_ => true,
}
}
fn format_scalar(value: &JsonValue) -> (String, ValueKind) {
match value {
JsonValue::Null => ("null".to_string(), ValueKind::Null),
JsonValue::Bool(b) => (b.to_string(), ValueKind::Bool),
JsonValue::Number(n) => (n.to_string(), ValueKind::Number),
JsonValue::String(s) => (truncate_chars(&format!("\"{s}\""), MAX_VALUE_CHARS), ValueKind::String),
JsonValue::Object(map) => (container_ref_text(map.len(), true), ValueKind::ObjectRef),
JsonValue::Array(arr) => (container_ref_text(arr.len(), false), ValueKind::ArrayRef),
}
}
fn container_ref_text(count: usize, is_object: bool) -> String {
match (is_object, count) {
(true, 1) => "{1 key}".to_string(),
(true, n) => format!("{{{n} keys}}"),
(false, 1) => "[1 item]".to_string(),
(false, n) => format!("[{n} items]"),
}
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
pub fn build_json_graph(value: &JsonValue) -> Graph {
let mut graph = Graph::default();
let mut queue: VecDeque<Pending<'_, JsonValue>> = VecDeque::new();
queue.push_back(Pending {
from: None,
label: String::new(),
depth: 0,
payload: value,
});
while let Some(pending) = queue.pop_front() {
match pending.payload {
JsonValue::Object(map) if !map.is_empty() => {
let Some(id) = alloc_node(&mut graph, &pending, |rows| {
for (key, val) in map {
let (value, kind) = format_scalar(val);
rows.push(GraphRow {
key: key.clone(),
value,
kind,
});
}
}) else {
continue;
};
for (row_idx, (key, val)) in map.iter().enumerate() {
if !is_row_like(val) {
queue.push_back(Pending {
from: Some((id, Some(row_idx))),
label: key.clone(),
depth: pending.depth + 1,
payload: val,
});
}
}
}
JsonValue::Array(arr) if !arr.is_empty() => {
let scalars: Vec<(usize, &JsonValue)> =
arr.iter().enumerate().filter(|(_, v)| is_row_like(v)).collect();
if !scalars.is_empty() {
alloc_node(&mut graph, &pending, |rows| {
for (idx, val) in &scalars {
let (value, kind) = format_scalar(val);
rows.push(GraphRow {
key: idx.to_string(),
value,
kind,
});
}
});
}
for (idx, val) in arr.iter().enumerate() {
if !is_row_like(val) {
queue.push_back(Pending {
from: pending.from,
label: idx.to_string(),
depth: pending.depth,
payload: val,
});
}
}
}
other => {
alloc_node(&mut graph, &pending, |rows| {
let (value, kind) = format_scalar(other);
rows.push(GraphRow {
key: String::new(),
value,
kind,
});
});
}
}
}
graph
}
fn alloc_node<T>(
graph: &mut Graph,
pending: &Pending<'_, T>,
fill_rows: impl FnOnce(&mut Vec<GraphRow>),
) -> Option<NodeId> {
if graph.nodes.len() >= MAX_GRAPH_NODES {
graph.truncated = true;
return None;
}
let id = graph.nodes.len();
let mut rows = Vec::new();
fill_rows(&mut rows);
graph.nodes.push(GraphNode {
id,
label: pending.label.clone(),
rows,
depth: pending.depth,
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
});
if let Some((from, from_row)) = pending.from {
graph.edges.push(GraphEdge {
from,
from_row,
to: id,
label: pending.label.clone(),
});
}
Some(id)
}
fn xml_text_content(node: &XmlNode) -> String {
let mut parts = Vec::new();
for child in &node.children {
if let XmlChild::Text(text) = child {
let trimmed = text.trim();
if !trimmed.is_empty() {
parts.push(trimmed);
}
}
}
parts.join(" ")
}
pub fn build_xml_graph(root: &XmlNode) -> Graph {
let mut graph = Graph::default();
let mut queue: VecDeque<Pending<'_, XmlNode>> = VecDeque::new();
queue.push_back(Pending {
from: None,
label: root.tag.clone(),
depth: 0,
payload: root,
});
while let Some(pending) = queue.pop_front() {
let node = pending.payload;
let mut tag_groups: Vec<(String, usize)> = Vec::new();
for child in node.child_nodes() {
match tag_groups.iter_mut().find(|(tag, _)| *tag == child.tag) {
Some((_, count)) => *count += 1,
None => tag_groups.push((child.tag.clone(), 1)),
}
}
let mut ref_row_base = 0;
let Some(id) = alloc_node(&mut graph, &pending, |rows| {
for (name, value) in &node.attributes {
rows.push(GraphRow {
key: format!("@{name}"),
value: truncate_chars(value, MAX_VALUE_CHARS),
kind: ValueKind::String,
});
}
let text = xml_text_content(node);
if !text.is_empty() {
rows.push(GraphRow {
key: "#text".to_string(),
value: truncate_chars(&text, MAX_VALUE_CHARS),
kind: ValueKind::Text,
});
}
ref_row_base = rows.len();
for (tag, count) in &tag_groups {
let value = if *count == 1 {
"{1 node}".to_string()
} else {
format!("[{count} nodes]")
};
rows.push(GraphRow {
key: tag.clone(),
value,
kind: ValueKind::ObjectRef,
});
}
if rows.is_empty() {
rows.push(GraphRow {
key: String::new(),
value: "{empty}".to_string(),
kind: ValueKind::Null,
});
}
}) else {
continue;
};
for child in node.child_nodes() {
let row_idx = tag_groups
.iter()
.position(|(tag, _)| *tag == child.tag)
.map(|group_idx| ref_row_base + group_idx);
queue.push_back(Pending {
from: Some((id, row_idx)),
label: child.tag.clone(),
depth: pending.depth + 1,
payload: child,
});
}
}
graph
}
pub fn layout(graph: &mut Graph, cfg: &LayoutConfig) {
let n = graph.nodes.len();
if n == 0 {
return;
}
for node in &mut graph.nodes {
let max_chars = node
.rows
.iter()
.map(|row| {
let sep = if row.key.is_empty() { 0 } else { 2 }; row.key.chars().count() + sep + row.value.chars().count()
})
.max()
.unwrap_or(1);
let width = 2.0 * cfg.node_padding + max_chars as f64 * cfg.metrics.char_width;
node.width = width.clamp(cfg.metrics.char_width * 4.0, cfg.max_node_width);
node.height = 2.0 * cfg.node_padding + node.rows.len().max(1) as f64 * cfg.metrics.row_height;
}
let max_depth = graph.nodes.iter().map(|node| node.depth).max().unwrap_or(0);
let mut col_widths = vec![0.0_f64; max_depth + 1];
for node in &graph.nodes {
col_widths[node.depth] = col_widths[node.depth].max(node.width);
}
let mut col_x = vec![0.0_f64; max_depth + 1];
for depth in 1..=max_depth {
col_x[depth] = col_x[depth - 1] + col_widths[depth - 1] + cfg.column_gap;
}
for node in &mut graph.nodes {
node.x = col_x[node.depth];
}
let mut children: Vec<Vec<NodeId>> = vec![Vec::new(); n];
let mut has_parent = vec![false; n];
for edge in &graph.edges {
children[edge.from].push(edge.to);
has_parent[edge.to] = true;
}
let mut subtree_h = vec![0.0_f64; n];
for id in (0..n).rev() {
let children_total: f64 = children[id].iter().map(|&child| subtree_h[child]).sum::<f64>()
+ cfg.sibling_gap * children[id].len().saturating_sub(1) as f64;
subtree_h[id] = graph.nodes[id].height.max(children_total);
}
let mut band_top = vec![0.0_f64; n];
let mut root_cursor = 0.0_f64;
for id in 0..n {
if !has_parent[id] {
band_top[id] = root_cursor;
root_cursor += subtree_h[id] + cfg.sibling_gap;
}
let node_height = graph.nodes[id].height;
graph.nodes[id].y = band_top[id] + (subtree_h[id] - node_height) / 2.0;
let children_total: f64 = children[id].iter().map(|&child| subtree_h[child]).sum::<f64>()
+ cfg.sibling_gap * children[id].len().saturating_sub(1) as f64;
let mut cursor = band_top[id] + (subtree_h[id] - children_total) / 2.0;
for &child in &children[id] {
band_top[child] = cursor;
cursor += subtree_h[child] + cfg.sibling_gap;
}
}
}
pub fn bounds(graph: &Graph) -> (f64, f64, f64, f64) {
if graph.nodes.is_empty() {
return (0.0, 0.0, 0.0, 0.0);
}
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
for node in &graph.nodes {
min_x = min_x.min(node.x);
min_y = min_y.min(node.y);
max_x = max_x.max(node.x + node.width);
max_y = max_y.max(node.y + node.height);
}
(min_x, min_y, max_x, max_y)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_fijo() -> LayoutConfig {
LayoutConfig {
metrics: FontMetrics {
char_width: 8.0,
row_height: 18.0,
},
..LayoutConfig::default()
}
}
#[test]
fn truncar_respeta_caracteres_no_bytes() {
let largo = "ñ".repeat(70);
let out = truncate_chars(&largo, MAX_VALUE_CHARS);
assert_eq!(out.chars().count(), MAX_VALUE_CHARS);
assert!(out.ends_with('…'));
}
#[test]
fn texto_de_referencia_singular_y_plural() {
assert_eq!(container_ref_text(1, true), "{1 key}");
assert_eq!(container_ref_text(2, true), "{2 keys}");
assert_eq!(container_ref_text(1, false), "[1 item]");
assert_eq!(container_ref_text(3, false), "[3 items]");
}
#[test]
fn bounds_de_grafo_vacio_es_cero() {
let graph = Graph::default();
assert_eq!(bounds(&graph), (0.0, 0.0, 0.0, 0.0));
}
#[test]
fn layout_de_un_nodo_arranca_en_origen() {
let value = serde_json::json!({"a": 1});
let mut graph = build_json_graph(&value);
layout(&mut graph, &cfg_fijo());
assert_eq!(graph.nodes[0].x, 0.0);
assert!(graph.nodes[0].width > 0.0);
assert!(graph.nodes[0].height > 0.0);
}
}