feat: add anchors, links; smooth pan/zoom animations

This commit is contained in:
Tomáš Mládek 2026-01-25 00:46:45 +01:00
parent c374ba0fc8
commit 24cbc894de
2 changed files with 368 additions and 27 deletions

View file

@ -40,6 +40,10 @@ pub struct TemplateApp {
#[serde(skip)]
reset_view_requested: bool,
/// Smooth camera animation state.
#[serde(skip)]
view_animation: Option<ViewAnimation>,
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
#[serde(skip)]
last_pointer_pos: Option<egui::Pos2>,
@ -69,12 +73,27 @@ impl Default for TemplateApp {
text_cache: None,
did_fit_start: false,
last_cursor_pos: None,
reset_view_requested: false
reset_view_requested: false,
view_animation: None,
}
}
}
#[derive(Debug, Clone)]
struct ViewAnimation {
start_center_x: f32,
start_center_y: f32,
start_zoom: f32,
target_center_x: f32,
target_center_y: f32,
target_zoom: f32,
elapsed: f32,
duration: f32,
}
impl TemplateApp {
const NAV_DURATION: f32 = 1.5;
/// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
log::info!("Initializing application...");
@ -120,6 +139,83 @@ impl TemplateApp {
app
}
fn cancel_animation(&mut self) {
self.view_animation = None;
}
fn begin_view_animation(
&mut self,
target_pan_x: f32,
target_pan_y: f32,
target_zoom: f32,
duration: f32,
canvas_rect: &egui::Rect,
) {
if duration <= 0.0 {
self.pan_x = target_pan_x;
self.pan_y = target_pan_y;
self.zoom = target_zoom;
self.view_animation = None;
return;
}
let (start_center_x, start_center_y) =
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
let (target_center_x, target_center_y) =
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
self.view_animation = Some(ViewAnimation {
start_center_x,
start_center_y,
start_zoom: self.zoom,
target_center_x,
target_center_y,
target_zoom,
elapsed: 0.0,
duration,
});
}
fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
if let Some(anim) = &mut self.view_animation {
anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
let eased = ease_in_out_cubic(t);
let zoom = lerp(
anim.start_zoom,
anim.target_zoom,
zoom_ease(t, anim.start_zoom, anim.target_zoom),
);
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
let visible_w = canvas_rect.width() / zoom;
let visible_h = canvas_rect.height() / zoom;
self.pan_x = -center_x + visible_w * 0.5;
self.pan_y = -center_y + visible_h * 0.5;
}
self.zoom = zoom;
if t >= 1.0 {
self.view_animation = None;
}
}
}
fn request_view_to_rect(
&mut self,
rect: (f32, f32, f32, f32),
canvas_rect: &egui::Rect,
duration: f32,
) {
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
}
}
/// Handle zoom towards a specific point.
fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) {
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
@ -130,6 +226,64 @@ impl TemplateApp {
}
}
fn ease_in_out_cubic(t: f32) -> f32 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
fn ease_out_cubic(t: f32) -> f32 {
1.0 - (1.0 - t).powi(3)
}
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
if target > start {
// Zooming in: bias progress late but soften landing
let biased = t.powf(1.6);
ease_in_out_cubic(biased)
} else {
// Zooming out: shed zoom early
ease_out_cubic(t)
}
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn compute_view_for_rect(
rect: (f32, f32, f32, f32),
canvas_rect: &egui::Rect,
) -> Option<(f32, f32, f32)> {
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
return None;
}
let (sx, sy, sw, sh) = rect;
if sw <= 0.0 || sh <= 0.0 {
return None;
}
let scale_x = canvas_rect.width() / sw;
let scale_y = canvas_rect.height() / sh;
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
let visible_w = canvas_rect.width() / fit_zoom;
let visible_h = canvas_rect.height() / fit_zoom;
let pan_x = -sx + (visible_w - sw) * 0.5;
let pan_y = -sy + (visible_h - sh) * 0.5;
Some((pan_x, pan_y, fit_zoom))
}
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
let visible_w = canvas_rect.width() / zoom;
let visible_h = canvas_rect.height() / zoom;
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
}
impl eframe::App for TemplateApp {
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
@ -150,6 +304,7 @@ impl eframe::App for TemplateApp {
primary_down,
primary_pressed,
primary_released,
space_pressed,
) = ctx.input(|i| {
(
i.key_pressed(egui::Key::Escape),
@ -163,9 +318,14 @@ impl eframe::App for TemplateApp {
i.pointer.primary_down(),
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.key_pressed(egui::Key::Space),
)
});
if primary_pressed || scroll_delta != 0.0 || (zoom_delta - 1.0).abs() > f32::EPSILON {
self.cancel_animation();
}
if let Some(pos) = pointer_latest.or(pointer_pos) {
self.last_cursor_pos = Some(pos);
}
@ -237,43 +397,50 @@ impl eframe::App for TemplateApp {
let canvas_rect = response.rect;
painter.rect_filled(canvas_rect, 0.0, background_color);
// Advance any pending camera animation (needs canvas size)
let anim_dt = if frame_time > 0.0 {
frame_time
} else {
1.0 / 60.0
};
self.update_animation(anim_dt, &canvas_rect);
if self.view_animation.is_some() {
ctx.request_repaint();
}
// Fullscreen toggle via double-click or 'F'
if response.double_clicked() || f_pressed {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
}
if !self.did_fit_start {
self.reset_view_requested = true;
self.did_fit_start = true;
}
let mut maybe_fit_view = |rect: &egui::Rect| {
if let Some(ref content) = self.svg_content {
if let Some((sx, sy, sw, sh)) = content.start_rect.or(content.viewbox) {
if sw > 0.0 && sh > 0.0 {
let scale_x = rect.width() / sw;
let scale_y = rect.height() / sh;
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
let visible_w = rect.width() / fit_zoom;
let visible_h = rect.height() / fit_zoom;
self.zoom = fit_zoom;
self.pan_x = -sx + (visible_w - sw) * 0.5;
self.pan_y = -sy + (visible_h - sh) * 0.5;
return true;
if let Some(rect) = content.start_rect.or(content.viewbox) {
if let Some((pan_x, pan_y, zoom)) =
compute_view_for_rect(rect, &canvas_rect)
{
self.pan_x = pan_x;
self.pan_y = pan_y;
self.zoom = zoom;
}
}
}
false
};
self.did_fit_start = true;
ctx.request_repaint();
}
if space_pressed {
self.reset_view_requested = true;
}
if self.reset_view_requested {
if maybe_fit_view(&canvas_rect) {
self.reset_view_requested = false;
} else {
// Nothing to fit, still clear the flag to avoid loops
self.reset_view_requested = false;
if let Some(ref content) = self.svg_content {
if let Some(rect) = content.start_rect.or(content.viewbox) {
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
}
}
// Always clear to avoid loops even if no rect present
self.reset_view_requested = false;
}
// Drag handling
@ -397,9 +564,57 @@ impl eframe::App for TemplateApp {
)
};
let text_cache = self.text_cache.as_mut().expect("just initialized");
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
if let Some(ref content) = self.svg_content {
let pointer_svg_hover = pointer_latest.or(pointer_pos).and_then(|p| {
if canvas_rect.contains(p) {
Some(screen_to_svg(p))
} else {
None
}
});
let click_svg_pos = if primary_released && !self.is_dragging {
pointer_latest.or(pointer_pos).and_then(|p| {
if canvas_rect.contains(p) {
Some(screen_to_svg(p))
} else {
None
}
})
} else {
None
};
if let Some((svg_x, svg_y)) = click_svg_pos {
if let Some(link) = content.anchor_links.iter().find(|link| {
svg_x >= link.x
&& svg_x <= link.x + link.width
&& svg_y >= link.y
&& svg_y <= link.y + link.height
}) {
if let Some(anchor) =
content.anchors.iter().find(|a| a.id == link.target_id)
{
pending_navigation = Some(anchor.bounds());
} else {
log::warn!("Anchor link target not found: {}", link.target_id);
}
}
}
if let Some((svg_x, svg_y)) = pointer_svg_hover {
if content.anchor_links.iter().any(|link| {
svg_x >= link.x
&& svg_x <= link.x + link.width
&& svg_y >= link.y
&& svg_y <= link.y + link.height
}) {
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
}
}
if self.render_internal_areas {
let mut hovered_descs: Vec<String> = Vec::new();
let pointer_svg = pointer_pos.map(screen_to_svg);
@ -481,6 +696,7 @@ impl eframe::App for TemplateApp {
}
// Text elements
let text_cache = self.text_cache.as_mut().expect("just initialized");
for text_elem in &content.texts {
let (x, y, w, h) = text_elem.bounds();
let rect =
@ -528,6 +744,10 @@ impl eframe::App for TemplateApp {
rendered_count += 1;
}
}
if let Some(rect) = pending_navigation {
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
}
});
// Debug window

View file

@ -1,7 +1,7 @@
//! SVG parsing module for extracting special elements from SVG files.
use quick_xml::Reader;
use quick_xml::events::Event;
use quick_xml::Reader;
use std::fs;
/// Trait for elements that can be rendered with a bounding box.
@ -77,6 +77,16 @@ pub struct TextElement {
pub font_size: f32, // Parsed from style attribute
}
/// A hyperlink pointing to an anchor by id.
#[derive(Debug, Clone)]
pub struct AnchorLink {
pub target_id: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Renderable for TextElement {
fn bounds(&self) -> (f32, f32, f32, f32) {
if self.lines.is_empty() {
@ -111,6 +121,7 @@ pub struct SvgContent {
pub video_scrolls: Vec<VideoScroll>,
pub audio_areas: Vec<AudioArea>,
pub anchors: Vec<Anchor>,
pub anchor_links: Vec<AnchorLink>,
pub texts: Vec<TextElement>,
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
pub background_color: Option<[u8; 3]>,
@ -139,6 +150,56 @@ enum PendingElement {
},
}
#[derive(Debug, Clone)]
struct LinkAccumulator {
target_id: String,
min_x: f32,
max_x: f32,
min_y: f32,
max_y: f32,
}
impl LinkAccumulator {
fn new(target_id: String) -> Self {
Self {
target_id,
min_x: f32::INFINITY,
max_x: f32::NEG_INFINITY,
min_y: f32::INFINITY,
max_y: f32::NEG_INFINITY,
}
}
fn update_bounds(&mut self, x: f32, y: f32, width: f32, height: f32) {
if width <= 0.0 || height <= 0.0 {
return;
}
self.min_x = self.min_x.min(x);
self.min_y = self.min_y.min(y);
self.max_x = self.max_x.max(x + width);
self.max_y = self.max_y.max(y + height);
}
fn finalize(&self) -> Option<(f32, f32, f32, f32)> {
if self.min_x.is_finite()
&& self.min_y.is_finite()
&& self.max_x.is_finite()
&& self.max_y.is_finite()
&& self.max_x > self.min_x
&& self.max_y > self.min_y
{
Some((
self.min_x,
self.min_y,
self.max_x - self.min_x,
self.max_y - self.min_y,
))
} else {
None
}
}
}
impl SvgContent {
/// Parse an SVG file and extract special elements.
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
@ -168,12 +229,28 @@ impl SvgContent {
let mut tspan_y = 0.0f32;
let mut tspan_content = String::new();
// Track hyperlink bounds targeting anchors
let mut current_link: Option<LinkAccumulator> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name_bytes = e.name();
let name = String::from_utf8_lossy(name_bytes.as_ref());
match name.as_ref() {
"a" => {
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
if key.as_ref().ends_with("href") {
let cleaned = value.trim_start_matches('#');
if cleaned.starts_with("anchor") {
current_link =
Some(LinkAccumulator::new(cleaned.to_string()));
}
}
}
}
"svg" => {
// Extract viewBox
for attr in e.attributes().flatten() {
@ -220,6 +297,10 @@ impl SvgContent {
width,
height,
});
if let Some(ref mut link) = current_link {
link.update_bounds(x, y, width, height);
}
}
"circle" => {
let mut cx = 0.0f32;
@ -240,6 +321,10 @@ impl SvgContent {
}
let _: bool = has_id_with_desc_potential; // May check desc child later
pending = Some(PendingElement::Circle { cx, cy, r });
if let Some(ref mut link) = current_link {
link.update_bounds(cx - r, cy - r, r * 2.0, r * 2.0);
}
}
"ellipse" => {
let mut cx = 0.0f32;
@ -259,6 +344,12 @@ impl SvgContent {
}
}
pending = Some(PendingElement::Ellipse { cx, cy, rx, ry });
if let Some(ref mut link) = current_link {
let width = rx * 2.0;
let height = ry * 2.0;
link.update_bounds(cx - rx, cy - ry, width, height);
}
}
"rect" => {
let mut id = String::new();
@ -291,6 +382,10 @@ impl SvgContent {
} else if id == "start" {
svg_content.start_rect = Some((x, y, width, height));
}
if let Some(ref mut link) = current_link {
link.update_bounds(x, y, width, height);
}
}
"text" => {
in_text = true;
@ -370,6 +465,10 @@ impl SvgContent {
} else if id == "start" {
svg_content.start_rect = Some((x, y, width, height));
}
if let Some(ref mut link) = current_link {
link.update_bounds(x, y, width, height);
}
}
}
Ok(Event::Text(ref e)) => {
@ -423,6 +522,19 @@ impl SvgContent {
"image" | "circle" | "ellipse" => {
pending = None;
}
"a" => {
if let Some(link) = current_link.take() {
if let Some((x, y, width, height)) = link.finalize() {
svg_content.anchor_links.push(AnchorLink {
target_id: link.target_id,
x,
y,
width,
height,
});
}
}
}
"tspan" => {
if in_tspan {
let content = tspan_content.trim().to_owned();
@ -444,6 +556,15 @@ impl SvgContent {
lines: text_lines.clone(),
font_size: text_font_size,
});
if let Some(ref mut link) = current_link {
let text_elem = TextElement {
lines: text_lines.clone(),
font_size: text_font_size,
};
let (x, y, w, h) = text_elem.bounds();
link.update_bounds(x, y, w, h);
}
}
in_text = false;
text_lines.clear();