feat: make canvas refresh more efficient
This commit is contained in:
parent
b430c80000
commit
8447fdef5d
5 changed files with 507 additions and 408 deletions
372
crates/chattyness-user-ui/src/components/avatar_canvas.rs
Normal file
372
crates/chattyness-user-ui/src/components/avatar_canvas.rs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
//! Individual avatar canvas component for per-user rendering.
|
||||
//!
|
||||
//! Each avatar gets its own canvas element positioned via CSS transforms.
|
||||
//! This enables efficient updates: position changes only update CSS (no redraw),
|
||||
//! while appearance changes (emotion, skin) redraw only that avatar's canvas.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||
|
||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||
use super::settings::BASE_PROP_SIZE;
|
||||
|
||||
/// Get a unique key for a member (for Leptos For keying).
|
||||
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
|
||||
(m.member.user_id, m.member.guest_session_id)
|
||||
}
|
||||
|
||||
/// Individual avatar canvas component.
|
||||
///
|
||||
/// Renders a single avatar with:
|
||||
/// - CSS transform for position (GPU-accelerated, no redraw on move)
|
||||
/// - Canvas for avatar sprite (redraws only on appearance change)
|
||||
/// - Optional speech bubble above the avatar
|
||||
#[component]
|
||||
pub fn AvatarCanvas(
|
||||
/// The member data for this avatar.
|
||||
member: ChannelMemberWithAvatar,
|
||||
/// X scale factor for coordinate conversion.
|
||||
scale_x: f64,
|
||||
/// Y scale factor for coordinate conversion.
|
||||
scale_y: f64,
|
||||
/// X offset for coordinate conversion.
|
||||
offset_x: f64,
|
||||
/// Y offset for coordinate conversion.
|
||||
offset_y: f64,
|
||||
/// Size of the avatar in pixels.
|
||||
prop_size: f64,
|
||||
/// Z-index for stacking order (higher = on top).
|
||||
z_index: i32,
|
||||
/// Active speech bubble for this user (if any).
|
||||
active_bubble: Option<ActiveBubble>,
|
||||
) -> impl IntoView {
|
||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Clone data for use in closures
|
||||
let skin_layer = member.avatar.skin_layer.clone();
|
||||
let emotion_layer = member.avatar.emotion_layer.clone();
|
||||
let display_name = member.member.display_name.clone();
|
||||
let current_emotion = member.member.current_emotion;
|
||||
|
||||
// Calculate canvas position from scene coordinates
|
||||
let canvas_x = member.member.position_x * scale_x + offset_x - prop_size / 2.0;
|
||||
let canvas_y = member.member.position_y * scale_y + offset_y - prop_size;
|
||||
|
||||
// Calculate canvas size (extra height for bubble and name)
|
||||
let bubble_extra = if active_bubble.is_some() { prop_size * 1.5 } else { 0.0 };
|
||||
let name_extra = 20.0;
|
||||
let canvas_width = prop_size.max(200.0); // Wide enough for bubble
|
||||
let canvas_height = prop_size + bubble_extra + name_extra;
|
||||
|
||||
// Adjust position to account for extra space above avatar
|
||||
let adjusted_y = canvas_y - bubble_extra;
|
||||
|
||||
// CSS positioning via transform (GPU-accelerated)
|
||||
let style = format!(
|
||||
"position: absolute; \
|
||||
left: 0; top: 0; \
|
||||
transform: translate({}px, {}px); \
|
||||
z-index: {}; \
|
||||
pointer-events: auto; \
|
||||
width: {}px; \
|
||||
height: {}px;",
|
||||
canvas_x - (canvas_width - prop_size) / 2.0,
|
||||
adjusted_y,
|
||||
z_index,
|
||||
canvas_width,
|
||||
canvas_height
|
||||
);
|
||||
|
||||
// Store references for the effect
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// Image cache for this avatar (persists across re-renders)
|
||||
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
||||
Rc::new(RefCell::new(HashMap::new()));
|
||||
|
||||
// Redraw trigger - incremented when images load to cause Effect to re-run
|
||||
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
|
||||
|
||||
// Clone values for the effect
|
||||
let skin_layer_clone = skin_layer.clone();
|
||||
let emotion_layer_clone = emotion_layer.clone();
|
||||
let display_name_clone = display_name.clone();
|
||||
let active_bubble_clone = active_bubble.clone();
|
||||
|
||||
// Effect to draw the avatar when canvas is ready or appearance changes
|
||||
Effect::new(move |_| {
|
||||
// Subscribe to redraw trigger so this effect re-runs when images load
|
||||
let _ = redraw_trigger.get();
|
||||
let Some(canvas) = canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
|
||||
// Set canvas resolution
|
||||
canvas_el.set_width(canvas_width as u32);
|
||||
canvas_el.set_height(canvas_height as u32);
|
||||
|
||||
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
|
||||
return;
|
||||
};
|
||||
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
|
||||
|
||||
// Clear canvas
|
||||
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
|
||||
|
||||
// Avatar center position within the canvas
|
||||
let avatar_cx = canvas_width / 2.0;
|
||||
let avatar_cy = bubble_extra + prop_size / 2.0;
|
||||
|
||||
// Draw placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(
|
||||
avatar_cx,
|
||||
avatar_cy,
|
||||
prop_size / 2.0,
|
||||
0.0,
|
||||
std::f64::consts::PI * 2.0,
|
||||
);
|
||||
ctx.set_fill_style_str("#6366f1");
|
||||
ctx.fill();
|
||||
|
||||
// Helper to load and draw an image
|
||||
// Images are cached; when loaded, triggers a redraw via signal
|
||||
let draw_image = |path: &str, cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>, ctx: &web_sys::CanvasRenderingContext2d, x: f64, y: f64, size: f64| {
|
||||
let normalized_path = normalize_asset_path(path);
|
||||
let mut cache_borrow = cache.borrow_mut();
|
||||
|
||||
if let Some(img) = cache_borrow.get(&normalized_path) {
|
||||
// Image is in cache - draw if loaded
|
||||
if img.complete() && img.natural_width() > 0 {
|
||||
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
img,
|
||||
x - size / 2.0,
|
||||
y - size / 2.0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
}
|
||||
// If not complete, onload handler will trigger redraw
|
||||
} else {
|
||||
// Not in cache - create and start loading
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
|
||||
// Set onload handler to trigger redraw when image loads
|
||||
let trigger = set_redraw_trigger;
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
trigger.update(|v| *v += 1);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
|
||||
img.set_src(&normalized_path);
|
||||
cache_borrow.insert(normalized_path, img);
|
||||
}
|
||||
};
|
||||
|
||||
// Draw skin layer (position 4 = center)
|
||||
if let Some(ref skin_path) = skin_layer_clone[4] {
|
||||
draw_image(skin_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size);
|
||||
}
|
||||
|
||||
// Draw emotion overlay (position 4 = center)
|
||||
if let Some(ref emotion_path) = emotion_layer_clone[4] {
|
||||
draw_image(emotion_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size);
|
||||
}
|
||||
|
||||
// Scale factor for text/badges
|
||||
let text_scale = prop_size / BASE_PROP_SIZE;
|
||||
|
||||
// Draw emotion badge if non-neutral
|
||||
if current_emotion > 0 {
|
||||
let badge_size = 16.0 * text_scale;
|
||||
let badge_x = avatar_cx + prop_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = avatar_cy - prop_size / 2.0 - badge_size / 2.0;
|
||||
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#f59e0b");
|
||||
ctx.fill();
|
||||
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Draw display name below avatar
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&display_name_clone, avatar_cx, avatar_cy + prop_size / 2.0 + 15.0 * text_scale);
|
||||
|
||||
// Draw speech bubble if active
|
||||
if let Some(ref bubble) = active_bubble_clone {
|
||||
let current_time = js_sys::Date::now() as i64;
|
||||
if bubble.expires_at >= current_time {
|
||||
draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - prop_size / 2.0, prop_size);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<canvas
|
||||
node_ref=canvas_ref
|
||||
style=style
|
||||
data-member-id=member.member.user_id.map(|u| u.to_string()).or_else(|| member.member.guest_session_id.map(|g| g.to_string())).unwrap_or_default()
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/static/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a speech bubble above the avatar.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_bubble(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
bubble: &ActiveBubble,
|
||||
center_x: f64,
|
||||
top_y: f64,
|
||||
prop_size: f64,
|
||||
) {
|
||||
let text_scale = prop_size / BASE_PROP_SIZE;
|
||||
let max_bubble_width = 200.0 * text_scale;
|
||||
let padding = 8.0 * text_scale;
|
||||
let font_size = 12.0 * text_scale;
|
||||
let line_height = 16.0 * text_scale;
|
||||
let tail_size = 8.0 * text_scale;
|
||||
let border_radius = 8.0 * text_scale;
|
||||
|
||||
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
|
||||
|
||||
// Measure and wrap text
|
||||
ctx.set_font(&format!("{}px sans-serif", font_size));
|
||||
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||
|
||||
// Calculate bubble dimensions
|
||||
let bubble_width = lines
|
||||
.iter()
|
||||
.map(|line| ctx.measure_text(line).map(|m| m.width()).unwrap_or(0.0))
|
||||
.fold(0.0_f64, |a, b| a.max(b))
|
||||
+ padding * 2.0;
|
||||
let bubble_width = bubble_width.max(60.0 * text_scale);
|
||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||
|
||||
// Position bubble above avatar
|
||||
let bubble_x = center_x - bubble_width / 2.0;
|
||||
let bubble_y = top_y - bubble_height - tail_size - 5.0 * text_scale;
|
||||
|
||||
// Draw bubble background
|
||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||
ctx.set_fill_style_str(bg_color);
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str(border_color);
|
||||
ctx.set_line_width(2.0);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw tail
|
||||
ctx.begin_path();
|
||||
ctx.move_to(center_x - tail_size, bubble_y + bubble_height);
|
||||
ctx.line_to(center_x, bubble_y + bubble_height + tail_size);
|
||||
ctx.line_to(center_x + tail_size, bubble_y + bubble_height);
|
||||
ctx.close_path();
|
||||
ctx.set_fill_style_str(bg_color);
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str(border_color);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.set_fill_style_str(text_color);
|
||||
ctx.set_text_align("left");
|
||||
ctx.set_text_baseline("top");
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let _ = ctx.fill_text(line, bubble_x + padding, bubble_y + padding + (i as f64) * line_height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap text to fit within max_width.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
let mut lines = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
|
||||
for word in words {
|
||||
let test_line = if current_line.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{} {}", current_line, word)
|
||||
};
|
||||
|
||||
let width = ctx.measure_text(&test_line).map(|m| m.width()).unwrap_or(0.0);
|
||||
|
||||
if width > max_width && !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
current_line = word.to_string();
|
||||
} else {
|
||||
current_line = test_line;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
}
|
||||
|
||||
// Limit to 4 lines
|
||||
if lines.len() > 4 {
|
||||
lines.truncate(3);
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.push_str("...");
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(text.to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Draw a rounded rectangle path.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_rounded_rect(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
radius: f64,
|
||||
) {
|
||||
ctx.begin_path();
|
||||
ctx.move_to(x + radius, y);
|
||||
ctx.line_to(x + width - radius, y);
|
||||
ctx.quadratic_curve_to(x + width, y, x + width, y + radius);
|
||||
ctx.line_to(x + width, y + height - radius);
|
||||
ctx.quadratic_curve_to(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.line_to(x + radius, y + height);
|
||||
ctx.quadratic_curve_to(x, y + height, x, y + height - radius);
|
||||
ctx.line_to(x, y + radius);
|
||||
ctx.quadratic_curve_to(x, y, x + radius, y);
|
||||
ctx.close_path();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue