cyb/shell/src/worlds/particle_page.rs

//! Spacetime: `cyb://particle/<hex>` โ€” identity, rank, axons. No spark.

use bevy::prelude::*;
use mir::bevy::resources::{GpuBuffers, WarpTarget};
use prysm::molecules::particle_card;
use prysm::theme;

use super::content;
use super::graph::BrainIndex;
use crate::now::{Now, NowKind};
use crate::shell::chrome::{CHROME_BOTTOM_H, CHROME_TOP_H, ContentRoot};

pub struct ParticlePagePlugin;

#[derive(Component)]
struct ParticlePage;

#[derive(Component)]
struct AxonRow(usize);

impl Plugin for ParticlePagePlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Update, (sync_page, walk_axon).run_if(now_meta))
            .add_systems(Update, despawn_idle.run_if(not(now_meta)));
    }
}

fn now_meta(now: Res<Now>) -> bool {
    now.kind == NowKind::Meta
}

fn despawn_idle(mut commands: Commands, pages: Query<Entity, With<ParticlePage>>) {
    for e in &pages {
        commands.entity(e).despawn();
    }
}

fn sync_page(
    mut commands: Commands,
    now: Res<Now>,
    index: Option<Res<BrainIndex>>,
    gpu: Option<Res<GpuBuffers>>,
    pages: Query<Entity, With<ParticlePage>>,
) {
    if !now.is_changed() && !pages.is_empty() {
        return;
    }
    for e in &pages {
        commands.entity(e).despawn();
    }
    let Some(p) = now.hash else { return };
    let hash = *p.as_bytes();
    let idx = now.idx;
    let title = idx
        .and_then(|i| {
            index
                .as_ref()
                .and_then(|ix| ix.labels.get(i).cloned().flatten())
        })
        .or_else(|| content::lookup(&hash))
        .unwrap_or_else(|| p.short_hex());
    let focus = idx
        .and_then(|i| index.as_ref().and_then(|ix| ix.focus.get(i).copied()))
        .unwrap_or(0.0);
    let degree = match (idx, gpu.as_ref()) {
        (Some(i), Some(g)) => g
            .csr
            .as_ref()
            .map(|csr| neighbor_count(csr, i))
            .unwrap_or(0),
        _ => 0,
    };

    let root = commands
        .spawn((
            ParticlePage,
            ContentRoot,
            Node {
                position_type: PositionType::Absolute,
                top: Val::Px(CHROME_TOP_H),
                bottom: Val::Px(CHROME_BOTTOM_H),
                left: Val::Px(0.0),
                right: Val::Px(0.0),
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::Center,
                overflow: Overflow::clip(),
                ..default()
            },
            BackgroundColor(theme::DARK_BASE),
            GlobalZIndex(8),
        ))
        .id();
    let page = commands
        .spawn((
            crate::worlds::page::scroll_column(),
            ScrollPosition::default(),
            crate::worlds::scroll::PersistScroll("particle"),
            ChildOf(root),
        ))
        .id();
    particle_card::spawn(
        &mut commands,
        page,
        &title,
        &p.short_hex(),
        Some(&format!("focus {focus:.4}   axons {degree}")),
    );

    let Some(i) = idx else { return };
    let Some(gpu) = gpu else { return };
    let Some(csr) = gpu.csr.as_ref() else { return };
    let Some(index) = index else { return };
    // A hub particle can have thousands of axons. Spawning a button for
    // each one hangs the UI thread โ€” the click that opened this page.
    const AXON_CAP: usize = 32;
    let all: Vec<(usize, f32)> = neighbors(csr, i).collect();
    let extra = all.len().saturating_sub(AXON_CAP);
    for (n_idx, weight) in all.into_iter().take(AXON_CAP) {
        let name = index
            .labels
            .get(n_idx)
            .cloned()
            .flatten()
            .unwrap_or_else(|| {
                index
                    .hashes
                    .get(n_idx)
                    .map(|h| file::Particle::from_bytes(*h).short_hex())
                    .unwrap_or_else(|| n_idx.to_string())
            });
        commands
            .spawn((
                AxonRow(n_idx),
                Button,
                Node {
                    width: Val::Percent(100.0),
                    justify_content: JustifyContent::SpaceBetween,
                    padding: UiRect::axes(Val::Px(theme::G * 1.5), Val::Px(theme::G * 0.75)),
                    border: UiRect::all(Val::Px(1.0)),
                    ..default()
                },
                BackgroundColor(theme::DARK_BASE),
                BorderColor::all(theme::BORDER),
                ChildOf(page),
            ))
            .with_children(|row| {
                row.spawn((
                    Text::new(name),
                    TextFont {
                        font_size: theme::BODY,
                        ..default()
                    },
                    TextColor(theme::TEXT_PRIMARY),
                ));
                row.spawn((
                    Text::new(format!("{weight:.2}")),
                    TextFont {
                        font_size: theme::CAPTION,
                        ..default()
                    },
                    TextColor(theme::TEXT_DIM),
                ));
            });
    }
    if extra > 0 {
        commands.spawn((
            Text::new(format!("{extra} more axons")),
            TextFont {
                font_size: theme::CAPTION,
                ..default()
            },
            TextColor(theme::TEXT_DIM),
            ChildOf(page),
        ));
    }
}

fn walk_axon(
    interactions: Query<(&Interaction, &AxonRow), Changed<Interaction>>,
    index: Option<Res<BrainIndex>>,
    mut now: ResMut<Now>,
    mut nav: ResMut<crate::worlds::nav::Nav>,
    world: Res<State<crate::worlds::WorldState>>,
    warp: Option<ResMut<WarpTarget>>,
) {
    let Some(index) = index else { return };
    for (i, row) in &interactions {
        if *i != Interaction::Pressed {
            continue;
        }
        let Some(&hash) = index.hashes.get(row.0) else {
            continue;
        };
        crate::worlds::nav::stand_from(&mut nav, &mut now, *world.get(), hash, Some(row.0), false);
        if let Some(mut warp) = warp {
            warp.particle_idx = Some(row.0 as u32);
        }
        return;
    }
}

fn neighbor_count(csr: &mir::graph::Csr, idx: usize) -> usize {
    neighbors(csr, idx).count()
}

fn neighbors(csr: &mir::graph::Csr, idx: usize) -> impl Iterator<Item = (usize, f32)> + '_ {
    let (a, b) = (
        csr.row_ptr.get(idx).copied().unwrap_or(0) as usize,
        csr.row_ptr.get(idx + 1).copied().unwrap_or(0) as usize,
    );
    (a..b.min(csr.col_idx.len())).map(|e| {
        (
            csr.col_idx[e] as usize,
            csr.values.get(e).copied().unwrap_or(0.0),
        )
    })
}

Graph