496 lines
17 KiB
Rust
496 lines
17 KiB
Rust
//! The knowledge graph and the algorithm flow graphs.
|
||
//!
|
||
//! Two kinds of graph are served:
|
||
//!
|
||
//! - The concept graph, authored in `content/graph/concepts.json`. Its nodes
|
||
//! are concepts and its edges are the relationships between them, such as
|
||
//! `uses`, `refines`, or `contrasts`. A node carries the identifier of the
|
||
//! document that explains it, so the frontend can open the document when a
|
||
//! node is clicked.
|
||
//! - Flow graphs, authored one per file in `content/flows/`. A flow graph
|
||
//! describes the steps of an algorithm, such as the SGP4 propagation
|
||
//! sequence, as nodes with shapes and labelled edges with branches.
|
||
//!
|
||
//! Both are plain JSON with the same shape, so one renderer on the frontend
|
||
//! handles either. The layout is computed on the frontend from the node
|
||
//! kinds; the backend only supplies the structure, which keeps the authored
|
||
//! data independent of any particular rendering.
|
||
|
||
use crate::docs::ApiCatalog;
|
||
use crate::json::{self, Value};
|
||
|
||
/// The kind of a graph node.
|
||
///
|
||
/// The kind selects how a node is drawn. The set is intentionally small so
|
||
/// that a new graph can be authored without touching the renderer.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum NodeKind {
|
||
/// A concept in the knowledge graph.
|
||
Concept,
|
||
/// A crate of the `apsis-rs` workspace.
|
||
Crate,
|
||
/// A layer of the `apsis-rs` workspace.
|
||
Layer,
|
||
/// A step of an algorithm.
|
||
Step,
|
||
/// A decision in an algorithm, with more than one outgoing edge.
|
||
Decision,
|
||
/// A terminal step, such as a returned value or an error.
|
||
Terminal,
|
||
/// A data item, such as an element set or an epoch.
|
||
Data,
|
||
}
|
||
|
||
impl NodeKind {
|
||
/// Parses a kind name, defaulting to [`NodeKind::Concept`].
|
||
pub fn parse(name: &str) -> Self {
|
||
match name {
|
||
"crate" => Self::Crate,
|
||
"layer" => Self::Layer,
|
||
"step" => Self::Step,
|
||
"decision" => Self::Decision,
|
||
"terminal" => Self::Terminal,
|
||
"data" => Self::Data,
|
||
_ => Self::Concept,
|
||
}
|
||
}
|
||
|
||
/// Returns the kind name as it is written in the data.
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
Self::Concept => "concept",
|
||
Self::Crate => "crate",
|
||
Self::Layer => "layer",
|
||
Self::Step => "step",
|
||
Self::Decision => "decision",
|
||
Self::Terminal => "terminal",
|
||
Self::Data => "data",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A node of a graph.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Node {
|
||
/// Identifier, unique within the graph.
|
||
pub id: String,
|
||
/// Display label in English.
|
||
pub label: String,
|
||
/// Display label in Chinese, or `None` when the label is a name that is
|
||
/// written the same way in both languages.
|
||
pub label_zh: Option<String>,
|
||
/// Kind, selecting how the node is drawn.
|
||
pub kind: NodeKind,
|
||
/// Chapter or group the node belongs to, used for colouring.
|
||
pub group: String,
|
||
/// One sentence description in English.
|
||
pub summary: String,
|
||
/// One sentence description in Chinese, when one is authored.
|
||
pub summary_zh: Option<String>,
|
||
/// Identifier of the document that explains the node, when one exists.
|
||
pub document: Option<String>,
|
||
/// Identifier of the interface item, when the node names one.
|
||
pub api: Option<String>,
|
||
}
|
||
|
||
/// An edge of a graph.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Edge {
|
||
/// Source node identifier.
|
||
pub source: String,
|
||
/// Target node identifier.
|
||
pub target: String,
|
||
/// Relationship name, such as `uses`.
|
||
pub kind: String,
|
||
/// Optional label drawn on the edge, in English.
|
||
pub label: Option<String>,
|
||
/// The same label in Chinese, when one is authored.
|
||
pub label_zh: Option<String>,
|
||
}
|
||
|
||
/// A graph, either the concept graph or one flow graph.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Graph {
|
||
/// Identifier of the graph.
|
||
pub id: String,
|
||
/// Display title in English.
|
||
pub title: String,
|
||
/// Display title in Chinese, when one is authored.
|
||
pub title_zh: Option<String>,
|
||
/// One sentence description in English.
|
||
pub summary: String,
|
||
/// One sentence description in Chinese, when one is authored.
|
||
pub summary_zh: Option<String>,
|
||
/// Nodes.
|
||
pub nodes: Vec<Node>,
|
||
/// Edges.
|
||
pub edges: Vec<Edge>,
|
||
}
|
||
|
||
impl Graph {
|
||
/// Returns a node by identifier.
|
||
///
|
||
/// Used to resolve the endpoints of an edge, which is what the check
|
||
/// for a dangling edge does.
|
||
#[allow(dead_code)]
|
||
pub fn node(&self, id: &str) -> Option<&Node> {
|
||
self.nodes.iter().find(|node| node.id == id)
|
||
}
|
||
|
||
/// Returns the edges that leave a node.
|
||
#[allow(dead_code)]
|
||
pub fn outgoing(&self, id: &str) -> impl Iterator<Item = &Edge> {
|
||
self.edges.iter().filter(move |edge| edge.source == id)
|
||
}
|
||
|
||
/// Returns the identifiers of every edge endpoint that is not a node.
|
||
///
|
||
/// The authored graph data is checked with this at startup, so a broken
|
||
/// edge is reported rather than drawn to nowhere.
|
||
#[allow(dead_code)]
|
||
pub fn dangling_edges(&self) -> Vec<(String, String)> {
|
||
self.edges
|
||
.iter()
|
||
.filter(|edge| self.node(&edge.source).is_none() || self.node(&edge.target).is_none())
|
||
.map(|edge| (edge.source.clone(), edge.target.clone()))
|
||
.collect()
|
||
}
|
||
|
||
/// Returns the distinct groups in the order they first appear.
|
||
pub fn groups(&self) -> Vec<String> {
|
||
let mut groups = Vec::new();
|
||
for node in &self.nodes {
|
||
if !groups.iter().any(|group| group == &node.group) {
|
||
groups.push(node.group.clone());
|
||
}
|
||
}
|
||
groups
|
||
}
|
||
}
|
||
|
||
/// The concept graph together with the algorithm flow graphs.
|
||
#[derive(Debug)]
|
||
pub struct GraphSet {
|
||
/// The concept knowledge graph.
|
||
pub concepts: Graph,
|
||
/// The flow graphs, in title order.
|
||
pub flows: Vec<Graph>,
|
||
}
|
||
|
||
impl GraphSet {
|
||
/// Returns a flow graph by identifier.
|
||
pub fn flow(&self, id: &str) -> Option<&Graph> {
|
||
self.flows.iter().find(|graph| graph.id == id)
|
||
}
|
||
}
|
||
|
||
/// Loads the concept graph and every flow graph from the embedded content.
|
||
pub fn load_graph() -> GraphSet {
|
||
let concepts = crate::docs::content::get("graph/concepts.json")
|
||
.and_then(parse_graph)
|
||
.unwrap_or_else(|| Graph {
|
||
id: "concepts".to_string(),
|
||
title: "Concepts".to_string(),
|
||
title_zh: Some("概念图谱".to_string()),
|
||
summary: String::new(),
|
||
summary_zh: None,
|
||
nodes: Vec::new(),
|
||
edges: Vec::new(),
|
||
});
|
||
|
||
let mut flows: Vec<Graph> = crate::docs::content::list("flows/", ".json")
|
||
.into_iter()
|
||
.filter_map(|(_, text)| parse_graph(text))
|
||
.collect();
|
||
flows.sort_by(|left, right| left.title.cmp(&right.title));
|
||
|
||
GraphSet { concepts, flows }
|
||
}
|
||
|
||
/// Parses a graph from its JSON representation.
|
||
///
|
||
/// Returns `None` when the identifier is missing, because a graph without an
|
||
/// identifier cannot be addressed.
|
||
pub fn parse_graph(text: &str) -> Option<Graph> {
|
||
let value = json::parse(text).ok()?;
|
||
let id = value.get("id")?.as_str()?.to_string();
|
||
let title = value
|
||
.get("title")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or(&id)
|
||
.to_string();
|
||
let summary = value
|
||
.get("summary")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string();
|
||
let title_zh = text_field(&value, "title_zh");
|
||
let summary_zh = text_field(&value, "summary_zh");
|
||
|
||
let nodes = value
|
||
.get("nodes")
|
||
.map(Value::as_array)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|node| {
|
||
Some(Node {
|
||
id: node.get("id")?.as_str()?.to_string(),
|
||
label: node
|
||
.get("label")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_else(|| node.get("id").and_then(Value::as_str).unwrap_or(""))
|
||
.to_string(),
|
||
label_zh: text_field(node, "label_zh"),
|
||
kind: NodeKind::parse(
|
||
node.get("kind")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("concept"),
|
||
),
|
||
group: node
|
||
.get("group")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("general")
|
||
.to_string(),
|
||
summary: node
|
||
.get("summary")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.to_string(),
|
||
summary_zh: text_field(node, "summary_zh"),
|
||
document: node
|
||
.get("document")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
api: node.get("api").and_then(Value::as_str).map(str::to_string),
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let edges = value
|
||
.get("edges")
|
||
.map(Value::as_array)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|edge| {
|
||
Some(Edge {
|
||
source: edge.get("source")?.as_str()?.to_string(),
|
||
target: edge.get("target")?.as_str()?.to_string(),
|
||
kind: edge
|
||
.get("kind")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("relates")
|
||
.to_string(),
|
||
label: edge
|
||
.get("label")
|
||
.or_else(|| edge.get("label_en"))
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
label_zh: edge
|
||
.get("label_zh")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
Some(Graph {
|
||
id,
|
||
title,
|
||
title_zh,
|
||
summary,
|
||
summary_zh,
|
||
nodes,
|
||
edges,
|
||
})
|
||
}
|
||
|
||
/// Returns a member of a JSON object as an owned string, when it is a
|
||
/// non-empty string.
|
||
fn text_field(value: &Value, key: &str) -> Option<String> {
|
||
value
|
||
.get(key)
|
||
.and_then(Value::as_str)
|
||
.filter(|text| !text.is_empty())
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// Builds the crate structure graph from the interface catalog.
|
||
///
|
||
/// The nodes are the crates, grouped by layer, and the edges follow the
|
||
/// layer order that `apsis-rs` enforces:
|
||
/// `foundation -> physics -> orbit -> rf -> network -> ops -> sim`. A layer
|
||
/// edge is drawn from each layer to the next one that the enabled feature
|
||
/// set contains, which is what the dependency rule means in practice.
|
||
pub fn crate_structure(catalog: &ApiCatalog) -> Graph {
|
||
let layers = [
|
||
"foundation",
|
||
"physics",
|
||
"orbit",
|
||
"rf",
|
||
"network",
|
||
"ops",
|
||
"sim",
|
||
];
|
||
let mut nodes = Vec::new();
|
||
let mut edges = Vec::new();
|
||
let mut present: Vec<&str> = Vec::new();
|
||
|
||
for krate in &catalog.crates {
|
||
// A crate that belongs to no layer has no place in a diagram of the
|
||
// layer structure: the workspace facade re-exports every layer and
|
||
// would sit under a layer it is not part of. It stays on the index,
|
||
// where it is a crate like any other.
|
||
if krate.layer.is_empty() {
|
||
continue;
|
||
}
|
||
nodes.push(Node {
|
||
id: krate.name.clone(),
|
||
label: krate.name.clone(),
|
||
label_zh: None,
|
||
kind: NodeKind::Crate,
|
||
group: krate.layer.clone(),
|
||
summary: crate_summary_zh(&krate.name)
|
||
.unwrap_or_else(|| krate.summary.lines().next().unwrap_or_default().to_string()),
|
||
summary_zh: None,
|
||
document: None,
|
||
api: None,
|
||
});
|
||
edges.push(Edge {
|
||
source: krate.name.clone(),
|
||
target: krate.layer.clone(),
|
||
kind: "belongs-to".to_string(),
|
||
label: None,
|
||
label_zh: None,
|
||
});
|
||
if !present.contains(&krate.layer.as_str()) {
|
||
present.push(match layers.iter().find(|layer| **layer == krate.layer) {
|
||
Some(layer) => layer,
|
||
None => "foundation",
|
||
});
|
||
}
|
||
}
|
||
|
||
// One node per layer that the catalog contains, so the graph describes
|
||
// this build rather than the whole workspace.
|
||
for layer in layers {
|
||
if !present.contains(&layer) {
|
||
continue;
|
||
}
|
||
nodes.push(Node {
|
||
id: layer.to_string(),
|
||
label: layer.to_string(),
|
||
label_zh: None,
|
||
kind: NodeKind::Layer,
|
||
group: layer.to_string(),
|
||
summary: format!(
|
||
"The {layer} layer. A layer may use the layers to its left and no layer to its right."
|
||
),
|
||
summary_zh: Some(format!(
|
||
"{layer} 层。每一层可以依赖其左侧的层,不能依赖其右侧的层。"
|
||
)),
|
||
document: Some("architecture-apsis-rs".to_string()),
|
||
api: None,
|
||
});
|
||
}
|
||
|
||
// The dependency rule, drawn between the layers that are present.
|
||
for window in layers.windows(2) {
|
||
let (lower, upper) = (window[0], window[1]);
|
||
if present.contains(&lower) && present.contains(&upper) {
|
||
edges.push(Edge {
|
||
source: lower.to_string(),
|
||
target: upper.to_string(),
|
||
kind: "depends".to_string(),
|
||
label: Some(format!("{lower} enables {upper}")),
|
||
label_zh: Some(format!("{lower} 启用 {upper}")),
|
||
});
|
||
}
|
||
}
|
||
|
||
Graph {
|
||
id: "crates".to_string(),
|
||
title: "apsis-rs crate structure".to_string(),
|
||
title_zh: Some("apsis-rs crate 结构".to_string()),
|
||
summary: "Workspace crates grouped by layer; edges follow the one way dependency rule."
|
||
.to_string(),
|
||
summary_zh: Some("按层分组的 workspace crate;边表示单向依赖规则。".to_string()),
|
||
nodes,
|
||
edges,
|
||
}
|
||
}
|
||
|
||
/// Returns the Chinese summary of a crate, when one is authored.
|
||
///
|
||
/// The crate name is the key, so the table stays beside the graph rather
|
||
/// than in a separate translation file.
|
||
fn crate_summary_zh(name: &str) -> Option<String> {
|
||
let text = match name {
|
||
"apsis-core" => "共享基础类型:通用错误、常量与单位标记 trait。",
|
||
"apsis-units" => "物理量强类型 newtype:长度、时间、频率、角度、温度。",
|
||
"apsis-math" => "无依赖线性代数:三维矢量、三阶矩阵、四元数。",
|
||
"apsis-macros" => "支撑 workspace 的宏。",
|
||
"apsis-time" => "时间尺度、历元与日历转换。",
|
||
"apsis-leap" => "用于 UTC 转换的内嵌闰秒表。",
|
||
"apsis-coord" => "在类型中携带参考系的位置表示。",
|
||
"apsis-geodesy" => "参考椭球与大地坐标计算。",
|
||
"apsis-frames" => "参考系定义与地固系旋转。",
|
||
"apsis-tle" => "两行元素集解析。",
|
||
"apsis-sgp4" => "SGP4 与 SDP4 解析轨道传播器。",
|
||
"apsis-kepler" => "开普勒轨道元素与二体问题。",
|
||
"apsis-ephemeris" => "星历采样与插值。",
|
||
"apsis-maneuver" => "脉冲与有限推力轨道机动模型。",
|
||
"apsis-orbit" => "轨道层统一入口:元素、传播与机动。",
|
||
_ => return None,
|
||
};
|
||
Some(text.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const SAMPLE: &str = r#"{
|
||
"id": "concepts",
|
||
"title": "Concepts",
|
||
"summary": "The concept map.",
|
||
"nodes": [
|
||
{"id": "sgp4", "label": "SGP4", "kind": "concept", "group": "propagation", "summary": "Analytical model.", "document": "sgp4"},
|
||
{"id": "tle", "label": "TLE", "kind": "data", "group": "propagation", "summary": "Element set.", "document": "tle"}
|
||
],
|
||
"edges": [
|
||
{"source": "sgp4", "target": "tle", "kind": "consumes", "label": "reads"}
|
||
]
|
||
}"#;
|
||
|
||
#[test]
|
||
fn parses_a_graph() {
|
||
let graph = parse_graph(SAMPLE).unwrap();
|
||
assert_eq!(graph.id, "concepts");
|
||
assert_eq!(graph.nodes.len(), 2);
|
||
assert_eq!(graph.edges.len(), 1);
|
||
assert_eq!(graph.node("sgp4").unwrap().kind, NodeKind::Concept);
|
||
assert_eq!(graph.node("tle").unwrap().kind, NodeKind::Data);
|
||
assert_eq!(graph.outgoing("sgp4").count(), 1);
|
||
assert!(graph.dangling_edges().is_empty());
|
||
assert_eq!(graph.groups(), vec!["propagation"]);
|
||
}
|
||
|
||
#[test]
|
||
fn reports_dangling_edges() {
|
||
let text = r#"{"id":"x","nodes":[],"edges":[{"source":"a","target":"b","kind":"k"}]}"#;
|
||
let graph = parse_graph(text).unwrap();
|
||
assert_eq!(graph.dangling_edges().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn crate_structure_follows_the_layer_order() {
|
||
let catalog = crate::docs::load_api_catalog();
|
||
let graph = crate_structure(&catalog);
|
||
assert!(!graph.nodes.is_empty());
|
||
assert!(graph.dangling_edges().is_empty());
|
||
for edge in &graph.edges {
|
||
assert_ne!(edge.source, edge.target);
|
||
}
|
||
}
|
||
}
|