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>, pub lowercase_keymap: HashMap<String, String>,
#[serde(default)] #[serde(default)]
pub meta: Meta, 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)] #[derive(Clone, Default, Debug)]
@ -160,8 +167,11 @@ impl Graph {
} }
// Flag detached edges // Flag detached edges
if !in_nodes.contains_key(&edge.to) { if in_nodes.contains_key(&edge.to) {
new_edge.detached = false; new_edge.detached = false;
} else {
new_edge.detached = true;
self.increment_detached(&key);
} }
if let Some(e) = new_edges.get_mut(&connection_key) { if let Some(e) = new_edges.get_mut(&connection_key) {
@ -171,14 +181,19 @@ impl Graph {
// Create connections for each link // Create connections for each link
for link in &node.links { for link in &node.links {
let detached = !in_nodes.contains_key(link);
new_edges.insert( new_edges.insert(
link.clone(), link.clone(),
Edge { Edge {
from: key.clone(), from: key.clone(),
to: link.clone(), to: link.clone(),
detached: !in_nodes.clone().contains_key(link), detached,
}, },
); );
if detached {
self.increment_detached(&key);
}
} }
// Populate empty titles with IDs // Populate empty titles with IDs
@ -269,10 +284,34 @@ 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) { pub fn map_lowercase_keys(&mut self) {
for key in self.nodes.keys() { for key in self.nodes.keys() {

View file

@ -23,6 +23,15 @@ pub struct Node {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub connections: Option<HashMap<String, Edge>>, 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 { impl Node {
@ -39,6 +48,7 @@ impl Node {
redirect: String::default(), redirect: String::default(),
hidden: false, hidden: false,
summary: String::default(), summary: String::default(),
stats: Stats::default(),
} }
} }
} }

View file

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

View file

@ -14,16 +14,27 @@ use crate::{
pub async fn page(template: &str) -> Response<Body> { pub async fn page(template: &str) -> Response<Body> {
let mut context = tera::Context::default(); let mut context = tera::Context::default();
let graph = Graph::load(); 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("graph", &graph);
context.insert("root_node", &root_node);
context.insert("config", &graph.meta.config);
handlers::template::by_filename(template, &context, 500, None, false) 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 { pub async fn search(Form(query): Form<Query>) -> Redirect {
Redirect::permanent(format!("/node/{}", query.node).as_str()) Redirect::permanent(format!("/node/{}", query.node).as_str())
} }

View file

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

View file

@ -163,6 +163,17 @@ em#index-node-count {
font-size: 0.8em; 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) { @media (prefers-color-scheme: dark) {
* { * {
background-color: #222222; background-color: #222222;

View file

@ -6,14 +6,36 @@
{%- block body %} {%- block body %}
<h1>Data</h1> <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> <p>The raw data used to render this graph is available in the following formats:</p>
<ul> <ul>
{% if config.raw_toml %} {% if graph.meta.config.raw_toml %}
<li><a href="/graph/toml">TOML</a></li> <li><a href="/graph/toml">TOML</a></li>
{% endif %} {% endif %}
{% if config.raw_json %} {% if graph.meta.config.raw_json %}
<li><a href="/graph/json">JSON</a></li> <li><a href="/graph/json">JSON</a></li>
{% endif %} {% endif %}
</ul> </ul>
{% endif %}
{%- endblock body %} {%- endblock body %}