feat: add anchors, links; smooth pan/zoom animations
This commit is contained in:
parent
c374ba0fc8
commit
24cbc894de
2 changed files with 368 additions and 27 deletions
272
src/app.rs
272
src/app.rs
|
|
@ -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
|
||||
|
|
|
|||
123
src/svg.rs
123
src/svg.rs
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue