add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
//! Scene viewer component for displaying realm scenes with avatars.
|
||||
//!
|
||||
//! Uses layered canvases for efficient rendering:
|
||||
//! - Background canvas: Static, drawn once when scene loads
|
||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
|
||||
let trimmed = bounds_wkt.trim();
|
||||
let coords_str = trimmed
|
||||
.strip_prefix("POLYGON((")
|
||||
.and_then(|s| s.strip_suffix("))"))?;
|
||||
|
||||
let points: Vec<&str> = coords_str.split(',').collect();
|
||||
if points.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut max_x: f64 = 0.0;
|
||||
let mut max_y: f64 = 0.0;
|
||||
|
||||
for point in points.iter() {
|
||||
let coords: Vec<&str> = point.trim().split_whitespace().collect();
|
||||
if coords.len() >= 2 {
|
||||
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if max_x > 0.0 && max_y > 0.0 {
|
||||
Some((max_x as u32, max_y as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene viewer component for displaying a realm scene with avatars.
|
||||
///
|
||||
/// Uses two layered canvases:
|
||||
/// - Background canvas (z-index 0): Static background, drawn once
|
||||
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
|
||||
#[component]
|
||||
pub fn RealmSceneViewer(
|
||||
scene: Scene,
|
||||
#[allow(unused)]
|
||||
realm_slug: String,
|
||||
#[prop(into)]
|
||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||
#[prop(into)]
|
||||
on_move: Callback<(f64, f64)>,
|
||||
) -> impl IntoView {
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
||||
|
||||
let bg_color = scene
|
||||
.background_color
|
||||
.clone()
|
||||
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let has_background_image = scene.background_image_path.is_some();
|
||||
#[allow(unused_variables)]
|
||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||
|
||||
// Two separate canvas refs for layered rendering
|
||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
let scale_x = StoredValue::new(1.0_f64);
|
||||
let scale_y = StoredValue::new(1.0_f64);
|
||||
let offset_x = StoredValue::new(0.0_f64);
|
||||
let offset_y = StoredValue::new(0.0_f64);
|
||||
|
||||
// Handle canvas click for movement (on avatar canvas - topmost layer)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_canvas_click = {
|
||||
let on_move = on_move.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let rect = canvas_el.get_bounding_client_rect();
|
||||
|
||||
let canvas_x = ev.client_x() as f64 - rect.left();
|
||||
let canvas_y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
if sx > 0.0 && sy > 0.0 {
|
||||
let scene_x = (canvas_x - ox) / sx;
|
||||
let scene_y = (canvas_y - oy) / sy;
|
||||
|
||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
on_move.run((scene_x, scene_y));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
let image_path_clone = image_path.clone();
|
||||
let bg_color_clone = bg_color.clone();
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Flag to track if background has been drawn
|
||||
let bg_drawn = Rc::new(RefCell::new(false));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - runs once on mount, draws static background
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Don't track any reactive signals - this should only run once
|
||||
let Some(canvas) = bg_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip if already drawn
|
||||
if *bg_drawn.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
let bg_color = bg_color_clone.clone();
|
||||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
|
||||
// Mark background as drawn
|
||||
*bg_drawn_inner.borrow_mut() = true;
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref());
|
||||
draw_bg.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members change, redraws avatars only
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track members signal - this Effect reruns when members change
|
||||
let current_members = members.get();
|
||||
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize avatar canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency (not fill - keeps canvas transparent)
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Draw avatars
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref());
|
||||
draw_avatars_closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
||||
view! {
|
||||
<div class="scene-container w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
||||
style:background-color=bg_color.clone()
|
||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
||||
style:max-height="calc(100vh - 64px)"
|
||||
>
|
||||
// Background layer - static, drawn once
|
||||
<canvas
|
||||
node_ref=bg_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
on_canvas_click(ev);
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
>
|
||||
{format!("Scene: {}", scene.name)}
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_avatars(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
members: &[ChannelMemberWithAvatar],
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
) {
|
||||
for member in members {
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
let avatar_size = 48.0 * scale_x.min(scale_y);
|
||||
|
||||
// Draw avatar placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#6366f1");
|
||||
ctx.fill();
|
||||
|
||||
// Draw skin layer sprite if available
|
||||
if let Some(ref skin_path) = member.avatar.skin_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(skin_path));
|
||||
}
|
||||
|
||||
// Draw emotion overlay if available
|
||||
if let Some(ref emotion_path) = member.avatar.emotion_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(emotion_path));
|
||||
}
|
||||
|
||||
// Draw emotion indicator on avatar
|
||||
let emotion = member.member.current_emotion;
|
||||
if emotion > 0 {
|
||||
// Draw emotion number in a small badge
|
||||
let badge_size = 16.0 * scale_x.min(scale_y);
|
||||
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||
|
||||
// Badge background
|
||||
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"); // Amber color for emotion badge
|
||||
ctx.fill();
|
||||
|
||||
// Emotion number
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Draw display name
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue