Unified layout computation & cleanup

This commit is contained in:
Evan Carroll 2026-01-27 00:29:14 -06:00
parent e8ca7c9a12
commit ae210d5352
4 changed files with 307 additions and 193 deletions

View file

@ -1,5 +1,6 @@
//! Reusable UI components. //! Reusable UI components.
pub mod constants;
pub mod avatar; pub mod avatar;
pub mod avatar_editor; pub mod avatar_editor;
pub mod canvas_utils; pub mod canvas_utils;

View file

@ -14,6 +14,12 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
use super::chat_types::{ActiveBubble, emotion_bubble_colors}; use super::chat_types::{ActiveBubble, emotion_bubble_colors};
use super::constants::{
BASE_TEXT_SCALE, BUBBLE_BORDER_RADIUS_BASE, BUBBLE_BORDER_WIDTH, BUBBLE_FONT_SIZE_BASE,
BUBBLE_GAP_BASE, BUBBLE_LINE_HEIGHT, BUBBLE_MAX_WIDTH_BASE, BUBBLE_PADDING_BASE,
BUBBLE_TAIL_SIZE_BASE, LABEL_FONT_SIZE_BASE, LABEL_Y_OFFSET_BASE, Z_SPEECH_BUBBLE,
Z_USERNAME_LABEL,
};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas; pub use super::canvas_utils::hit_test_canvas;
@ -116,8 +122,9 @@ impl SceneTransform {
} }
} }
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 // =============================================================================
const BASE_TEXT_SCALE: f64 = 1.4; // Content Bounds - Avatar grid content analysis
// =============================================================================
/// Content bounds for a 3x3 avatar grid. /// Content bounds for a 3x3 avatar grid.
/// Tracks which rows/columns contain actual content for centering calculations. /// Tracks which rows/columns contain actual content for centering calculations.
@ -232,6 +239,10 @@ impl ContentBounds {
} }
} }
// =============================================================================
// Canvas Layout - Unified layout context
// =============================================================================
/// Unified layout context for avatar canvas rendering. /// Unified layout context for avatar canvas rendering.
/// ///
/// This struct computes all derived layout values once from the inputs, /// This struct computes all derived layout values once from the inputs,
@ -240,39 +251,39 @@ impl ContentBounds {
/// - Avatar positioning within the canvas /// - Avatar positioning within the canvas
/// - Coordinate transformations between canvas-local and screen space /// - Coordinate transformations between canvas-local and screen space
/// - Username label and speech bubble positioning /// - Username label and speech bubble positioning
#[derive(Clone, Copy)] #[derive(Clone, Copy, Debug, PartialEq)]
struct CanvasLayout { pub struct CanvasLayout {
// Core dimensions // Core dimensions
prop_size: f64, pub prop_size: f64,
avatar_size: f64, pub avatar_size: f64,
// Content offset from grid center // Content offset from grid center
content_x_offset: f64, pub content_x_offset: f64,
// Text scaling // Text scaling
text_scale: f64, pub text_scale: f64,
// Canvas dimensions // Canvas dimensions
canvas_width: f64, pub canvas_width: f64,
canvas_height: f64, pub canvas_height: f64,
// Canvas position in screen space // Canvas position in screen space
canvas_screen_x: f64, pub canvas_screen_x: f64,
canvas_screen_y: f64, pub canvas_screen_y: f64,
// Avatar center within canvas (canvas-local coordinates) // Avatar center within canvas (canvas-local coordinates)
avatar_cx: f64, pub avatar_cx: f64,
avatar_cy: f64, pub avatar_cy: f64,
// Content bounds info for positioning // Content bounds info for positioning
empty_top_rows: usize, pub empty_top_rows: usize,
empty_bottom_rows: usize, pub empty_bottom_rows: usize,
// Scene bounds for bubble clamping (screen coordinates) // Scene bounds for bubble clamping (screen coordinates)
scene_min_x: f64, pub scene_min_x: f64,
scene_max_x: f64, pub scene_max_x: f64,
scene_min_y: f64, pub scene_min_y: f64,
scene_max_y: f64, pub scene_max_y: f64,
} }
impl CanvasLayout { impl CanvasLayout {
@ -338,19 +349,29 @@ impl CanvasLayout {
} }
/// Content center X in canvas-local coordinates. /// Content center X in canvas-local coordinates.
fn content_center_x(&self) -> f64 { pub fn content_center_x(&self) -> f64 {
self.avatar_cx + self.content_x_offset self.avatar_cx + self.content_x_offset
} }
/// Top of avatar in canvas-local coordinates. /// Top of avatar in canvas-local coordinates.
fn avatar_top_y(&self) -> f64 { pub fn avatar_top_y(&self) -> f64 {
self.avatar_cy - self.avatar_size / 2.0 self.avatar_cy - self.avatar_size / 2.0
} }
/// Bottom of avatar in canvas-local coordinates. /// Bottom of avatar in canvas-local coordinates.
fn avatar_bottom_y(&self) -> f64 { pub fn avatar_bottom_y(&self) -> f64 {
self.avatar_cy + self.avatar_size / 2.0 self.avatar_cy + self.avatar_size / 2.0
} }
/// Top of actual content (accounting for empty rows).
pub fn content_top_y(&self) -> f64 {
self.avatar_top_y() + self.empty_top_rows as f64 * self.prop_size
}
/// Bottom of actual content (accounting for empty rows).
pub fn content_bottom_y(&self) -> f64 {
self.avatar_bottom_y() - self.empty_bottom_rows as f64 * self.prop_size
}
} }
/// Get a unique key for a member (for Leptos For keying). /// Get a unique key for a member (for Leptos For keying).
@ -359,13 +380,141 @@ pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid {
m.member.user_id m.member.user_id
} }
// =============================================================================
// Bubble Position
// =============================================================================
/// Position of speech bubble relative to avatar. /// Position of speech bubble relative to avatar.
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug, Default)]
enum BubblePosition { enum BubblePosition {
#[default]
Above, Above,
Below, Below,
} }
// =============================================================================
// Bubble Style Helper
// =============================================================================
/// Parameters for bubble style generation.
struct BubbleStyleParams {
position_type: &'static str,
left: f64,
top: f64,
width_style: String,
opacity: f64,
bg_color: &'static str,
border_color: &'static str,
text_color: &'static str,
tail_offset: f64,
text_scale: f64,
}
/// Generate the CSS style string for a speech bubble.
fn build_bubble_style(params: &BubbleStyleParams) -> String {
let font_size = BUBBLE_FONT_SIZE_BASE * params.text_scale;
let padding = BUBBLE_PADDING_BASE * params.text_scale;
let tail_size = BUBBLE_TAIL_SIZE_BASE * params.text_scale;
let border_radius = BUBBLE_BORDER_RADIUS_BASE * params.text_scale;
format!(
"position: {}; \
left: {}px; \
top: {}px; \
{} \
opacity: {}; \
--bubble-bg: {}; \
--bubble-border: {}; \
--bubble-text: {}; \
--tail-offset: {}%; \
--font-size: {}px; \
--padding: {}px; \
--tail-size: {}px; \
--border-radius: {}px; \
z-index: {}; \
pointer-events: none;",
params.position_type,
params.left,
params.top,
params.width_style,
params.opacity,
params.bg_color,
params.border_color,
params.text_color,
params.tail_offset,
font_size,
padding,
tail_size,
border_radius,
Z_SPEECH_BUBBLE,
)
}
/// Calculate bubble position based on layout and measured dimensions.
///
/// Returns (left, top, tail_offset, position) in wrapper-relative coordinates,
/// or None if not yet measured.
fn calculate_bubble_position(
layout: &CanvasLayout,
bubble_width: f64,
bubble_height: f64,
) -> (f64, f64, f64, BubblePosition) {
let tail_size = BUBBLE_TAIL_SIZE_BASE * layout.text_scale;
let gap = BUBBLE_GAP_BASE * layout.text_scale;
// Avatar bounds in canvas/wrapper-local coordinates
let avatar_center_x = layout.content_center_x();
let avatar_top = layout.content_top_y();
let avatar_bottom = layout.content_bottom_y();
// Convert to screen coordinates for clamping
let avatar_screen_center_x = layout.canvas_screen_x + avatar_center_x;
let avatar_screen_top = layout.canvas_screen_y + avatar_top;
let avatar_screen_bottom = layout.canvas_screen_y + avatar_bottom;
// Horizontal positioning (in screen coordinates)
let ideal_bubble_screen_left = avatar_screen_center_x - bubble_width / 2.0;
let clamped_bubble_screen_left = ideal_bubble_screen_left
.max(layout.scene_min_x)
.min((layout.scene_max_x - bubble_width).max(layout.scene_min_x));
// Vertical positioning - determine above or below
let space_above = avatar_screen_top - layout.scene_min_y;
let space_needed = bubble_height + tail_size + gap;
let position = if space_above >= space_needed {
BubblePosition::Above
} else {
BubblePosition::Below
};
let ideal_bubble_screen_top = match position {
BubblePosition::Above => avatar_screen_top - gap - tail_size - bubble_height,
BubblePosition::Below => avatar_screen_bottom + gap + tail_size,
};
let clamped_bubble_screen_top = ideal_bubble_screen_top
.max(layout.scene_min_y)
.min((layout.scene_max_y - bubble_height).max(layout.scene_min_y));
// Convert back to wrapper-relative coordinates
let bubble_left = clamped_bubble_screen_left - layout.canvas_screen_x;
let bubble_top = clamped_bubble_screen_top - layout.canvas_screen_y;
// Calculate tail offset (where avatar is relative to bubble)
let tail_offset = if bubble_width > 0.0 {
let avatar_rel_to_bubble = avatar_center_x - bubble_left;
(avatar_rel_to_bubble / bubble_width * 100.0).clamp(10.0, 90.0)
} else {
50.0
};
(bubble_left, bubble_top, tail_offset, position)
}
// =============================================================================
// Avatar Component
// =============================================================================
/// Individual avatar component. /// Individual avatar component.
/// ///
/// Renders a single avatar with: /// Renders a single avatar with:
@ -401,17 +550,16 @@ pub fn Avatar(
// Disable pointer-events when fading (opacity < 1.0) so fading avatars aren't clickable // 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 pointer_events = if opacity < 1.0 { "none" } else { "auto" };
// Signal to store computed layout for use by label and bubble
let (layout_signal, set_layout_signal) = signal(None::<CanvasLayout>);
// Bubble measurement state - only SIZE is stored, position is calculated reactively // Bubble measurement state - only SIZE is stored, position is calculated reactively
let (bubble_measured_width, set_bubble_measured_width) = signal(0.0_f64); let (bubble_measured_width, set_bubble_measured_width) = signal(0.0_f64);
let (bubble_measured_height, set_bubble_measured_height) = signal(0.0_f64); let (bubble_measured_height, set_bubble_measured_height) = signal(0.0_f64);
let (bubble_is_measured, set_bubble_is_measured) = signal(false); let (bubble_is_measured, set_bubble_is_measured) = signal(false);
let (bubble_position, set_bubble_position) = signal(BubblePosition::Above); let (bubble_position, set_bubble_position) = signal(BubblePosition::Above);
// Compute layout reactively // ==========================================================================
let compute_layout = move || { // Unified layout computation - single Memo used by all parts of the component
// ==========================================================================
let layout_memo: Memo<CanvasLayout> = Memo::new(move |_| {
let m = member.get(); let m = member.get();
let ps = prop_size.get(); let ps = prop_size.get();
let t = transform.get(); let t = transform.get();
@ -439,11 +587,13 @@ pub fn Avatar(
avatar_screen_y, avatar_screen_y,
boundaries, boundaries,
) )
}; });
// ==========================================================================
// Wrapper style (positions the entire avatar container) // Wrapper style (positions the entire avatar container)
// ==========================================================================
let wrapper_style = move || { let wrapper_style = move || {
let layout = compute_layout(); let layout = layout_memo.get();
format!( format!(
"position: absolute; \ "position: absolute; \
left: 0; top: 0; \ left: 0; top: 0; \
@ -464,18 +614,15 @@ pub fn Avatar(
) )
}; };
// Canvas style (fills the wrapper) // ==========================================================================
let canvas_style = "width: 100%; height: 100%;";
// Label style (positioned relative to wrapper, below avatar) // Label style (positioned relative to wrapper, below avatar)
// ==========================================================================
let label_style = move || { let label_style = move || {
let layout = compute_layout(); let layout = layout_memo.get();
let font_size = 12.0 * layout.text_scale; let font_size = LABEL_FONT_SIZE_BASE * layout.text_scale;
// Position at content center X, below content bottom // Position at content center X, below content bottom
let label_x = layout.content_center_x(); let label_x = layout.content_center_x();
let label_y = layout.avatar_bottom_y() let label_y = layout.content_bottom_y() + LABEL_Y_OFFSET_BASE * layout.text_scale;
- layout.empty_bottom_rows as f64 * layout.prop_size
+ 15.0 * layout.text_scale;
format!( format!(
"position: absolute; \ "position: absolute; \
@ -484,8 +631,8 @@ pub fn Avatar(
transform: translateX(-50%); \ transform: translateX(-50%); \
font-size: {}px; \ font-size: {}px; \
white-space: nowrap; \ white-space: nowrap; \
z-index: 99998;", z-index: {};",
label_x, label_y, font_size label_x, label_y, font_size, Z_USERNAME_LABEL
) )
}; };
@ -493,12 +640,11 @@ pub fn Avatar(
let display_name = move || member.get().member.display_name.clone(); let display_name = move || member.get().member.display_name.clone();
// Compute data-member-id reactively // Compute data-member-id reactively
let data_member_id = move || { let data_member_id = move || member.get().member.user_id.to_string();
let m = member.get();
m.member.user_id.to_string()
};
// Store references for the canvas drawing effect // ==========================================================================
// Canvas drawing effect
// ==========================================================================
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
use std::cell::RefCell; use std::cell::RefCell;
@ -521,40 +667,12 @@ pub fn Avatar(
// Get current values from signals // Get current values from signals
let m = member.get(); let m = member.get();
let layout = layout_memo.get();
let ps = prop_size.get();
let te = text_em_size.get();
let Some(canvas) = canvas_ref.get() else { let Some(canvas) = canvas_ref.get() else {
return; return;
}; };
// Calculate content bounds for the avatar
let content_bounds = ContentBounds::from_layers(
&m.avatar.skin_layer,
&m.avatar.clothes_layer,
&m.avatar.accessories_layer,
&m.avatar.emotion_layer,
);
// Use passed-in transform and screen bounds (computed once at scene level)
let t = transform.get();
let boundaries = screen_bounds.get();
let (avatar_screen_x, avatar_screen_y) =
t.to_screen(m.member.position_x, m.member.position_y);
let layout = CanvasLayout::new(
&content_bounds,
ps,
te,
avatar_screen_x,
avatar_screen_y,
boundaries,
);
// Store layout for use by label and bubble
set_layout_signal.set(Some(layout));
let canvas_el: &web_sys::HtmlCanvasElement = &canvas; let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution from layout // Set canvas resolution from layout
@ -705,6 +823,10 @@ pub fn Avatar(
} }
}); });
// ==========================================================================
// Bubble measurement effect
// ==========================================================================
// Track the message ID we've measured to detect new messages // Track the message ID we've measured to detect new messages
let (last_measured_message_id, set_last_measured_message_id) = let (last_measured_message_id, set_last_measured_message_id) =
signal(Option::<uuid::Uuid>::None); signal(Option::<uuid::Uuid>::None);
@ -768,77 +890,10 @@ pub fn Avatar(
}); });
} }
// Calculate bubble position reactively based on current layout // ==========================================================================
// This runs whenever the avatar moves, keeping the bubble following the avatar // Bubble visibility and style
let calc_bubble_position = move || -> Option<(f64, f64, f64, BubblePosition)> { // ==========================================================================
let layout = layout_signal.get()?;
let measured = bubble_is_measured.get();
if !measured {
return None;
}
let bubble_width = bubble_measured_width.get();
let bubble_height = bubble_measured_height.get();
if bubble_width <= 0.0 || bubble_height <= 0.0 {
return None;
}
let tail_size = 8.0 * layout.text_scale;
let gap = 5.0 * layout.text_scale;
// === AVATAR BOUNDS (in canvas/wrapper-local coordinates) ===
let avatar_center_x = layout.content_center_x();
let avatar_top =
layout.avatar_top_y() + layout.empty_top_rows as f64 * layout.prop_size;
let avatar_bottom =
layout.avatar_bottom_y() - layout.empty_bottom_rows as f64 * layout.prop_size;
// === CONVERT TO SCENE COORDINATES FOR CLAMPING ===
let avatar_screen_center_x = layout.canvas_screen_x + avatar_center_x;
let avatar_screen_top = layout.canvas_screen_y + avatar_top;
let avatar_screen_bottom = layout.canvas_screen_y + avatar_bottom;
// === HORIZONTAL POSITIONING (in scene coordinates) ===
let ideal_bubble_screen_left = avatar_screen_center_x - bubble_width / 2.0;
let clamped_bubble_screen_left = ideal_bubble_screen_left
.max(layout.scene_min_x)
.min(layout.scene_max_x - bubble_width);
// === VERTICAL POSITIONING ===
let space_above = avatar_screen_top - layout.scene_min_y;
let space_needed = bubble_height + tail_size + gap;
let position = if space_above >= space_needed {
BubblePosition::Above
} else {
BubblePosition::Below
};
let ideal_bubble_screen_top = match position {
BubblePosition::Above => avatar_screen_top - gap - tail_size - bubble_height,
BubblePosition::Below => avatar_screen_bottom + gap + tail_size,
};
let clamped_bubble_screen_top = ideal_bubble_screen_top
.max(layout.scene_min_y)
.min(layout.scene_max_y - bubble_height);
// === CONVERT BACK TO WRAPPER-RELATIVE COORDINATES ===
let bubble_left = clamped_bubble_screen_left - layout.canvas_screen_x;
let bubble_top = clamped_bubble_screen_top - layout.canvas_screen_y;
// === CALCULATE TAIL OFFSET ===
let tail_offset = if bubble_width > 0.0 {
let avatar_rel_to_bubble = avatar_center_x - bubble_left;
(avatar_rel_to_bubble / bubble_width * 100.0).clamp(10.0, 90.0)
} else {
50.0
};
Some((bubble_left, bubble_top, tail_offset, position))
};
// Bubble visibility check
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let is_bubble_visible = move || { let is_bubble_visible = move || {
let Some(bubble_signal) = active_bubble else { let Some(bubble_signal) = active_bubble else {
@ -851,11 +906,8 @@ pub fn Avatar(
now < bubble.expires_at now < bubble.expires_at
}; };
#[cfg(not(feature = "hydrate"))] #[cfg(not(feature = "hydrate"))]
let is_bubble_visible = move || { let is_bubble_visible = move || active_bubble.map(|s| s.get().is_some()).unwrap_or(false);
active_bubble.map(|s| s.get().is_some()).unwrap_or(false)
};
// Bubble style
let bubble_style = move || { let bubble_style = move || {
let Some(bubble_signal) = active_bubble else { let Some(bubble_signal) = active_bubble else {
return "display: none;".to_string(); return "display: none;".to_string();
@ -863,29 +915,25 @@ pub fn Avatar(
let Some(bubble) = bubble_signal.get() else { let Some(bubble) = bubble_signal.get() else {
return "display: none;".to_string(); return "display: none;".to_string();
}; };
let Some(layout) = layout_signal.get() else {
return "display: none;".to_string();
};
let layout = layout_memo.get();
let (bg_color, border_color, text_color) = let (bg_color, border_color, text_color) =
emotion_bubble_colors(&bubble.message.emotion); emotion_bubble_colors(&bubble.message.emotion);
let max_width = 200.0 * layout.text_scale; let max_width = BUBBLE_MAX_WIDTH_BASE * layout.text_scale;
let padding = 8.0 * layout.text_scale; let font_size = BUBBLE_FONT_SIZE_BASE * layout.text_scale;
let tail_size = 8.0 * layout.text_scale; let padding = BUBBLE_PADDING_BASE * layout.text_scale;
let font_size = 12.0 * layout.text_scale;
let border_width = 2.0; // CSS border width
let measured = bubble_is_measured.get(); let measured = bubble_is_measured.get();
let m_height = bubble_measured_height.get(); let m_height = bubble_measured_height.get();
let m_width = bubble_measured_width.get();
// Detect multi-line: if measured height exceeds what a single line would be // Detect multi-line: if measured height exceeds what a single line would be
// Single line height = font-size * line-height + padding * 2 + border * 2 let single_line_height =
let single_line_height = font_size * 1.5 + padding * 2.0 + border_width * 2.0; font_size * BUBBLE_LINE_HEIGHT + padding * 2.0 + BUBBLE_BORDER_WIDTH * 2.0;
let is_multiline = measured && m_height > single_line_height * 1.3; let is_multiline = measured && m_height > single_line_height * 1.3;
// Width strategy: fit-content for single line, explicit max-width for multi-line // Width strategy: fit-content for single line, explicit max-width for multi-line
// This ensures long text gets full width while short text shrinks to fit
let width_style = if is_multiline { let width_style = if is_multiline {
format!("width: {}px;", max_width) format!("width: {}px;", max_width)
} else { } else {
@ -894,10 +942,12 @@ pub fn Avatar(
// Get reactive position (recalculates when avatar moves) // Get reactive position (recalculates when avatar moves)
let (position_type, final_left, final_top, tail_offset, position) = let (position_type, final_left, final_top, tail_offset, position) =
if let Some((left, top, tail, pos)) = calc_bubble_position() { if measured && m_width > 0.0 && m_height > 0.0 {
let (left, top, tail, pos) =
calculate_bubble_position(&layout, m_width, m_height);
("absolute", left, top, tail, pos) ("absolute", left, top, tail, pos)
} else { } else {
// Not yet measured - use fixed positioning off-screen // Not yet measured - position off-screen for measurement
("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above) ("fixed", -1000.0, -1000.0, 50.0, BubblePosition::Above)
}; };
@ -907,36 +957,18 @@ pub fn Avatar(
// Store position for bubble_class to use // Store position for bubble_class to use
set_bubble_position.set(position); set_bubble_position.set(position);
format!( build_bubble_style(&BubbleStyleParams {
"position: {}; \
left: {}px; \
top: {}px; \
{} \
opacity: {}; \
--bubble-bg: {}; \
--bubble-border: {}; \
--bubble-text: {}; \
--tail-offset: {}%; \
--font-size: {}px; \
--padding: {}px; \
--tail-size: {}px; \
--border-radius: {}px; \
z-index: 99999; \
pointer-events: none;",
position_type, position_type,
final_left, left: final_left,
final_top, top: final_top,
width_style, width_style,
bubble_opacity, opacity: bubble_opacity,
bg_color, bg_color,
border_color, border_color,
text_color, text_color,
tail_offset, tail_offset,
font_size, text_scale: layout.text_scale,
padding, })
tail_size,
8.0 * layout.text_scale,
)
}; };
let bubble_class = move || { let bubble_class = move || {
@ -961,11 +993,15 @@ pub fn Avatar(
.unwrap_or(false) .unwrap_or(false)
}; };
// ==========================================================================
// View
// ==========================================================================
view! { view! {
<div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id> <div class="avatar-wrapper" style=wrapper_style data-member-id=data_member_id>
<canvas <canvas
node_ref=canvas_ref node_ref=canvas_ref
style=canvas_style style="width: 100%; height: 100%;"
/> />
<div class="username-label" style=label_style> <div class="username-label" style=label_style>
{display_name} {display_name}

View file

@ -0,0 +1,76 @@
//! Shared constants for UI components.
//!
//! Centralizes magic numbers and configuration values used across multiple components.
// =============================================================================
// Text Scaling
// =============================================================================
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
pub const BASE_TEXT_SCALE: f64 = 1.4;
// =============================================================================
// Z-Index Layers
// =============================================================================
/// Z-index for the props container.
pub const Z_PROPS: i32 = 1;
/// Z-index for individual loose props inside the container.
pub const Z_LOOSE_PROP: i32 = 5;
/// Z-index for the avatars container.
pub const Z_AVATARS_CONTAINER: i32 = 2;
/// Z-index for fading avatars (below active avatars).
pub const Z_FADING_AVATAR: i32 = 5;
/// Base z-index for active avatars (actual z-index is this + sort order).
pub const Z_AVATAR_BASE: i32 = 10;
/// Z-index for the click overlay (captures scene interactions).
pub const Z_CLICK_OVERLAY: i32 = 100;
/// Z-index for username labels (above avatars, below bubbles).
pub const Z_USERNAME_LABEL: i32 = 200;
/// Z-index for speech bubbles (topmost in scene).
pub const Z_SPEECH_BUBBLE: i32 = 201;
// =============================================================================
// Bubble Dimensions
// =============================================================================
/// Maximum width of speech bubbles (before text scaling).
pub const BUBBLE_MAX_WIDTH_BASE: f64 = 200.0;
/// Padding inside speech bubbles (before text scaling).
pub const BUBBLE_PADDING_BASE: f64 = 8.0;
/// Size of the bubble tail/pointer (before text scaling).
pub const BUBBLE_TAIL_SIZE_BASE: f64 = 8.0;
/// Gap between avatar and bubble (before text scaling).
pub const BUBBLE_GAP_BASE: f64 = 5.0;
/// Border radius of speech bubbles (before text scaling).
pub const BUBBLE_BORDER_RADIUS_BASE: f64 = 8.0;
/// Font size for bubble text (before text scaling).
pub const BUBBLE_FONT_SIZE_BASE: f64 = 12.0;
/// CSS border width for bubbles.
pub const BUBBLE_BORDER_WIDTH: f64 = 2.0;
/// Line height multiplier for bubble text.
pub const BUBBLE_LINE_HEIGHT: f64 = 1.5;
// =============================================================================
// Label Dimensions
// =============================================================================
/// Font size for username labels (before text scaling).
pub const LABEL_FONT_SIZE_BASE: f64 = 12.0;
/// Vertical offset for username label below avatar content (before text scaling).
pub const LABEL_Y_OFFSET_BASE: f64 = 15.0;

View file

@ -23,6 +23,7 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key}; use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key};
use super::constants::{Z_AVATAR_BASE, Z_AVATARS_CONTAINER, Z_CLICK_OVERLAY, Z_FADING_AVATAR, Z_LOOSE_PROP, Z_PROPS};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas; use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble; use super::chat_types::ActiveBubble;
@ -488,7 +489,7 @@ pub fn RealmSceneViewer(
style=move || canvas_style(0) style=move || canvas_style(0)
aria-hidden="true" aria-hidden="true"
/> />
<div class="props-container absolute inset-0" style="z-index: 1; pointer-events: none;"> <div class="props-container absolute inset-0" style=format!("z-index: {}; pointer-events: none;", Z_PROPS)>
<Show when=move || scales_ready.get() fallback=|| ()> <Show when=move || scales_ready.get() fallback=|| ()>
{move || { {move || {
loose_props.get().into_iter().map(|prop| { loose_props.get().into_iter().map(|prop| {
@ -501,14 +502,14 @@ pub fn RealmSceneViewer(
prop=prop_signal prop=prop_signal
transform=scene_transform transform=scene_transform
base_prop_size=prop_size base_prop_size=prop_size
z_index=5 z_index=Z_LOOSE_PROP
/> />
} }
}).collect_view() }).collect_view()
}} }}
</Show> </Show>
</div> </div>
<div class="avatars-container absolute inset-0" style="z-index: 2; pointer-events: none; overflow: visible;"> <div class="avatars-container absolute inset-0" style=format!("z-index: {}; pointer-events: none; overflow: visible;", Z_AVATARS_CONTAINER)>
<Show when=move || scales_ready.get() fallback=|| ()> <Show when=move || scales_ready.get() fallback=|| ()>
{move || { {move || {
member_keys.get().into_iter().map(|key| { member_keys.get().into_iter().map(|key| {
@ -516,7 +517,7 @@ pub fn RealmSceneViewer(
members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist") members_by_key.get().get(&key).map(|(_, m)| m.clone()).expect("member key should exist")
}); });
let z_index_signal = Signal::derive(move || { let z_index_signal = Signal::derive(move || {
members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + 10).unwrap_or(10) members_by_key.get().get(&key).map(|(idx, _)| (*idx as i32) + Z_AVATAR_BASE).unwrap_or(Z_AVATAR_BASE)
}); });
let z = z_index_signal.get_untracked(); let z = z_index_signal.get_untracked();
// Derive bubble signal for this member // Derive bubble signal for this member
@ -557,7 +558,7 @@ pub fn RealmSceneViewer(
member=member_signal member=member_signal
transform=scene_transform transform=scene_transform
prop_size=prop_size prop_size=prop_size
z_index=5 z_index=Z_FADING_AVATAR
text_em_size=text_em_size text_em_size=text_em_size
opacity=opacity opacity=opacity
screen_bounds=screen_bounds screen_bounds=screen_bounds
@ -570,7 +571,7 @@ pub fn RealmSceneViewer(
</div> </div>
<div <div
class="click-overlay absolute inset-0" class="click-overlay absolute inset-0"
style="z-index: 5; cursor: pointer;" style=format!("z-index: {}; cursor: pointer;", Z_CLICK_OVERLAY)
aria-label=format!("Scene: {}", scene_name) aria-label=format!("Scene: {}", scene_name)
role="img" role="img"
on:click=move |ev| { on:click=move |ev| {