diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 2a34b4e..0d60a8b 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1772,7 +1772,7 @@ pub struct ChannelMember { } /// Channel member with user info for display. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ChannelMemberInfo { pub id: Uuid, @@ -1814,7 +1814,7 @@ pub struct UpdateEmotionRequest { /// Data needed to render an avatar's current appearance. /// Contains the asset paths for all equipped props. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AvatarRenderData { pub avatar_id: Uuid, pub current_emotion: i16, @@ -1842,7 +1842,7 @@ impl Default for AvatarRenderData { } /// Channel member with full avatar render data. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChannelMemberWithAvatar { pub member: ChannelMemberInfo, pub avatar: AvatarRenderData, diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index c556d8a..8fb66f2 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -96,103 +96,100 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { /// - 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, /// X scale factor for coordinate conversion. - scale_x: f64, + scale_x: Signal, /// Y scale factor for coordinate conversion. - scale_y: f64, + scale_y: Signal, /// X offset for coordinate conversion. - offset_x: f64, + offset_x: Signal, /// Y offset for coordinate conversion. - offset_y: f64, + offset_y: Signal, /// Size of the avatar in pixels. - prop_size: f64, + prop_size: Signal, /// Z-index for stacking order (higher = on top). z_index: i32, /// Active speech bubble for this user (if any). - active_bubble: Option, + active_bubble: Signal>, /// 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, /// 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::::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! { } } diff --git a/crates/chattyness-user-ui/src/components/chat_types.rs b/crates/chattyness-user-ui/src/components/chat_types.rs index e998f66..c91c1a7 100644 --- a/crates/chattyness-user-ui/src/components/chat_types.rs +++ b/crates/chattyness-user-ui/src/components/chat_types.rs @@ -11,7 +11,7 @@ pub const MAX_MESSAGE_LOG_SIZE: usize = 2000; pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000; /// A chat message for display and logging. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatMessage { pub message_id: Uuid, pub user_id: Option, @@ -73,7 +73,7 @@ impl MessageLog { } /// Active speech bubble state for a user. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ActiveBubble { pub message: ChatMessage, /// When the bubble should expire (milliseconds since epoch). diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 0e3cc24..d104f66 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -737,6 +737,31 @@ pub fn RealmSceneViewer( // Text size multiplier from settings let text_em_size = Signal::derive(move || settings.get().text_em_size); + // Create signals for scale/offset values to pass to AvatarCanvas + let scale_x_signal = Signal::derive(move || scale_x.get_value()); + let scale_y_signal = Signal::derive(move || scale_y.get_value()); + let offset_x_signal = Signal::derive(move || offset_x.get_value()); + let offset_y_signal = Signal::derive(move || offset_y.get_value()); + + // Create a map of members by key for efficient lookup + let members_by_key = Signal::derive(move || { + use std::collections::HashMap; + sorted_members.get() + .into_iter() + .enumerate() + .map(|(idx, m)| (member_key(&m), (idx, m))) + .collect::>() + }); + + // Get the list of member keys - use Memo so it only updates when keys actually change + // (not when member data like position changes) + let member_keys = Memo::new(move |_| { + sorted_members.get() + .iter() + .map(member_key) + .collect::>() + }); + let scene_name = scene.name.clone(); view! { @@ -764,76 +789,97 @@ pub fn RealmSceneViewer( class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none;" > - {move || { - // Wait for scale factors to be calculated before rendering avatars - if !scales_ready.get() { - return Vec::new().into_iter().collect_view(); - } + // Wait for scale factors before rendering + + // Use stable keys - each AvatarCanvas gets its own derived signal + {move || { + member_keys.get().into_iter().map(|key| { + // Create a derived signal for this specific member + let member_signal = Signal::derive(move || { + members_by_key.get() + .get(&key) + .map(|(_, m)| m.clone()) + .expect("member key should exist") + }); - let current_bubbles = active_bubbles.get(); - let sx = scale_x.get_value(); - let sy = scale_y.get_value(); - let ox = offset_x.get_value(); - let oy = offset_y.get_value(); - let ps = prop_size.get(); - let te = text_em_size.get(); + // Derive z-index from position in sorted list + let z_index_signal = Signal::derive(move || { + members_by_key.get() + .get(&key) + .map(|(idx, _)| (*idx as i32) + 10) + .unwrap_or(10) + }); + + // Derive bubble for this member + let bubble_signal = Signal::derive(move || { + active_bubbles.get().get(&key).cloned() + }); + + let z = z_index_signal.get_untracked(); - // Render active members - let mut views: Vec<_> = sorted_members.get() - .into_iter() - .enumerate() - .map(|(idx, member)| { - let key = member_key(&member); - let bubble = current_bubbles.get(&key).cloned(); - let z = (idx as i32) + 10; view! { } - }) - .collect(); + }).collect_view() + }} + // Fading members use closure approach (temporary, per-frame updates) + {move || { + let Some(fading_signal) = fading_members else { + return Vec::new().into_iter().collect_view(); + }; - // Render fading members with calculated opacity - if let Some(fading_signal) = fading_members { #[cfg(feature = "hydrate")] let now = js_sys::Date::now() as i64; #[cfg(not(feature = "hydrate"))] let now = 0i64; - for fading in fading_signal.get() { - let elapsed = now - fading.fade_start; - if elapsed < fading.fade_duration { - let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64); - let opacity = opacity.max(0.0).min(1.0); - views.push(view! { - - }); - } - } - } - - views.into_iter().collect_view() - }} + fading_signal.get() + .into_iter() + .filter_map(|fading| { + let elapsed = now - fading.fade_start; + if elapsed < fading.fade_duration { + let opacity = 1.0 - (elapsed as f64 / fading.fade_duration as f64); + let opacity = opacity.max(0.0).min(1.0); + // Fading members get static signals (they're temporary) + let member_signal = Signal::derive({ + let m = fading.member.clone(); + move || m.clone() + }); + let bubble_signal: Signal> = Signal::derive(|| None); + Some(view! { + + }) + } else { + None + } + }) + .collect_view() + }} + // Click overlay - captures clicks for movement and hit-testing