//! 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, /// 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, /// Identifier of the document that explains the node, when one exists. pub document: Option, /// Identifier of the interface item, when the node names one. pub api: Option, } /// 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, /// The same label in Chinese, when one is authored. pub label_zh: Option, } /// 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, /// One sentence description in English. pub summary: String, /// One sentence description in Chinese, when one is authored. pub summary_zh: Option, /// Nodes. pub nodes: Vec, /// Edges. pub edges: Vec, } 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 { 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 { 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, } 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 = 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 { 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 { 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 { 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); } } }