Add counting of detached nodes

This commit is contained in:
Juno Takano 2026-01-13 15:32:36 -03:00
commit 41a5994bbd
7 changed files with 109 additions and 18 deletions

View file

@ -30,6 +30,13 @@ pub struct Graph {
pub lowercase_keymap: HashMap<String, String>,
#[serde(default)]
pub meta: Meta,
#[serde(default)]
pub stats: Stats,
}
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)]
pub struct Stats {
pub detached: HashMap<String, u32>,
}
#[derive(Clone, Default, Debug)]
@ -160,8 +167,11 @@ impl Graph {
}
// Flag detached edges
if !in_nodes.contains_key(&edge.to) {
if in_nodes.contains_key(&edge.to) {
new_edge.detached = false;
} else {
new_edge.detached = true;
self.increment_detached(&key);
}
if let Some(e) = new_edges.get_mut(&connection_key) {
@ -171,14 +181,19 @@ impl Graph {
// Create connections for each link
for link in &node.links {
let detached = !in_nodes.contains_key(link);
new_edges.insert(
link.clone(),
Edge {
from: key.clone(),
to: link.clone(),
detached: !in_nodes.clone().contains_key(link),
detached,
},
);
if detached {
self.increment_detached(&key);
}
}
// Populate empty titles with IDs
@ -269,11 +284,35 @@ impl Graph {
},
);
}
} else {
if let Some(destination) = anchor.destination()
&& !anchor.external()
{
self.stats
.detached
.entry(
destination
.trim_start_matches("/node/")
.to_string(),
)
.and_modify(|count| {
*count = count.saturating_add(1)
})
.or_insert(1);
}
}
}
}
}
fn increment_detached(&mut self, node_id: &str) {
self.stats
.detached
.entry(node_id.to_string())
.and_modify(|count| *count = count.saturating_add(1))
.or_insert(1);
}
pub fn map_lowercase_keys(&mut self) {
for key in self.nodes.keys() {
self.lowercase_keymap

View file

@ -23,6 +23,15 @@ pub struct Node {
#[serde(skip_serializing_if = "Option::is_none")]
pub connections: Option<HashMap<String, Edge>>,
#[serde(default)]
pub stats: Stats,
}
#[derive(Serialize, Deserialize, Clone, Default, Eq, PartialEq, Debug)]
pub struct Stats {
pub outgoing: u32,
pub incoming: u32,
}
impl Node {
@ -39,6 +48,7 @@ impl Node {
redirect: String::default(),
hidden: false,
summary: String::default(),
stats: Stats::default(),
}
}
}

View file

@ -18,8 +18,13 @@ pub fn new(graph: &Graph) -> Router {
get(|| handlers::navigation::page("index.html"))
.post(handlers::navigation::search),
)
.route("/redirect", get(handlers::navigation::redirect))
.route(
"/node/{node_id}",
get(handlers::graph::node).post(handlers::graph::node),
)
.route("/data", get(handlers::navigation::data))
.route("/search", get(handlers::navigation::search))
.route("/redirect", get(handlers::navigation::redirect))
.route(
"/static/style.css",
get(|| handlers::fixed::file("./static/style.css", "text/css")),
@ -30,10 +35,6 @@ pub fn new(graph: &Graph) -> Router {
handlers::fixed::file("./static/favicon.svg", "image/svg+xml")
}),
)
.route(
"/node/{node_id}",
get(handlers::graph::node).post(handlers::graph::node),
)
.fallback(handlers::error::not_found);
if graph.meta.config.about {
@ -47,8 +48,6 @@ pub fn new(graph: &Graph) -> Router {
}
if graph.meta.config.raw {
router = router
.route("/data", get(|| handlers::navigation::page("data.html")));
if graph.meta.config.raw_json {
router = router.route(
"/graph/json",

View file

@ -14,16 +14,27 @@ use crate::{
pub async fn page(template: &str) -> Response<Body> {
let mut context = tera::Context::default();
let graph = Graph::load();
let root_node = graph.get_root().unwrap_or_default();
let nodes: Vec<Node> = graph.nodes.into_values().collect();
context.insert("nodes", &nodes);
context.insert("root_node", &root_node);
context.insert("config", &graph.meta.config);
context.insert("graph", &graph);
handlers::template::by_filename(template, &context, 500, None, false)
}
pub async fn data() -> Response<Body> {
let mut context = tera::Context::default();
let graph = Graph::load();
let mut detached_pairs: Vec<(String, u32)> =
graph.stats.detached.clone().into_iter().collect();
detached_pairs.sort_by(|a, b| b.1.cmp(&a.1));
context.insert("graph", &graph);
context.insert("detached_count", &graph.stats.detached.len());
context.insert("detached_pairs", &detached_pairs);
handlers::template::by_filename("data.html", &context, 500, None, false)
}
pub async fn search(Form(query): Form<Query>) -> Redirect {
Redirect::permanent(format!("/node/{}", query.node).as_str())
}

View file

@ -539,10 +539,9 @@ text = """
- [ ] Example (implies metadata `has_example`)
- [ ] References/influences (implies metadata `has_references`)
- [ ] Meta-awareness
- [ ] Detached edges
- [x] Detached edges
- [ ] Most linked to nodes
- [ ] Most linked from nodes
- [ ] Most linked to nonexistent nodes
- [ ] Most linked
- [ ] Rendering
- [ ] Sorting of tree, index list and drop-down navigation

View file

@ -163,6 +163,17 @@ em#index-node-count {
font-size: 0.8em;
}
table {
margin: auto;
border-collapse: collapse;
border: 0.5px dotted #666;
}
td, th {
padding: 10px;
border: 0.5px dotted #666;
}
@media (prefers-color-scheme: dark) {
* {
background-color: #222222;

View file

@ -6,14 +6,36 @@
{%- block body %}
<h1>Data</h1>
<h2>Metadata</h2>
<table>
<tr>
<td>Detached edges</td> <td>{{ detached_count }}</td>
</tr>
</table>
<h3>Detached edges</h3>
<details>
<summary>Expand to list all detached edges.</summary>
<ul>
{% for pair in detached_pairs %}
<li>{{ pair.0 }}: {{ pair.1 }}</li>
{% endfor %}
</ul>
</details>
{% if graph.meta.config.raw_toml or graph.meta.config.raw_json %}
<h2>Raw formats</h2>
<p>The raw data used to render this graph is available in the following formats:</p>
<ul>
{% if config.raw_toml %}
{% if graph.meta.config.raw_toml %}
<li><a href="/graph/toml">TOML</a></li>
{% endif %}
{% if config.raw_json %}
{% if graph.meta.config.raw_json %}
<li><a href="/graph/json">JSON</a></li>
{% endif %}
</ul>
{% endif %}
{%- endblock body %}