Skip to main content

graph_explorer_render/
labels.rs

1use std::collections::HashMap;
2
3use glyphon::{Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping,
4    SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport};
5use crate::Camera;
6
7/// Bundled fallback font (SIL Open Font License, from the `Inter` family).
8///
9/// `cosmic-text`'s `FontSystem::new()` calls `fontdb::Database::load_system_fonts()`,
10/// which is a no-op on `wasm32` (there is no filesystem to scan). Without at least one
11/// face loaded, `Buffer::shape_until_scroll` panics with "no default font found". We embed
12/// a single font and register it as the generic `sans-serif` family so it works
13/// identically on native and in the browser.
14static FALLBACK_FONT: &[u8] = include_bytes!("../assets/Inter-Bold.ttf");
15
16/// Where a label sits relative to its anchor point.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum LabelAnchor {
19    /// To the right of a node, clear of its disc. `radius` is in WORLD units
20    /// and is scaled by zoom at placement time.
21    NodeRight { radius: f32 },
22    /// Centred on the point — used for edge labels, which sit at the edge's
23    /// midpoint with nothing to sit beside.
24    Center,
25}
26
27/// One label the caller selected for drawing this frame (LOD survivor).
28/// `key` is the interned `LabelId`; `text` is only read on cache miss.
29pub struct LabelDraw<'a> {
30    pub key: u32,
31    pub text: &'a str,
32    pub pos: [f32; 2],
33    pub opacity: f32,
34    /// RGB only; alpha comes from `cam.fade * opacity`, so a caller cannot
35    /// accidentally defeat the global fade.
36    pub color: [f32; 3],
37    pub anchor: LabelAnchor,
38}
39
40/// A survivor with its shaping already ensured; what `prepare` consumes.
41#[derive(Clone, Copy)]
42struct PlacedLabel { key: u32, pos: [f32; 2], opacity: f32, color: [f32; 3], anchor: LabelAnchor }
43
44/// Shaped-buffer cache bound: crude clear-on-cap keeps memory bounded across
45/// a session that pans over tens of thousands of distinct labels. After a
46/// clear, the next frames re-shape only the current survivors (≤ the LOD
47/// cap), so the worst case is one shaping burst, not a leak.
48const LABEL_CACHE_CAP: usize = 2048;
49
50pub struct Labels {
51    font_system: FontSystem,
52    swash: SwashCache,
53    atlas: TextAtlas,
54    viewport: Viewport,
55    renderer: TextRenderer,
56    shaped: HashMap<u32, Buffer>,
57    placed: Vec<PlacedLabel>,
58}
59
60impl Labels {
61    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
62        let cache = Cache::new(device);
63        let mut atlas = TextAtlas::new(device, queue, &cache, format);
64        let viewport = Viewport::new(device, &cache);
65        let renderer = TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
66        let mut font_system = FontSystem::new();
67        let db = font_system.db_mut();
68        db.load_font_data(FALLBACK_FONT.to_vec());
69        let family = db.faces().next().map(|face| face.families[0].0.clone());
70        if let Some(family) = family {
71            db.set_sans_serif_family(family.clone());
72            db.set_serif_family(family.clone());
73            db.set_monospace_family(family.clone());
74            db.set_cursive_family(family.clone());
75            db.set_fantasy_family(family);
76        }
77        Self {
78            font_system,
79            swash: SwashCache::new(),
80            atlas,
81            viewport,
82            renderer,
83            shaped: HashMap::new(),
84            placed: Vec::new(),
85        }
86    }
87
88    /// Project world position to physical screen pixels (origin top-left).
89    fn project(cam: &Camera, world: [f32; 2]) -> (f32, f32) {
90        let relx = (world[0] - cam.center[0]) * cam.zoom;
91        let rely = (world[1] - cam.center[1]) * cam.zoom;
92        let sx = cam.viewport[0] * 0.5 + relx;
93        let sy = cam.viewport[1] * 0.5 - rely; // flip y
94        (sx, sy)
95    }
96
97    /// Record this frame's survivors, shaping any label not already cached.
98    /// Steady state (same labels visible) shapes nothing.
99    pub fn set_labels(&mut self, labels: &[LabelDraw]) {
100        if self.shaped.len() > LABEL_CACHE_CAP {
101            self.shaped.clear();
102        }
103        self.placed.clear();
104        let Self { shaped, font_system, placed, .. } = self;
105        for l in labels {
106            shaped.entry(l.key).or_insert_with(|| {
107                let mut buf = Buffer::new(font_system, Metrics::new(14.0, 18.0));
108                buf.set_text(font_system, l.text, Attrs::new().family(Family::SansSerif), Shaping::Advanced);
109                buf.shape_until_scroll(font_system, false);
110                buf
111            });
112            placed.push(PlacedLabel { key: l.key, pos: l.pos, opacity: l.opacity, color: l.color, anchor: l.anchor });
113        }
114    }
115
116    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, cam: &Camera) -> Result<(), String> {
117        self.viewport.update(queue, Resolution { width: cam.viewport[0] as u32, height: cam.viewport[1] as u32 });
118        let areas: Vec<TextArea> = self.placed.iter().filter_map(|p| {
119            // A placed key is always inserted into `shaped` by `set_labels`
120            // before it lands in `placed`; skipping a missing key is pure
121            // defensiveness (drop the label for a frame rather than panic).
122            let buf = self.shaped.get(&p.key)?;
123            let (sx, sy) = Self::project(cam, p.pos);
124            let left = match p.anchor {
125                // `radius` is a WORLD-unit radius; `sx` is already in screen
126                // px, so it must be scaled by zoom before adding — otherwise
127                // the label drifts off the node at any zoom other than 1.
128                LabelAnchor::NodeRight { radius } => sx + radius * cam.zoom + 4.0,
129                // Centring needs the shaped width, which only the laid-out
130                // buffer knows. `line_w` is per visual line; a label is one
131                // line, but max() is correct for any.
132                LabelAnchor::Center => {
133                    let w = buf.layout_runs().map(|r| r.line_w).fold(0.0_f32, f32::max);
134                    sx - w * 0.5
135                }
136            };
137            let [r, g, b] = p.color;
138            Some(TextArea {
139                buffer: buf,
140                left,
141                top: sy - 9.0,
142                scale: 1.0,
143                bounds: TextBounds { left: 0, top: 0, right: cam.viewport[0] as i32, bottom: cam.viewport[1] as i32 },
144                default_color: Color::rgba(
145                    (r * 255.0) as u8,
146                    (g * 255.0) as u8,
147                    (b * 255.0) as u8,
148                    (cam.fade * p.opacity * 255.0) as u8,
149                ),
150                custom_glyphs: &[],
151            })
152        }).collect();
153        self.renderer.prepare(device, queue, &mut self.font_system, &mut self.atlas, &self.viewport, areas, &mut self.swash).map_err(|e| e.to_string())?;
154        Ok(())
155    }
156
157    pub fn draw<'a>(&'a self, rpass: &mut wgpu::RenderPass<'a>) -> Result<(), String> {
158        self.renderer.render(&self.atlas, &self.viewport, rpass).map_err(|e| e.to_string())?;
159        Ok(())
160    }
161}