Skip to content

Commit a3c38d3

Browse files
committed
feat: oval knowledge graph layout and focused node label fix
Stretch the graph into an ellipse when horizontal space exceeds vertical space (aspect > 1.15), using separate x/y radius steps so nodes and orbit rings fill the available terminal width. Adds draw_ellipse_screen alongside the existing draw_circle_screen. Fix focused node label truncation: when cycling focus the selected node now always renders its full title regardless of depth, matching the behaviour already in place for Mission/Watch/Heartbeat nodes. Add knowledge graph zoom-out and zoom-in screenshots to the root README.
1 parent 5da57b7 commit a3c38d3

4 files changed

Lines changed: 85 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Welcome to Keel. This is not a notes app with automation bolted on; it is a boar
1111
<img src="docs/images/keel-flow.png" alt="Keel flow dashboard" width="32%" />
1212
<img src="docs/images/keel-flow-scene.png" alt="Keel flow scene" width="32%" />
1313
<img src="docs/images/keel-workshop-scene.png" alt="Keel workshop scene" width="32%" />
14+
<img src="docs/images/keel-knowledge-graph-zoom-out.png" alt="Keel knowledge graph zoom out" width="32%" />
15+
<img src="docs/images/keel-knowledge-graph-zoom-in.png" alt="Keel knowledge graph zoom in" width="32%" />
1416
</p>
1517

1618
---

crates/keel-cli/src/cli/presentation/knowledge_graph.rs

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -607,12 +607,23 @@ fn layout_positions<'a>(
607607
.max(1);
608608
let center_x = (chart.canvas.pixel_width() / 2) as isize;
609609
let center_y = (chart.canvas.pixel_height() / 2) as isize;
610-
let min_dimension = chart.canvas.pixel_width().min(chart.canvas.pixel_height()) as f64;
610+
let px_w = chart.canvas.pixel_width() as f64;
611+
let px_h = chart.canvas.pixel_height() as f64;
612+
let min_dimension = px_w.min(px_h);
611613
let margin = (min_dimension * 0.12).clamp(4.0, 12.0);
612-
let max_radius = (chart.canvas.pixel_width().min(chart.canvas.pixel_height()) as f64 / 2.0
613-
- margin)
614-
.max(8.0);
615-
let radius_step = max_radius / (max_depth as f64 + 0.35);
614+
let base_radius = (min_dimension / 2.0 - margin).max(8.0);
615+
let radius_step = base_radius / (max_depth as f64 + 0.35);
616+
617+
// Use an oval layout when there is meaningfully more horizontal space than
618+
// vertical space, stretching the x-axis radius up to the available width.
619+
let aspect = px_w / px_h.max(1.0);
620+
let (x_radius_step, y_radius_step) = if aspect > 1.15 {
621+
let max_x_radius = (px_w / 2.0 - margin).max(8.0);
622+
let x_step = max_x_radius / (max_depth as f64 + 0.35);
623+
(x_step, radius_step)
624+
} else {
625+
(radius_step, radius_step)
626+
};
616627

617628
let mut weights = HashMap::new();
618629
let mut positions = HashMap::new();
@@ -632,7 +643,8 @@ fn layout_positions<'a>(
632643
TAU,
633644
center_x,
634645
center_y,
635-
radius_step,
646+
x_radius_step,
647+
y_radius_step,
636648
&child_map,
637649
&mut weights,
638650
&mut positions,
@@ -648,7 +660,8 @@ fn assign_child_positions<'a>(
648660
angle_span: f64,
649661
center_x: isize,
650662
center_y: isize,
651-
radius_step: f64,
663+
x_radius_step: f64,
664+
y_radius_step: f64,
652665
child_map: &HashMap<String, Vec<&'a KnowledgeGraphViewNode>>,
653666
weights: &mut HashMap<String, usize>,
654667
positions: &mut HashMap<String, PositionedNode<'a>>,
@@ -674,9 +687,9 @@ fn assign_child_positions<'a>(
674687
angle_span * weight as f64 / total_weight as f64
675688
};
676689
let angle = cursor + child_span / 2.0;
677-
let radius = radius_step * child.depth as f64;
678-
let x = center_x as f64 + radius * angle.cos();
679-
let y = center_y as f64 + radius * angle.sin();
690+
let depth = child.depth as f64;
691+
let x = center_x as f64 + x_radius_step * depth * angle.cos();
692+
let y = center_y as f64 + y_radius_step * depth * angle.sin();
680693

