feat: chart wheel visualization (Phase G)

Add an interactive natal chart wheel widget rendered via cairo/pango
(app/src/chart_wheel.rs, core/src/chart_geometry.rs), mounted above the
aspect list on both the returning-user cold-start path and the
lazy-mount path for fresh users. Wheel taps route through the same
FeedActivated nav path the feed cards use.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-24 18:58:29 +02:00
parent 164a749e4d
commit 1f987f200f
8 changed files with 1130 additions and 0 deletions
Generated
+28
View File
@@ -4111,6 +4111,32 @@ dependencies = [
"system-deps",
]
[[package]]
name = "pangocairo"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b36c5c84304072939d860595d9bda2a797d3bd6f7215e20b8ccd0e72d84da8c8"
dependencies = [
"cairo-rs",
"glib",
"libc",
"pango",
"pangocairo-sys",
]
[[package]]
name = "pangocairo-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadbb01ad38be76e0d37e329d40ba0f3f9ef261d7b84b05201d7a0f14f819406"
dependencies = [
"cairo-sys-rs",
"glib-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "papaya"
version = "0.2.4"
@@ -7312,6 +7338,8 @@ dependencies = [
"hex",
"libadwaita",
"p2panda-core",
"pango",
"pangocairo",
"relm4",
"serde",
"tokio",
+2
View File
@@ -31,6 +31,8 @@ tokio.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
gio = "0.21"
pango = "0.21"
pangocairo = "0.21"
hex = "0.4"
tzf-rs = "0.4"
chrono = "0.4"
+56
View File
@@ -1904,6 +1904,34 @@ impl AsyncComponent for AppModel {
if !self.on_setup_page && widgets.chart_container.first_child().is_none() {
if let Some(chart) = &self.chart {
// Phase G: chart wheel on top of the aspect list. Wheel
// taps emit `Activated { interp_key }` which we route via
// the same FeedActivated nav path the feed cards use.
let chart_rc = Rc::new(chart.clone());
let s_for_wheel = sender.clone();
let (wheel, _wheel_state) = crate::chart_wheel::launch(
crate::chart_wheel::ChartWheelInit {
chart: chart_rc,
overlay_chart: None,
show_aspect_lines: true,
show_houses: true,
},
move |out| match out {
crate::chart_wheel::ChartWheelOut::Activated { interp_key } => {
s_for_wheel.input(AppMsg::FeedActivated {
event_id: [0u8; 32],
payload: crate::feed_view::ActivatedPayload::OpenInterpKey(
interp_key.to_sig(),
),
});
}
},
);
wheel.set_size_request(360, 360);
wheel.set_margin_top(8);
wheel.set_margin_bottom(8);
widgets.chart_container.append(&wheel);
let nav = aspect_view::launch(aspect_view::AspectViewInit {
kind: aspect_view::AspectViewKind::Natal,
items: aspect_list::natal_items(&chart.natal_aspects()),
@@ -2688,6 +2716,34 @@ fn build_widgets(
// Populate aspect views for returning users with an existing chart.
if let Some(chart) = &model.chart {
// Phase G: wheel above aspect list (same pattern as update_view's
// lazy mount for fresh users — wired here so returning users see it
// on cold start too).
let chart_rc = Rc::new(chart.clone());
let s_for_wheel = sender.clone();
let (wheel, _wheel_state) = crate::chart_wheel::launch(
crate::chart_wheel::ChartWheelInit {
chart: chart_rc,
overlay_chart: None,
show_aspect_lines: true,
show_houses: true,
},
move |out| match out {
crate::chart_wheel::ChartWheelOut::Activated { interp_key } => {
s_for_wheel.input(AppMsg::FeedActivated {
event_id: [0u8; 32],
payload: crate::feed_view::ActivatedPayload::OpenInterpKey(
interp_key.to_sig(),
),
});
}
},
);
wheel.set_size_request(360, 360);
wheel.set_margin_top(8);
wheel.set_margin_bottom(8);
chart_container.append(&wheel);
let nav = aspect_view::launch(aspect_view::AspectViewInit {
kind: aspect_view::AspectViewKind::Natal,
items: aspect_list::natal_items(&chart.natal_aspects()),
+538
View File
@@ -0,0 +1,538 @@
//! `ChartWheel` — Cairo-rendered natal chart wheel.
//!
//! One widget, three immediate consumers:
//! - Chart tab: primary view above the aspect list.
//! - Stargazer page: peer's wheel with optional synastry overlay.
//! - Aspect detail pages: highlighted-subset render.
//!
//! # Layout
//!
//! Concentric rings, outermost first:
//! 1. **Sign ring** — 12 wedges with element-color tints + sign glyphs.
//! 2. **House ring** — radial cusp spokes + house numbers.
//! 3. **Planet ring** — natal planet glyphs at their ecliptic longitudes.
//! 4. **Overlay ring** — optional (synastry / transit overlay).
//! 5. **Aspect web** — lines between aspecting planets, color-coded.
//!
//! The pure [`draw_wheel`] fn does all rendering; the widget wraps it in
//! a `gtk::DrawingArea` with theme reactivity + hit testing.
use std::cell::RefCell;
use std::rc::Rc;
use libadwaita::gtk;
use libadwaita::gtk::prelude::*;
use libadwaita::gtk::cairo;
use libadwaita::gtk::pango;
use zodia_core::{Aspect, AspectKind, Chart, InterpKey, Planet, PlanetPositions};
use zodia_core::chart_geometry::{
Cluster, cluster_conjunctions, house_cusp_angles, longitude_to_render_angle,
};
// ── public api ────────────────────────────────────────────────────────────────
pub struct ChartWheelInit {
pub chart: Rc<Chart>,
pub overlay_chart: Option<Rc<Chart>>,
pub show_aspect_lines: bool,
pub show_houses: bool,
}
#[derive(Debug, Clone)]
pub enum ChartWheelOut {
/// User tapped a navigable element. Parent maps to the detail-page
/// nav flow used by feed cards.
Activated { interp_key: InterpKey },
}
/// Spawn a chart wheel. Returns the `DrawingArea` widget + a callback the
/// caller invokes when the chart changes (e.g. on a re-compute).
pub fn launch<F>(init: ChartWheelInit, mut on_activate: F)
-> (gtk::DrawingArea, Rc<RefCell<WheelState>>)
where F: FnMut(ChartWheelOut) + 'static,
{
let state = Rc::new(RefCell::new(WheelState {
chart: init.chart,
overlay_chart: init.overlay_chart,
show_aspect_lines: init.show_aspect_lines,
show_houses: init.show_houses,
hit_targets: Vec::new(),
}));
let area = gtk::DrawingArea::new();
area.set_hexpand(true);
area.set_vexpand(true);
area.set_content_width(360);
area.set_content_height(360);
// Theme reactivity: redraw on theme change.
if let Some(settings) = gtk::Settings::default() {
let area_w = area.clone();
settings.connect_notify_local(Some("gtk-theme-name"), move |_, _| {
area_w.queue_draw();
});
let area_w = area.clone();
settings.connect_notify_local(Some("gtk-application-prefer-dark-theme"), move |_, _| {
area_w.queue_draw();
});
}
let st_draw = Rc::clone(&state);
let area_for_palette = area.clone();
area.set_draw_func(move |_da, cr, w, h| {
let palette = palette_from_widget(&area_for_palette);
let size = w.min(h) as f64;
let mut s = st_draw.borrow_mut();
let opts = DrawOpts {
show_aspect_lines: s.show_aspect_lines,
show_houses: s.show_houses,
min_orb_opacity: 0.2,
conjunction_fan_threshold_deg: 6.0,
};
// Centre the wheel in the available area.
cr.translate((w as f64 - size) * 0.5, (h as f64 - size) * 0.5);
let targets = draw_wheel(
cr, size, &s.chart, s.overlay_chart.as_deref(), &palette, &opts,
);
s.hit_targets = targets;
});
// Hit-testing: click → resolve to a hit_target → emit Activated.
let click = gtk::GestureClick::new();
let st_click = Rc::clone(&state);
let area_for_click = area.clone();
let on_activate_cell: Rc<RefCell<Box<dyn FnMut(ChartWheelOut)>>> =
Rc::new(RefCell::new(Box::new(move |o| on_activate(o))));
let cb = Rc::clone(&on_activate_cell);
click.connect_released(move |g, _n, x, y| {
g.set_state(gtk::EventSequenceState::Claimed);
let w = area_for_click.width() as f64;
let h = area_for_click.height() as f64;
let size = w.min(h);
// Undo the centring translate the draw fn applied.
let cx = x - (w - size) * 0.5;
let cy = y - (h - size) * 0.5;
let s = st_click.borrow();
if let Some(t) = hit_test(&s.hit_targets, cx, cy) {
(cb.borrow_mut())(ChartWheelOut::Activated { interp_key: t });
}
});
area.add_controller(click);
(area, state)
}
/// Mutable widget state held in the `Rc<RefCell<_>>` returned by [`launch`].
/// Callers can swap `chart` / `overlay_chart` and then call
/// [`queue_redraw`] on the DrawingArea to repaint.
pub struct WheelState {
pub chart: Rc<Chart>,
pub overlay_chart: Option<Rc<Chart>>,
pub show_aspect_lines: bool,
pub show_houses: bool,
/// Filled by `draw_wheel` on every repaint. Hit-testing scans these.
hit_targets: Vec<HitTarget>,
}
// ── palette ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub fg: (f64, f64, f64, f64),
pub bg: (f64, f64, f64, f64),
pub dim: (f64, f64, f64, f64),
pub accent: (f64, f64, f64, f64),
pub fire: (f64, f64, f64, f64),
pub earth: (f64, f64, f64, f64),
pub air: (f64, f64, f64, f64),
pub water: (f64, f64, f64, f64),
pub harmonious: (f64, f64, f64, f64),
pub challenging: (f64, f64, f64, f64),
pub neutral: (f64, f64, f64, f64),
}
impl Palette {
/// Sensible default for tests and headless rendering. Dark-theme palette.
pub fn default_dark() -> Self {
Self {
fg: (0.92, 0.92, 0.92, 1.00),
bg: (0.10, 0.10, 0.12, 1.00),
dim: (0.55, 0.55, 0.58, 1.00),
accent: (0.35, 0.68, 0.96, 1.00),
fire: (0.78, 0.22, 0.18, 0.18),
earth: (0.30, 0.55, 0.32, 0.18),
air: (0.96, 0.86, 0.36, 0.18),
water: (0.30, 0.55, 0.85, 0.18),
harmonious: (0.40, 0.78, 0.45, 0.85),
challenging: (0.86, 0.36, 0.36, 0.85),
neutral: (0.60, 0.60, 0.62, 0.65),
}
}
}
fn palette_from_widget(w: &gtk::DrawingArea) -> Palette {
let fg = w.color();
// GTK4's `color()` is the only theme-aware color accessible without the
// deprecated StyleContext API. Detect dark-theme by fg brightness;
// pick the matching palette + override fg with the live value.
let dark = (fg.red() + fg.green() + fg.blue()) > 1.5;
let mut p = if dark { Palette::default_dark() } else { Palette::default_light() };
p.fg = rgba_to_tuple(fg);
p
}
impl Palette {
/// Light-theme companion to [`default_dark`].
pub fn default_light() -> Self {
Self {
fg: (0.10, 0.10, 0.10, 1.00),
bg: (0.98, 0.97, 0.96, 1.00),
dim: (0.45, 0.45, 0.45, 1.00),
accent: (0.20, 0.45, 0.78, 1.00),
fire: (0.88, 0.32, 0.22, 0.16),
earth: (0.36, 0.58, 0.34, 0.16),
air: (0.96, 0.82, 0.34, 0.16),
water: (0.30, 0.52, 0.82, 0.16),
harmonious: (0.18, 0.62, 0.32, 0.80),
challenging: (0.84, 0.28, 0.28, 0.80),
neutral: (0.50, 0.50, 0.50, 0.55),
}
}
}
fn rgba_to_tuple(c: gtk::gdk::RGBA) -> (f64, f64, f64, f64) {
(c.red() as f64, c.green() as f64, c.blue() as f64, c.alpha() as f64)
}
// ── draw options ─────────────────────────────────────────────────────────────
pub struct DrawOpts {
pub show_aspect_lines: bool,
pub show_houses: bool,
/// Minimum opacity for aspect lines (tightest orbs render at 1.0).
pub min_orb_opacity: f64,
/// Planets within this angular distance get fanned out on the planet
/// ring so their glyphs don't overlap.
pub conjunction_fan_threshold_deg: f64,
}
// ── hit targets ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct HitTarget {
pub kind: HitKind,
/// Screen-space centre of the target's hit region.
pub cx: f64,
pub cy: f64,
/// Hit-radius in pixels (for planet glyphs).
pub radius: f64,
/// Or, for line-shaped targets, the start/end points; we hit-test by
/// point-line distance.
pub line_end: Option<(f64, f64)>,
pub interp_key: InterpKey,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HitKind { Planet, Aspect }
fn hit_test(targets: &[HitTarget], x: f64, y: f64) -> Option<InterpKey> {
// Planets win over aspect lines when both contain the click.
let mut best_planet: Option<&HitTarget> = None;
let mut best_aspect: Option<(f64, &HitTarget)> = None;
for t in targets {
match t.kind {
HitKind::Planet => {
let dx = x - t.cx; let dy = y - t.cy;
if dx * dx + dy * dy <= t.radius * t.radius {
best_planet = Some(t);
}
}
HitKind::Aspect => {
let Some((ex, ey)) = t.line_end else { continue; };
let d = point_line_distance(x, y, t.cx, t.cy, ex, ey);
if d <= 6.0 {
match best_aspect {
None => best_aspect = Some((d, t)),
Some((bd, _)) if d < bd => best_aspect = Some((d, t)),
_ => {}
}
}
}
}
}
best_planet
.or(best_aspect.map(|(_, t)| t))
.map(|t| t.interp_key.clone())
}
fn point_line_distance(px: f64, py: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
let dx = x2 - x1; let dy = y2 - y1;
let len_sq = dx * dx + dy * dy;
if len_sq <= 1e-9 { return ((px - x1).powi(2) + (py - y1).powi(2)).sqrt(); }
let t = (((px - x1) * dx) + ((py - y1) * dy)) / len_sq;
let t = t.clamp(0.0, 1.0);
let cx = x1 + t * dx; let cy = y1 + t * dy;
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
}
// ── pure draw fn ─────────────────────────────────────────────────────────────
/// Render the wheel into `cr`. Returns the set of hit targets the widget
/// scans on click. Pure modulo the Cairo side-effects.
pub fn draw_wheel(
cr: &cairo::Context,
size: f64,
chart: &Chart,
overlay: Option<&Chart>,
pal: &Palette,
opts: &DrawOpts,
) -> Vec<HitTarget> {
let mut targets: Vec<HitTarget> = Vec::new();
let cx = size * 0.5;
let cy = size * 0.5;
let r_outer = size * 0.48;
let r_sign_in = size * 0.42;
let r_house_in = size * 0.36;
let r_planet = size * 0.30;
let r_overlay = size * 0.345;
let r_web_max = size * 0.27;
let glyph_r = size * 0.035;
let asc = chart.houses.ascendant;
// ── sign ring background ─────────────────────────────────────────────────
for sign_idx in 0..12 {
let lon_start = sign_idx as f64 * 30.0;
let lon_end = lon_start + 30.0;
let a_start = longitude_to_render_angle(lon_start, asc);
let a_end = longitude_to_render_angle(lon_end, asc);
// Render angle decreases counter-clockwise; Cairo arc takes
// (start, end) clockwise — so swap.
let tint = sign_element_color(sign_idx, pal);
set_rgba(cr, tint);
cr.move_to(cx, cy);
cr.arc(cx, cy, r_outer, a_end, a_start);
cr.arc_negative(cx, cy, r_sign_in, a_start, a_end);
cr.close_path();
let _ = cr.fill();
// Sign glyph at wedge centre.
let a_mid = longitude_to_render_angle(lon_start + 15.0, asc);
let r_mid = (r_outer + r_sign_in) * 0.5;
let gx = cx + r_mid * a_mid.cos();
let gy = cy - r_mid * a_mid.sin();
set_rgba(cr, pal.fg);
let glyph = SIGN_GLYPHS[sign_idx];
center_text(cr, glyph, gx, gy, size * 0.045);
}
// Outer + inner ring strokes.
cr.set_line_width(size * 0.003);
set_rgba(cr, pal.dim);
cr.arc(cx, cy, r_outer, 0.0, std::f64::consts::TAU);
let _ = cr.stroke();
cr.arc(cx, cy, r_sign_in, 0.0, std::f64::consts::TAU);
let _ = cr.stroke();
// ── house ring ───────────────────────────────────────────────────────────
if opts.show_houses {
let cusps = chart.houses.cusps;
let angles = house_cusp_angles(&cusps, asc);
for i in 0..12 {
let a = angles[i];
let x0 = cx + r_sign_in * a.cos();
let y0 = cy - r_sign_in * a.sin();
let x1 = cx + r_house_in * a.cos();
let y1 = cy - r_house_in * a.sin();
set_rgba(cr, pal.dim);
cr.set_line_width(size * 0.002);
cr.move_to(x0, y0);
cr.line_to(x1, y1);
let _ = cr.stroke();
// House number at the cusp midpoint (angular).
let next = (i + 1) % 12;
let mut a_mid = (a + angles[next]) * 0.5;
// Wrap correction across the discontinuity.
if (a - angles[next]).abs() > std::f64::consts::PI {
a_mid += std::f64::consts::PI;
}
let r_label = (r_sign_in + r_house_in) * 0.5;
let lx = cx + r_label * a_mid.cos();
let ly = cy - r_label * a_mid.sin();
set_rgba(cr, pal.dim);
center_text(cr, &format!("{}", i + 1), lx, ly, size * 0.028);
}
cr.arc(cx, cy, r_house_in, 0.0, std::f64::consts::TAU);
cr.set_line_width(size * 0.0015);
set_rgba(cr, pal.dim);
let _ = cr.stroke();
}
// ── planet positions ─────────────────────────────────────────────────────
let positions = planet_positions_sorted(&chart.positions);
let clusters = cluster_conjunctions(&positions, opts.conjunction_fan_threshold_deg);
let placed = fan_clusters(&clusters, asc, r_planet, glyph_r, size);
for (planet, lon, gx_off, gy_off) in &placed {
let a = longitude_to_render_angle(*lon, asc);
let bx = cx + r_planet * a.cos() + gx_off;
let by = cy - r_planet * a.sin() + gy_off;
// Glyph background disc.
set_rgba(cr, pal.bg);
cr.arc(bx, by, glyph_r, 0.0, std::f64::consts::TAU);
let _ = cr.fill();
set_rgba(cr, pal.fg);
cr.set_line_width(size * 0.002);
cr.arc(bx, by, glyph_r, 0.0, std::f64::consts::TAU);
let _ = cr.stroke();
center_text(cr, planet.symbol(), bx, by, size * 0.045);
targets.push(HitTarget {
kind: HitKind::Planet,
cx: bx, cy: by, radius: glyph_r * 1.2,
line_end: None,
interp_key: InterpKey::PlacementSign {
planet: *planet,
sign: ((lon.rem_euclid(360.0)) / 30.0) as u8,
},
});
}
// ── overlay ring ─────────────────────────────────────────────────────────
if let Some(o) = overlay {
let opos = planet_positions_sorted(&o.positions);
for (planet, lon) in &opos {
let a = longitude_to_render_angle(*lon, asc);
let bx = cx + r_overlay * a.cos();
let by = cy - r_overlay * a.sin();
set_rgba(cr, pal.bg);
cr.arc(bx, by, glyph_r * 0.85, 0.0, std::f64::consts::TAU);
let _ = cr.fill();
set_rgba(cr, pal.accent);
cr.set_line_width(size * 0.002);
cr.arc(bx, by, glyph_r * 0.85, 0.0, std::f64::consts::TAU);
let _ = cr.stroke();
center_text(cr, planet.symbol(), bx, by, size * 0.038);
}
}
// ── aspect web ───────────────────────────────────────────────────────────
if opts.show_aspect_lines {
let aspects: Vec<Aspect> = chart.natal_aspects();
for a in &aspects {
let lon_a = chart.positions.0.get(&a.body_a).copied().unwrap_or(0.0);
let lon_b = chart.positions.0.get(&a.body_b).copied().unwrap_or(0.0);
let ang_a = longitude_to_render_angle(lon_a, asc);
let ang_b = longitude_to_render_angle(lon_b, asc);
let ax = cx + r_web_max * ang_a.cos();
let ay = cy - r_web_max * ang_a.sin();
let bx = cx + r_web_max * ang_b.cos();
let by = cy - r_web_max * ang_b.sin();
let color = aspect_color(a.kind, pal);
let max_orb = a.kind.default_orb().max(0.5);
let intensity = (1.0 - (a.orb / max_orb)).clamp(opts.min_orb_opacity, 1.0);
let (r, g, b, ca) = color;
set_rgba(cr, (r, g, b, ca * intensity));
cr.set_line_width(size * 0.0025);
cr.move_to(ax, ay);
cr.line_to(bx, by);
let _ = cr.stroke();
targets.push(HitTarget {
kind: HitKind::Aspect,
cx: ax, cy: ay,
radius: 0.0,
line_end: Some((bx, by)),
interp_key: InterpKey::Natal { aspect_sig: a.sig() },
});
}
}
targets
}
fn fan_clusters(
clusters: &[Cluster],
asc: f64,
r_planet: f64,
glyph_r: f64,
size: f64,
) -> Vec<(Planet, f64, f64, f64)> {
let mut out: Vec<(Planet, f64, f64, f64)> = Vec::new();
for c in clusters {
let n = c.planets.len();
if n == 1 {
let (p, l) = c.planets[0];
out.push((p, l, 0.0, 0.0));
continue;
}
// Fan along the radial direction at each planet's longitude:
// shift outward by `(i - (n-1)/2) * step`.
let step = glyph_r * 0.9;
for (i, (p, l)) in c.planets.iter().enumerate() {
let offset_idx = i as f64 - (n as f64 - 1.0) * 0.5;
let a = longitude_to_render_angle(*l, asc);
let dx = offset_idx * step * a.cos();
let dy = -offset_idx * step * a.sin();
out.push((*p, *l, dx, dy));
}
let _ = size; // reserved for future radial-bound clamping
let _ = r_planet;
}
out
}
fn planet_positions_sorted(pp: &PlanetPositions) -> Vec<(Planet, f64)> {
let mut v: Vec<(Planet, f64)> = pp.0.iter().map(|(p, l)| (*p, *l)).collect();
v.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
v
}
fn set_rgba(cr: &cairo::Context, c: (f64, f64, f64, f64)) {
cr.set_source_rgba(c.0, c.1, c.2, c.3);
}
/// Render `txt` centered at `(x, y)` using Pango so font fallback covers
/// astrological glyphs not in the primary font. Strips the U+FE0E variation
/// selector that `Planet::symbol` injects for text-style preference, since
/// it makes fonts without the codepoint render tofu instead of falling back.
fn center_text(cr: &cairo::Context, txt: &str, x: f64, y: f64, size_px: f64) {
let clean: String = txt.chars().filter(|c| *c != '\u{FE0E}').collect();
let layout = pangocairo::functions::create_layout(cr);
// Family chain: primary sans + symbol fonts that ship with most
// Linux/macOS/Windows installs. Pango picks the first family that
// covers each codepoint, so planets fall through to symbol fonts.
let mut desc = pango::FontDescription::from_string(
"Noto Sans, Noto Sans Symbols, Noto Sans Symbols2, Symbola, \
DejaVu Sans, sans-serif",
);
desc.set_absolute_size(size_px * pango::SCALE as f64);
layout.set_font_description(Some(&desc));
layout.set_text(&clean);
let (w_pango, h_pango) = layout.size();
let w = w_pango as f64 / pango::SCALE as f64;
let h = h_pango as f64 / pango::SCALE as f64;
cr.move_to(x - w * 0.5, y - h * 0.5);
pangocairo::functions::show_layout(cr, &layout);
}
fn aspect_color(k: AspectKind, p: &Palette) -> (f64, f64, f64, f64) {
match k {
AspectKind::Trine | AspectKind::Sextile => p.harmonious,
AspectKind::Square | AspectKind::Opposition
| AspectKind::Quincunx => p.challenging,
AspectKind::Conjunction | AspectKind::SemiSextile => p.neutral,
}
}
fn sign_element_color(sign_idx: usize, p: &Palette) -> (f64, f64, f64, f64) {
// Aries=0 → Fire; Taurus=1 → Earth; Gemini=2 → Air; Cancer=3 → Water; cycle.
match sign_idx % 4 {
0 => p.fire,
1 => p.earth,
2 => p.air,
_ => p.water,
}
}
const SIGN_GLYPHS: [&str; 12] = [
"", "", "", "", "", "", "", "", "", "", "", "",
];
+1
View File
@@ -7,6 +7,7 @@ mod app;
mod aspect_list;
mod aspect_view;
mod baseline;
mod chart_wheel;
mod feed_item;
mod feed_view;
mod interp_row;
+202
View File
@@ -0,0 +1,202 @@
//! Pure angle math for chart-wheel rendering.
//!
//! No GTK, no Cairo, no rendering target — this module's job is to translate
//! ecliptic-longitude data + house cusps into the angular space the wheel
//! widget consumes. The widget calls these functions during its draw pass;
//! tests can call them in isolation to assert geometric invariants.
//!
//! # Convention
//!
//! Western astrology renders the Ascendant at the 9-o'clock position (left
//! side of the wheel) with longitudes increasing counter-clockwise. In
//! screen coordinates the Y axis points down, so the conversion negates the
//! sweep direction relative to a math-convention plot.
use std::f64::consts::PI;
use crate::Planet;
/// Convert an ecliptic longitude (degrees, 0..360) to a render angle in
/// radians, rotated so the chart's Ascendant lands at the 9-o'clock
/// position of the wheel. Output range is `[-π, π]`; downstream callers
/// use it via `(cos θ, -sin θ)` (the negated sin accounts for the screen
/// Y-down convention so math-positive angles map to visual CCW).
///
/// Convention: house 1 lies *below* the ASC (counter-clockwise from it on
/// screen). House cusps progress CCW around the wheel as longitudes
/// increase, so:
///
/// render_angle = π + (lon - ascendant_lon)·π/180
///
/// `lon = ascendant` → `π` (= 9-o'clock = ASC). `lon = ascendant + 30`
/// (1st-house contents toward 2nd cusp) → angle in the lower-left
/// quadrant. `lon = ascendant + 180` → `0` (= 3-o'clock = DSC).
pub fn longitude_to_render_angle(lon_deg: f64, ascendant_deg: f64) -> f64 {
let delta = (lon_deg - ascendant_deg).to_radians();
let raw = PI + delta;
// Wrap to [-π, π] so callers can compare without re-normalising.
let mut a = raw;
while a > PI { a -= 2.0 * PI; }
while a < -PI { a += 2.0 * PI; }
a
}
/// Angles in radians of every house-cusp boundary, in the same render
/// coordinate space as [`longitude_to_render_angle`]. Length is always 12;
/// `out[0]` is the 1st house cusp (= Ascendant ≈ π).
pub fn house_cusp_angles(cusps: &[f64; 12], ascendant_deg: f64) -> [f64; 12] {
let mut out = [0.0_f64; 12];
for i in 0..12 {
out[i] = longitude_to_render_angle(cusps[i], ascendant_deg);
}
out
}
/// One conjunction cluster: a contiguous run of planets within
/// `threshold_deg` of each other along the ecliptic. The wheel widget
/// fans these out radially so the glyphs don't overlap.
#[derive(Debug, Clone, PartialEq)]
pub struct Cluster {
/// Planets in this cluster, sorted by ascending longitude. Single-
/// planet "clusters" represent isolated planets; the widget can treat
/// `len == 1` as the no-fan case.
pub planets: Vec<(Planet, f64)>,
}
/// Group planets into conjunction clusters: any two planets whose
/// ecliptic-longitude separation is ≤ `threshold_deg` end up in the same
/// cluster. Order-stable: clusters and the planets inside each cluster
/// are returned sorted by ascending longitude.
///
/// Crossing 0°/360° boundary is handled — Sun at 359° and Moon at 1° are
/// 2° apart, not 358°.
pub fn cluster_conjunctions(
positions: &[(Planet, f64)],
threshold_deg: f64,
) -> Vec<Cluster> {
if positions.is_empty() { return Vec::new(); }
let mut sorted: Vec<(Planet, f64)> = positions.iter()
.map(|&(p, l)| (p, l.rem_euclid(360.0)))
.collect();
sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut clusters: Vec<Cluster> = Vec::new();
let mut current = Cluster { planets: vec![sorted[0]] };
for i in 1..sorted.len() {
let prev = current.planets.last().expect("non-empty").1;
let cur = sorted[i].1;
let sep = (cur - prev).abs();
if sep <= threshold_deg {
current.planets.push(sorted[i]);
} else {
clusters.push(std::mem::replace(&mut current, Cluster {
planets: vec![sorted[i]],
}));
}
}
clusters.push(current);
// Wrap-around merge: if first and last clusters touch across 0°/360°.
if clusters.len() >= 2 {
let first_first = clusters[0].planets[0].1;
let last_last = clusters.last().unwrap().planets.last().unwrap().1;
let wrap_sep = (360.0 - last_last) + first_first;
if wrap_sep <= threshold_deg {
let mut tail = clusters.pop().unwrap();
// Tail planets logically sit "before" the head — splice them in front.
tail.planets.append(&mut clusters[0].planets);
clusters[0] = tail;
}
}
clusters
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, eps: f64) -> bool { (a - b).abs() < eps }
#[test]
fn ascendant_lands_at_nine_oclock() {
let asc = 100.0;
let a = longitude_to_render_angle(asc, asc);
assert!(approx(a, PI, 1e-9), "ASC should map to π (9-o'clock), got {a}");
}
#[test]
fn descendant_lands_at_three_oclock() {
let asc = 100.0;
let a = longitude_to_render_angle(asc + 180.0, asc);
assert!(approx(a, 0.0, 1e-9), "DSC should map to 0 (3-o'clock), got {a}");
}
#[test]
fn house_one_below_ascendant() {
// House 1 contents (ASC + 15°, ~middle of 1st house) should sit
// below the horizon-line through ASC/DSC — i.e. y-up direction in
// render coords is negative (Cairo's Y points down so the wheel's
// visual "below" is `sin(angle) < 0`).
let asc = 0.0;
let a = longitude_to_render_angle(15.0, asc);
assert!(a.sin() < 0.0,
"house-1 contents must render below ASC line, got sin={}", a.sin());
assert!(a.cos() < 0.0,
"house-1 contents must render to the left (cos<0), got cos={}", a.cos());
}
#[test]
fn cusps_partition_the_circle() {
// 12 evenly-spaced cusps (whole-sign starting from ASC=0°) should
// yield 12 angles spaced exactly 30° apart.
let cusps = [0.0, 30.0, 60.0, 90.0, 120.0, 150.0,
180.0, 210.0, 240.0, 270.0, 300.0, 330.0];
let angles = house_cusp_angles(&cusps, 0.0);
for i in 0..12 {
let next = (i + 1) % 12;
let mut sep = (angles[i] - angles[next]).abs();
if sep > PI { sep = 2.0 * PI - sep; }
assert!(approx(sep, PI / 6.0, 1e-9),
"cusps {i}->{next} sep {sep} != π/6");
}
}
#[test]
fn cluster_isolated_planets_each_own_cluster() {
let positions = vec![
(Planet::Sun, 10.0),
(Planet::Mars, 100.0),
(Planet::Moon, 200.0),
];
let clusters = cluster_conjunctions(&positions, 3.0);
assert_eq!(clusters.len(), 3);
for c in &clusters { assert_eq!(c.planets.len(), 1); }
}
#[test]
fn cluster_two_close_planets_merge() {
let positions = vec![
(Planet::Sun, 10.0),
(Planet::Moon, 12.0),
(Planet::Mars, 100.0),
];
let clusters = cluster_conjunctions(&positions, 3.0);
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].planets.len(), 2);
assert_eq!(clusters[1].planets.len(), 1);
}
#[test]
fn cluster_handles_zero_crossing() {
let positions = vec![
(Planet::Sun, 359.0),
(Planet::Moon, 1.0),
];
let clusters = cluster_conjunctions(&positions, 3.0);
assert_eq!(clusters.len(), 1, "wrap-around 0° should merge");
assert_eq!(clusters[0].planets.len(), 2);
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod cities;
pub mod birth;
pub mod calendar;
pub mod chart;
pub mod chart_geometry;
pub mod ephemeris;
pub mod houses;
pub mod interp;
+302
View File
@@ -0,0 +1,302 @@
# PRD: Chart visualization (Phase G)
**Status:** needs-triage
**Branch:** TBD (will be `feat/chart-wheel`)
**Foundation already landed:** Phases AC-1 (0.7.0), Phase E activity feed (0.8.0), Phase F-collab collaborative interpretations (0.9.0).
**Naming:** the deliverable is "the chart wheel" in user-facing copy and `ChartWheel` in code.
## Problem Statement
Zodia today renders every astrological chart as a textual aspect list. The Chart tab is a `FactoryVecDeque<InterpRow>` of placements + aspects, each row a glyph string plus orb and tap target. This works, but it has visible costs:
- **Astrology is geometric and we throw the geometry away.** Aspects *are* angles between planets on a circle; houses *are* twelfths of the same circle; the sign ring *is* a 12-fold partition. Rendering all of this as a flat list strips the structure that practitioners actually reason with.
- **Synastry has nowhere to live.** When a peer's chart consents, today's UI shows their placements + the synastry aspects as another list. There is no visual primitive for "your wheel with their planets overlaid." The most interesting astrological view in a social app — comparing two charts — is the one the current UI handles worst.
- **Transits are temporally locked into the activity feed.** A transit card in Sky tells you *that* Mars is in orb of your natal Sun, but not where in your wheel it's happening. Locating it requires mental projection from text back onto a circle you have to imagine.
- **Reusability is non-existent.** Every place that wants to show a chart re-renders the same list with slightly different filter rules. There is no `ChartWheel` component the Chart tab, the stargazer page, the future circle-of-the-day view, or a mobile-narrow lockscreen widget could all reuse.
- **Mobile layout has no story.** The current list works at any width because lists scale trivially. A chart wheel needs an actual responsive plan: what does the wheel become when the viewport is 360 px wide?
- **Aesthetics matter for this app.** Zodia's whole pitch is that astrology is something to dwell with; a textual list signals "data entry app." A rendered wheel — the iconic image of the practice — signals "this is your chart."
The result is that Zodia has a community body, a doc model, and a feed, but the *chart itself* — the surface the entire app revolves around — has no visual representation. Phase G fixes that.
## Solution
Build one `ChartWheel` widget — a `gtk::DrawingArea`-backed Cairo renderer — that draws a natal chart as a concentric set of rings: signs outermost, houses, planets, aspect lines inside. It accepts an optional *overlay* chart for synastry (transiting peer's planets drawn on the outer rim of the local chart). Adwaita semantic colors so it tracks light/dark theme automatically. Interactive: tap a planet or aspect to push the same detail page the feed cards already push. Scales: at narrow widths the wheel shrinks, the aspect-line web simplifies, and below a breakpoint the widget falls back to the existing aspect list rather than rendering a too-small wheel.
One component, three immediate consumers:
1. **Chart tab** — replaces the top-level aspect list as the primary view; the list remains accessible as a "details" disclosure below the wheel.
2. **Stargazer page** — when a peer has consented to chart-sharing, their wheel renders above the conversation thread; tapping switches to a synastry overlay against your chart.
3. **Future circle-of-the-day / lock-widget surfaces** (Phase J+) — the same component, rendered at any size with no consumer-side knowledge of how it's drawn.
No new wire format. No new ops. No store schema change. The entire phase is rendering + interaction work plus a small `chart_geometry` module under `zodia-core` factoring out the angle math that's currently scattered. Phase G cashes in the chart data we've had since 0.6 by giving it the geometric surface practitioners expect.
## User Stories
1. As a Zodia user opening my Chart tab, I want to see my chart as a circular wheel — signs around the rim, planets placed at their longitudes, houses divided into wedges — so the first impression is "this is my chart," not "this is a database query."
2. As a Zodia user reading my wheel, I want aspect lines drawn between aspecting planets, color-coded by aspect kind (harmonious / challenging / neutral), so the geometry of my chart is legible at a glance.
3. As a Zodia user tapping a planet on the wheel, I want to navigate to that planet's placement detail page, so the wheel acts as a navigation surface and not just a static image.
4. As a Zodia user tapping an aspect line on the wheel, I want to navigate to that aspect's detail page (the same one feed cards push), so spatial discovery and feed-driven discovery converge.
5. As a Zodia user with a consented peer, I want to see their natal wheel on their stargazer page, so I have the same iconic chart-as-mandala read for them as for myself.
6. As a Zodia user comparing my chart with a consented peer's, I want to toggle a *synastry overlay* — their planets drawn on the outer ring of my wheel — so I can read the synastry as the inter-chart geometry it is.
7. As a Zodia user on a narrow screen (e.g. 360 px), I want the wheel to either scale gracefully or fall back to the existing aspect list, so my Chart tab doesn't render an illegible postage-stamp circle.
8. As a Zodia user with light-theme preferences, I want the wheel to render with the light Adwaita palette; switching to dark mode mid-session should redraw without restart.
9. As a Zodia user with motion sensitivity, I want any chart animation (entrance, rotation on overlay toggle) to respect the system's "reduce motion" preference, so the wheel is calm not flashy.
10. As a Zodia user with poor vision, I want a textual a11y description of the wheel exposed to the accessibility tree, so a screen reader can summarise "Sun in 5th house Leo, square Mars in 8th house Scorpio..." rather than reporting an opaque drawing area.
11. As a Zodia user watching an active transit, I want to see *where* the transiting planet is on my wheel right now — a ghost-glyph at its current ecliptic longitude — so the transit's location in my chart is spatially obvious, not just textually flagged.
12. As a future Phase G+ implementer (chart lockscreen widget, exported PNG of "today's wheel"), I want the rendering pipeline to take a chart + size + options and produce a Cairo surface independent of any GTK widget, so the wheel can be drawn into any output target.
## Implementation Decisions
### Drawing technology
`gtk::DrawingArea` + `cairo`. Rationale:
- **Cairo is already in the dependency graph** (transitively through GTK4). No new system library.
- **Vector-native** — strokes, fills, text, arcs scale without aliasing concerns at the wheel's typical sizes (180720 px).
- **Adwaita semantic colors** flow through `gtk::StyleContext::lookup_color` so the wheel tracks theme changes for free.
- **Drawing functions are pure** — given (chart, size, options) → render. Easy to test by snapshot-diffing the output surface.
Alternatives considered and rejected:
- **GtkSnapshot + render nodes** — more efficient but locks us into GTK 4's render-node API and complicates future export-to-PNG. Not worth it for our scale.
- **SVG generated then rendered** — extra layer; SVG-as-IR adds no value for a renderer this static.
- **Cairo via librsvg of a templated SVG** — fragile and harder to make theme-reactive.
### Layout (concentric rings, outermost first)
1. **Sign ring** — the 12 zodiac signs as wedges around the outer rim. Wedge fill is a subtle tint of the sign's element color (fire / earth / air / water → red / green / yellow / blue, each de-saturated to ~25% opacity so it reads as background). Sign glyphs centered in each wedge.
2. **House ring** — house cusp lines drawn as radial spokes from the sign-ring inner edge to the centre. House numbers labeled at the cusp midpoint. House cusp boundaries computed from `Chart::cusps` (already in `zodia-core`).
3. **Planet ring** — natal planets placed at their ecliptic longitudes, drawn just inside the house ring. Each planet rendered as a small filled circle with the planet's glyph centered. Conjunctions within ~3° clustered with a small radial fan so glyphs don't overlap.
4. **Aspect web** — the innermost region. Lines drawn between aspecting planet pairs, color-coded by aspect kind:
- **Harmonious** (trine, sextile) — accent-green.
- **Challenging** (square, opposition, quincunx) — accent-red.
- **Neutral / structural** (conjunction) — dim-grey.
- Line opacity scales with `1.0 - (orb / max_orb)` so tight aspects render more boldly.
All five rings + the web are drawn in one `cairo::Context` traversal. Total cost at 480 px is ~2 ms on a typical desktop based on the geometry count (~10 planets × ~9 aspects ≈ 90 lines + 12 wedges + 12 spokes + 1 outer ring + ~10 planet glyphs).
### Optional overlay chart (synastry / transit)
The widget takes a second optional `Chart` (or just `PlanetPositions`) drawn on a *secondary planet ring* just outside the natal planet ring. Overlay planets are rendered with a subtle outline-only glyph to distinguish from the natal solid-filled ones. Synastry aspect lines (between natal and overlay planets) drawn in the same web but with a dashed stroke so the eye can separate natal-natal from natal-overlay aspects.
The transit case (Phase G's secondary win): the overlay chart is computed from today's `PlanetPositions::transits_at(current_jdn)`. A "show current transits" toggle on the Chart tab populates this overlay; a sun-glyph indicator on the wheel border tracks the local Sun position over time (cheap, hour-quantised).
### Interaction model
`gtk::DrawingArea::add_controller(GestureClick)`:
- **Tap on a planet's glyph** → push the placement detail page (`AppMsg::FeedActivated { payload: OpenInterpKey(planet_placement_key) }`).
- **Tap on an aspect line** → push the aspect detail page (same nav, with the aspect's `interp_key`).
- **Tap on a house wedge** → push the house-cusp / placement detail (when a planet is *in* that house, default to the planet detail; otherwise to the placement-house key for the house's ruler).
- **Tap on empty centre** → no-op (intentional dead zone for misclicks).
Hit detection uses simple angle-and-radius math in click coordinates → ecliptic longitude → match against the rendered glyphs' angular positions. No quadtree needed at this scale.
Hover (mouse only — touch gets no hover) shows a tooltip with the same text the current `InterpRow` would carry: planet name, sign, house, degree.
### Responsive scaling and mobile fallback
The widget's `measure()` impl declares a natural size of 480 px and a minimum of 240 px. At the minimum:
- Wedges shrink to fit; the sign ring keeps its glyphs but loses element-color tints (too noisy small).
- Aspect lines drop to the strongest N (tight orbs only) — full web becomes illegible.
- Planet clusters expand more aggressively (tighter conjunctions get more radial separation).
Below 240 px (the "phone narrow" case): the wheel hides entirely and the parent re-shows the existing `aspect_list` as a fallback. Switch is driven by `adw::Breakpoint`, the same primitive driving the sidebar collapse — consistent with the rest of the app's responsive story.
### Theme reactivity
The widget connects to `gtk::Settings::default().connect_notify_local("gtk-theme-name", ...)` and queues a redraw. Colors are resolved at draw time via `widget.color()` for the accent / dim-label / theme-fg shades, so a light↔dark switch redraws with the new palette without any cached-color invalidation logic.
### Animation policy
- **First paint** — no animation. The wheel appears.
- **Overlay toggle** — a 200 ms fade-in of the overlay ring + dashed lines. Skipped if `gtk-enable-animations` is false or `prefers-reduced-motion` is set.
- **Transit-hour update** — silent redraw; no animation (would be distracting for an hour-rate update).
### Component API
```rust
// app/src/chart_wheel.rs
pub struct ChartWheelInit {
pub chart: Rc<Chart>,
pub overlay_chart: Option<Rc<Chart>>,
pub show_aspect_lines: bool, // default true
pub show_houses: bool, // default true
}
pub enum ChartWheelMsg {
SetChart(Rc<Chart>),
SetOverlay(Option<Rc<Chart>>),
SetShowAspectLines(bool),
SetShowHouses(bool),
ThemeChanged, // internal — fired on Settings notify
}
pub enum ChartWheelOut {
/// User tapped something with a navigable interp_key.
Activated { interp_key: InterpKey },
}
pub fn launch(init: ChartWheelInit) -> (gtk::DrawingArea, relm4::Sender<ChartWheelMsg>);
```
The widget owns no chart data beyond what's passed in; `SetChart` swaps it. Parent components mediate navigation on `Activated`.
### Drawing function — pure, testable
```rust
// Pure function the widget's draw_func calls. Takes a context + size +
// inputs, no widget reference. Lets us unit-test by rendering to an
// ImageSurface and snapshot-diffing the output.
pub fn draw_wheel(
cr: &cairo::Context,
size_px: f64,
chart: &Chart,
overlay: Option<&Chart>,
palette: &Palette, // resolved colors
opts: &DrawOpts,
);
pub struct Palette {
pub fg: cairo::RGBA,
pub bg: cairo::RGBA,
pub accent: cairo::RGBA,
pub dim: cairo::RGBA,
pub fire: cairo::RGBA,
pub earth: cairo::RGBA,
pub air: cairo::RGBA,
pub water: cairo::RGBA,
pub harmonious: cairo::RGBA,
pub challenging: cairo::RGBA,
pub neutral: cairo::RGBA,
}
pub struct DrawOpts {
pub show_aspect_lines: bool,
pub show_houses: bool,
pub min_orb_opacity: f64,
pub conjunction_fan_threshold_deg: f64,
}
```
`Palette` is constructed inside the widget from the live `StyleContext`; tests construct it manually with deterministic values.
### Geometry factoring
`zodia-core` gains a small `chart_geometry` module:
- `fn longitude_to_radians(lon: f64, ascendant: f64) -> f64` — handles the "Ascendant at left" convention used in Western astrology (ASC is at 9-o'clock, not 3-o'clock).
- `fn house_cusp_angles(cusps: &Cusps, ascendant: f64) -> [f64; 12]` — the radians for each house wedge boundary.
- `fn cluster_conjunctions(positions: &[(Planet, f64)], threshold_deg: f64) -> Vec<Cluster>` — for the planet-ring fan rendering.
None of these depend on Cairo or GTK; the `app/` widget pulls them in. This keeps the math testable in isolation and reusable from a future export-to-PNG tool.
### Accessibility description
The widget overrides `gtk::Widget::set_accessible_role(gtk::AccessibleRole::Img)` and sets a derived `gtk::AccessibleProperty::Label` of the form:
> "Natal chart for [name]. Sun in Leo, 5th house, 12°. Moon in Pisces, 11th house, 3°. ... Mars square Sun, orb 1.2°. ..."
Generated once per chart change. Screen readers get a readable summary; the underlying drawing remains a `DrawingArea`. Per-planet hit regions also expose their own accessibility children for keyboard nav (Tab cycles planets in zodiacal order; Enter activates).
### Where it lives
`app/src/chart_wheel.rs`. Not its own crate: Cairo + GTK ties make it inseparable from the `app/` layer, and the only consumers are GTK widgets. The `chart_geometry` helpers live in `zodia-core` so they're testable without `app/`'s deps.
When (Phase G+) we want to export "today's chart as PNG" from a CLI, the pipeline is:
1. `zodia_core::compute_chart(...)``Chart`.
2. `app::chart_wheel::draw_wheel(cr, size, &chart, None, &Palette::default_dark(), &DrawOpts::default())`.
That's the whole export tool. The `draw_wheel` fn is pure; no GTK runtime required to call it against an `ImageSurface`.
### Integration points
- **Chart tab** (`app::app::build_main_page` chart container): the `ChartWheel` becomes the primary content, the existing `aspect_list` collapses into an `adw::ExpanderRow` "Aspect details" below the wheel.
- **Stargazer page** (`app::stargazer_page`): adds a wheel above the chat list. Toggle: "Natal wheel" / "Synastry overlay against your chart." Defaults to natal until the user activates the overlay.
- **Feed card → detail page**: detail pages already render `interp_key`-scoped doc views; Phase G adds a small in-page wheel showing *just the relevant placement / aspect* (the rest of the wheel dim-rendered as context). The same `draw_wheel` fn with a highlight option.
### What disappears
- The "Aspects" `PreferencesGroup` is demoted from the top of the Chart tab into an expander below the wheel; nothing is deleted, just relocated.
- `aspect_list::aspect_items` and friends stay — the wheel calls into the same `Chart` accessors they do.
The wheel is *additive*; this phase removes no shipped behaviour.
## Testing Decisions
- **Geometry unit tests** (`zodia-core::chart_geometry`): property-style tests that longitude_to_radians round-trips correctly, that house cusp angles partition the circle into 12 contiguous arcs summing to 2π, that conjunction clustering separates within-threshold pairs but never merges far-apart planets.
- **Draw-function snapshot tests** (`app::chart_wheel::draw_wheel`): render a known chart at 480 px into a `cairo::ImageSurface`, hash the resulting pixel buffer, assert against a checked-in golden. Re-bless on intentional rendering changes via a `cargo test -- --bless` flag (custom test harness).
- **Hit-test unit tests**: given a known chart's rendered geometry, assert that click coordinates within a planet's glyph radius resolve to that planet's `interp_key`, and clicks in dead regions return `None`.
- **Responsive behaviour**: smoke test that calling `draw_wheel` at 240 px and 720 px both succeed without panic and produce non-empty surfaces. Visual quality of the small render isn't unit-tested — it's a manual QA pass.
- **Theme-change**: integration test that a `notify::gtk-theme-name` signal triggers `queue_draw` exactly once.
- **Accessibility label generation**: pure fn that takes `&Chart` and returns the descriptive string; unit-tested for several fixture charts.
No live-iroh tests. No new pipeline / op surface in this phase.
## Out of Scope
- **Animated wheel-of-time / time scrubber.** "Drag a slider to see the wheel at different dates" is a future feature; Phase G renders the current moment + a single optional overlay only.
- **Custom user themes for the wheel** (planet colors, custom glyph sets). Adwaita semantic colors only; theme support is "whatever GTK reports."
- **Drag-to-rotate the wheel** to put a different planet at the top. Astrologers do this; we don't ship it in G. The ASC-at-left convention is fixed.
- **Three-way charts** (composite / Davison / triple synastry). One overlay only; multi-overlay is its own design.
- **Mobile-specific gestures** (pinch-to-zoom, two-finger pan). The widget is a single-tap surface in G; gesture polish is later.
- **Exporting to PNG/PDF.** The `draw_wheel` fn is exportable but Phase G doesn't ship an export UI.
- **Aspect-line clustering** when many aspects converge on one planet. The web stays as-is; layout tweaks for dense charts are a follow-up.
- **Per-house background imagery** (zodiac-house art tradition). Pure geometric rendering only.
- **Animation on transit-planet movement.** The transit overlay updates on a slow timer; no real-time orbital motion.
- **Print stylesheet** — printing a wheel via GTK's print dialog. Out of scope.
- **Live cursor sharing** when collaborators are looking at the same wheel. Phase G is a viewer; collaboration is the doc editor's job.
## Further Notes
**Why this phase ships as 0.10.0.** Phase G adds no new wire format, no new ops, no store schema change, and no network behaviour. All changes are local to the upgrading device. Peers on 0.9.x continue to interoperate fully; the visible difference is entirely UX. The minor-version bump is justified by the magnitude of the user-facing shift (text list → rendered wheel as the primary chart surface).
**Connection to earlier PRDs.** The activity-feed PRD §"Out of scope" explicitly deferred "Chart visualization (Cairo wheel)" to Phase G. The collaborative-interpretations PRD §"Future Phase G/H/I" carved out the chart-viz layer as a separate concern. This PRD picks up that exact slot.
**Phasing context (revised post-shipping Phases E + F-collab).**
| Phase | What | Status |
|---|---|---|
| A | `zodia-ops` + `zodia-pipeline` scaffolding | shipped (0.7.0) |
| B | Network-replicated affirmations + sync metrics | shipped (0.7.0) |
| C-1 | Causal response threads | shipped (0.7.0) |
| E | Activity feed | shipped (0.8.0) |
| F-collab | Collaborative interpretations | shipped (0.9.0) |
| **G** | **Chart visualization (this PRD)** | **next (0.10.0)** |
| H | Pair-channel stream rework (drop ALPN, capability sub-streams) | follows G |
| I | Mesh audio recording + replay (audio mesh itself shipped early in F-collab) | follows H |
| J | Private/role-gated docs on UserChart anchors via p2panda-auth | follows I |
| K | Pruning processor + retention policy UI | follows J |
| C-2 | Lazy per-key topic subscription | interleaves where convenient |
**Open questions to resolve during implementation.**
- Exact element-color tints (RGBA values) for the sign-ring backgrounds. Probably 610% opacity over the theme background; needs a visual QA pass to land on numbers that read well in both light and dark.
- Whether aspect lines render edge-to-edge between planet glyphs or curve gently toward the centre. Straight lines are conventional; curved bezier lines are prettier but read less geometrically. Start straight.
- Conjunction-fan algorithm: linear fan vs adaptive spacing by glyph width. Start linear, tune if it produces visible artifacts on real charts.
- Whether the transit overlay's outer ring should display the current Sun-position glyph as a clock-hand-style indicator on the rim, separate from the overlay planets. Probably yes — gives an at-a-glance "time of day" read on the chart.
- How to render planets in retrograde — the conventional `℞` superscript adjacent to the glyph, vs a subtle backwards-orbit indicator. Start with the superscript per convention.
- Whether to render the *void-of-course Moon* state when applicable — niche but loved by users who track it. Probably yes, as a small ° marker; out of scope if it adds rendering complexity.
**Risks.**
- **Cairo text rendering inconsistency across platforms.** Glyph fonts vary (system Symbola vs not-installed-anywhere on macOS). Mitigation: bundle an Astro Sans / Symbola subset as a Cairo font resource so glyphs render identically on every platform. Adds ~200 KB to the binary.
- **DrawingArea redraw cost on rapid theme toggles.** Recomputing palette + redrawing on every notify could thrash during animated theme transitions. Mitigation: debounce theme notifies via a 50 ms `glib::timeout`.
- **Hit-testing fragility.** Floating-point coords + small glyph radii can lead to "I tapped Mars but it opened Venus" UX bugs. Property-test the hit-detect with fuzzed click points within glyph radii.
- **Accessibility label staleness.** If we generate the a11y label once on chart change and the chart updates, screen reader users may hear stale info. Mitigation: regenerate on every `SetChart` / `SetOverlay`; the label is cheap to derive.
- **Snapshot-test goldens churn**. Visual diffs are notoriously brittle — anti-aliasing differences across GTK versions can break them. Mitigation: hash a low-resolution downscale of the surface (32×32 grayscale) rather than the full pixel buffer; tolerates sub-pixel variance while still catching structural regressions.
**Naming.** "The chart wheel" or just "your chart" in user-facing copy. Internal: `ChartWheel`, `draw_wheel`, `Palette`, `DrawOpts`. The astrology-traditional term "horoscope wheel" is avoided — too overloaded with "today's horoscope" pop-astrology.
**Design inspiration touchstones.** Modern astrology apps' wheel renderings tend toward two extremes — busy mandala-style decorations or sterile vector diagrams. Zodia's target is closer to the latter, with restrained decoration: legible geometry first, ornament only via the subtle element-color sign-ring tints. The Adwaita aesthetic carries this naturally — clean, semantic, theme-aware, opinionated about typography.