add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View 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, &current_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);
}
}