681694
positions.insert(
682695
child.id.clone(),
@@ -699,7 +712,8 @@ fn assign_child_positions<'a>(
699712
child_sector,
700713
center_x,
701714
center_y,
702-
radius_step,
715+
x_radius_step,
716+
y_radius_step,
703717
child_map,
704718
weights,
705719
positions,
@@ -738,23 +752,30 @@ fn draw_orbits(
738752
) {
739753
let center_x = (chart.canvas.pixel_width() / 2) as isize;
740754
let center_y = (chart.canvas.pixel_height() / 2) as isize;
741-
let mut depth_radii: BTreeMap<usize, Vec<isize>> = projection
755+
756+
// Accumulate (|dx|, |dy|) per depth so we can draw an ellipse that fits
757+
// the actual node layout, which may be oval when horizontal space allows.
758+
let mut depth_axes: BTreeMap<usize, Vec<(isize, isize)>> = projection
742759
.nodes
743760
.iter()
744761
.filter(|node| node.kind != KnowledgeGraphNodeKind::World && node.depth > 0)
745762
.filter_map(|node| positions.get(&node.id))
746763
.fold(BTreeMap::new(), |mut acc, position| {
747-
let dx = position.x - center_x;
748-
let dy = position.y - center_y;
749-
let radius = (((dx * dx + dy * dy) as f64).sqrt().round() as isize).max(1);
750-
acc.entry(position.node.depth).or_default().push(radius);
764+
let dx = (position.x - center_x).abs();
765+
let dy = (position.y - center_y).abs();
766+
acc.entry(position.node.depth).or_default().push((dx, dy));
751767
acc
752768
});
753769

754-
for radii in depth_radii.values_mut() {
755-
let total: isize = radii.iter().copied().sum();
756-
let radius = (total as f64 / radii.len() as f64).round() as isize;
757-
draw_circle_screen(chart, center_x, center_y, radius, Some(Color::BrightBlack));
770+
for axes in depth_axes.values_mut() {
771+
let count = axes.len() as f64;
772+
let rx = (axes.iter().map(|(dx, _)| *dx).sum::<isize>() as f64 / count).round() as isize;
773+
let ry = (axes.iter().map(|(_, dy)| *dy).sum::<isize>() as f64 / count).round() as isize;
774+
if rx == ry {
775+
draw_circle_screen(chart, center_x, center_y, rx, Some(Color::BrightBlack));
776+
} else {
777+
draw_ellipse_screen(chart, center_x, center_y, rx, ry, Some(Color::BrightBlack));
778+
}
758779
}
759780
}
760781

@@ -963,12 +984,17 @@ fn draw_labels(
963984
continue;
964985
}
965986

966-
let label = if matches!(
967-
positioned.node.kind,
968-
KnowledgeGraphNodeKind::Mission
969-
| KnowledgeGraphNodeKind::Watch
970-
| KnowledgeGraphNodeKind::Heartbeat
971-
) {
987+
let is_focused = projection
988+
.focus
989+
.as_ref()
990+
.is_some_and(|focus| focus.id == positioned.node.id);
991+
let label = if is_focused
992+
|| matches!(
993+
positioned.node.kind,
994+
KnowledgeGraphNodeKind::Mission
995+
| KnowledgeGraphNodeKind::Watch
996+
| KnowledgeGraphNodeKind::Heartbeat
997+
) {
972998
positioned.node.title.clone()
973999
} else {
9741000
depth_label_text(
@@ -1126,6 +1152,37 @@ fn draw_circle_screen(
11261152
}
11271153
}
11281154

1155+
fn draw_ellipse_screen(
1156+
chart: &mut ChartContext,
1157+
center_x: isize,
1158+
center_y: isize,
1159+
rx: isize,
1160+
ry: isize,
1161+
color: Option<Color>,
1162+
) {
1163+
if rx <= 0 || ry <= 0 {
1164+
chart
1165+
.canvas
1166+
.set_pixel_screen(center_x.max(0) as usize, center_y.max(0) as usize, color);
1167+
return;
1168+
}
1169+
1170+
let segments = (rx.max(ry) * 8).clamp(24, 144) as usize;
1171+
let mut previous = None;
1172+
for step in 0..=segments {
1173+
let angle = TAU * step as f64 / segments as f64;
1174+
let x = center_x as f64 + rx as f64 * angle.cos();
1175+
let y = center_y as f64 + ry as f64 * angle.sin();
1176+
let current = (x.round() as isize, y.round() as isize);
1177+
if let Some((prev_x, prev_y)) = previous {
1178+
chart
1179+
.canvas
1180+
.line_screen(prev_x, prev_y, current.0, current.1, color);
1181+
}
1182+
previous = Some(current);
1183+
}
1184+
}
1185+
11291186
fn draw_circle_filled_screen(
11301187
chart: &mut ChartContext,
11311188
center_x: isize,
841 KB
Loading
838 KB
Loading

0 commit comments

Comments
 (0)