fix: eliminate redraws on position changes

This commit is contained in:
Evan Carroll 2026-01-18 00:47:46 -06:00
parent 5fcd49e847
commit b361460485
4 changed files with 235 additions and 176 deletions

View file

@ -96,103 +96,100 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
/// - Optional speech bubble above the avatar
#[component]
pub fn AvatarCanvas(
/// The member data for this avatar.
member: ChannelMemberWithAvatar,
/// The member data for this avatar (as a signal for reactive updates).
member: Signal<ChannelMemberWithAvatar>,
/// X scale factor for coordinate conversion.
scale_x: f64,
scale_x: Signal<f64>,
/// Y scale factor for coordinate conversion.
scale_y: f64,
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: f64,
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: f64,
offset_y: Signal<f64>,
/// Size of the avatar in pixels.
prop_size: f64,
prop_size: Signal<f64>,
/// Z-index for stacking order (higher = on top).
z_index: i32,
/// Active speech bubble for this user (if any).
active_bubble: Option<ActiveBubble>,
active_bubble: Signal<Option<ActiveBubble>>,
/// Text size multiplier for display names, chat bubbles, and badges.
#[prop(default = 1.0)]
text_em_size: f64,
#[prop(default = 1.0.into())]
text_em_size: Signal<f64>,
/// Opacity for fade-out animation (0.0 to 1.0, default 1.0).
#[prop(default = 1.0)]
opacity: f64,
) -> 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 clothes_layer = member.avatar.clothes_layer.clone();
let accessories_layer = member.avatar.accessories_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 content bounds for centering on actual content
let content_bounds = ContentBounds::from_layers(
&skin_layer,
&clothes_layer,
&accessories_layer,
&emotion_layer,
);
// Get offsets from grid center to content center
let x_content_offset = content_bounds.x_offset(prop_size);
let y_content_offset = content_bounds.y_offset(prop_size);
let empty_bottom_rows = content_bounds.empty_bottom_rows();
// Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = prop_size * 3.0;
// Calculate canvas position from scene coordinates, adjusted for content bounds
// Both X and Y center the avatar content on the click point
// Note: x_content_offset and y_content_offset are already in viewport pixels (prop_size includes scale)
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset;
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size / 2.0 - y_content_offset;
// Fixed text dimensions (independent of prop_size/zoom)
// Text stays readable regardless of zoom level - only affected by text_em_size slider
let text_scale = text_em_size * BASE_TEXT_SCALE;
let fixed_bubble_height = if active_bubble.is_some() {
// 4 lines * 16px line_height + 16px padding + 8px tail + 5px margin
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Canvas must fit both avatar AND fixed-size text
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
// Adjust bubble_extra for positioning (used later in avatar_cy calculation)
let bubble_extra = fixed_bubble_height;
// Adjust position to account for extra space above avatar
let adjusted_y = canvas_y - bubble_extra;
// CSS positioning via transform (GPU-accelerated)
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable
let pointer_events = if opacity < 1.0 { "none" } else { "auto" };
let style = format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
canvas_x - (canvas_width - avatar_size) / 2.0,
adjusted_y,
z_index,
pointer_events,
canvas_width,
canvas_height,
opacity
);
// Reactive style for CSS positioning (GPU-accelerated transforms)
// This closure re-runs when position, scale, offset, or prop_size changes
let style = move || {
let m = member.get();
let ps = prop_size.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let te = text_em_size.get();
let bubble = active_bubble.get();
// Calculate content bounds for centering on actual content
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
&m.avatar.accessories_layer,
&m.avatar.emotion_layer,
);
// Get offsets from grid center to content center
let x_content_offset = content_bounds.x_offset(ps);
let y_content_offset = content_bounds.y_offset(ps);
// Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = ps * 3.0;
// Calculate canvas position from scene coordinates, adjusted for content bounds
let canvas_x = m.member.position_x * sx + ox - avatar_size / 2.0 - x_content_offset;
let canvas_y = m.member.position_y * sy + oy - avatar_size / 2.0 - y_content_offset;
// Fixed text dimensions (independent of prop_size/zoom)
let text_scale = te * BASE_TEXT_SCALE;
let fixed_bubble_height = if bubble.is_some() {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
// Canvas must fit both avatar AND fixed-size text
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
// Adjust position to account for extra space above avatar
let adjusted_y = canvas_y - fixed_bubble_height;
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: {}; \
width: {}px; \
height: {}px; \
opacity: {};",
canvas_x - (canvas_width - avatar_size) / 2.0,
adjusted_y,
z_index,
pointer_events,
canvas_width,
canvas_height,
opacity
)
};
// Store references for the effect
#[cfg(feature = "hydrate")]
@ -210,22 +207,42 @@ pub fn AvatarCanvas(
// 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 clothes_layer_clone = clothes_layer.clone();
let accessories_layer_clone = accessories_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();
// Get current values from signals
let m = member.get();
let ps = prop_size.get();
let te = text_em_size.get();
let bubble = active_bubble.get();
let Some(canvas) = canvas_ref.get() else {
return;
};
// Calculate dimensions (same as in style closure)
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
&m.avatar.accessories_layer,
&m.avatar.emotion_layer,
);
let avatar_size = ps * 3.0;
let text_scale = te * BASE_TEXT_SCALE;
let fixed_bubble_height = if bubble.is_some() {
(4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale
} else {
0.0
};
let fixed_name_height = 20.0 * text_scale;
let fixed_text_width = 200.0 * text_scale;
let canvas_width = avatar_size.max(fixed_text_width);
let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height;
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution
@ -242,7 +259,7 @@ pub fn AvatarCanvas(
// Avatar center position within the canvas
let avatar_cx = canvas_width / 2.0;
let avatar_cy = bubble_extra + avatar_size / 2.0;
let avatar_cy = fixed_bubble_height + avatar_size / 2.0;
// Helper to load and draw an image
// Images are cached; when loaded, triggers a redraw via signal
@ -280,18 +297,13 @@ pub fn AvatarCanvas(
};
// Draw all 9 positions of the avatar grid (3x3 layout)
// Grid positions:
// 0 1 2
// 3 4 5
// 6 7 8
// Each cell is full prop_size, grid is 3x3
let cell_size = prop_size;
let cell_size = ps;
let grid_origin_x = avatar_cx - avatar_size / 2.0;
let grid_origin_y = avatar_cy - avatar_size / 2.0;
// Draw skin layer for all 9 positions
for pos in 0..9 {
if let Some(ref skin_path) = skin_layer_clone[pos] {
if let Some(ref skin_path) = m.avatar.skin_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
@ -302,7 +314,7 @@ pub fn AvatarCanvas(
// Draw clothes layer for all 9 positions
for pos in 0..9 {
if let Some(ref clothes_path) = clothes_layer_clone[pos] {
if let Some(ref clothes_path) = m.avatar.clothes_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
@ -313,7 +325,7 @@ pub fn AvatarCanvas(
// Draw accessories layer for all 9 positions
for pos in 0..9 {
if let Some(ref accessories_path) = accessories_layer_clone[pos] {
if let Some(ref accessories_path) = m.avatar.accessories_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
@ -324,7 +336,7 @@ pub fn AvatarCanvas(
// Draw emotion overlay for all 9 positions
for pos in 0..9 {
if let Some(ref emotion_path) = emotion_layer_clone[pos] {
if let Some(ref emotion_path) = m.avatar.emotion_layer[pos] {
let col = pos % 3;
let row = pos / 3;
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
@ -333,10 +345,8 @@ pub fn AvatarCanvas(
}
}
// Text scale independent of zoom - only affected by user's text_em_size setting
let text_scale = text_em_size * BASE_TEXT_SCALE;
// Draw emotion badge if non-neutral
let current_emotion = m.member.current_emotion;
if current_emotion > 0 {
let badge_size = 16.0 * text_scale;
let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0;
@ -355,16 +365,11 @@ pub fn AvatarCanvas(
}
// Calculate content bounds for name positioning
let content_bounds = ContentBounds::from_layers(
&skin_layer_clone,
&clothes_layer_clone,
&accessories_layer_clone,
&emotion_layer_clone,
);
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
let empty_bottom_rows = content_bounds.empty_bottom_rows();
// Draw display name below avatar (with black outline for readability)
let display_name = &m.member.display_name;
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
ctx.set_text_align("center");
ctx.set_text_baseline("alphabetic");
@ -372,26 +377,34 @@ pub fn AvatarCanvas(
// Black outline
ctx.set_stroke_style_str("#000");
ctx.set_line_width(3.0);
let _ = ctx.stroke_text(&display_name_clone, name_x, name_y);
let _ = ctx.stroke_text(display_name, name_x, name_y);
// White fill
ctx.set_fill_style_str("#fff");
let _ = ctx.fill_text(&display_name_clone, name_x, name_y);
let _ = ctx.fill_text(display_name, name_x, name_y);
// Draw speech bubble if active
if let Some(ref bubble) = active_bubble_clone {
if let Some(ref b) = bubble {
let current_time = js_sys::Date::now() as i64;
if bubble.expires_at >= current_time {
draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, text_em_size);
if b.expires_at >= current_time {
draw_bubble(&ctx, b, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, te);
}
}
});
}
// Compute data-member-id reactively
let data_member_id = move || {
let m = member.get();
m.member.user_id.map(|u| u.to_string())
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
.unwrap_or_default()
};
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()
data-member-id=data_member_id
/>
}
}