fix: scaling, and chat

* Chat ergonomics vastly improved.
* Scaling now done through client side settings
This commit is contained in:
Evan Carroll 2026-01-14 12:53:16 -06:00
parent 98f38c9714
commit b430c80000
8 changed files with 1564 additions and 439 deletions

View file

@ -0,0 +1,179 @@
//! Scene viewer display settings with localStorage persistence.
use serde::{Deserialize, Serialize};
/// LocalStorage key for viewer settings.
const SETTINGS_KEY: &str = "chattyness_viewer_settings";
/// Reference resolution for enlarged props calculation.
pub const REFERENCE_WIDTH: f64 = 1920.0;
pub const REFERENCE_HEIGHT: f64 = 1080.0;
/// Base size for props and avatars in scene space.
pub const BASE_PROP_SIZE: f64 = 60.0;
/// Minimum zoom level (25%).
pub const ZOOM_MIN: f64 = 0.25;
/// Maximum zoom level (400%).
pub const ZOOM_MAX: f64 = 4.0;
/// Zoom step increment.
pub const ZOOM_STEP: f64 = 0.25;
/// Pan step in pixels for keyboard navigation.
pub const PAN_STEP: f64 = 50.0;
/// Calculate the minimum zoom level for pan mode.
///
/// - Large scenes: min zoom fills the viewport
/// - Small scenes: min zoom is 1.0 (native resolution, centered)
pub fn calculate_min_zoom(
scene_width: f64,
scene_height: f64,
viewport_width: f64,
viewport_height: f64,
) -> f64 {
if scene_width <= 0.0
|| scene_height <= 0.0
|| viewport_width <= 0.0
|| viewport_height <= 0.0
{
return 1.0;
}
let min_x = viewport_width / scene_width;
let min_y = viewport_height / scene_height;
// For large scenes: min zoom fills viewport
// For small scenes: clamp to 1.0 (native resolution)
min_x.max(min_y).min(1.0)
}
/// Scene viewer display settings.
///
/// These settings control how the scene is rendered and are persisted
/// to localStorage for user preference retention.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ViewerSettings {
/// When true, canvas shows at native resolution with scrolling.
/// When false, canvas scales to fit viewport (default).
pub panning_enabled: bool,
/// Zoom level (0.25 to 4.0). Only applicable when `panning_enabled` is true.
pub zoom_level: f64,
/// When true, props use reference scaling based on 1920x1080.
/// Only applicable when `panning_enabled` is false.
pub enlarge_props: bool,
/// Saved horizontal scroll position for pan mode.
pub scroll_x: f64,
/// Saved vertical scroll position for pan mode.
pub scroll_y: f64,
}
impl Default for ViewerSettings {
fn default() -> Self {
Self {
panning_enabled: false,
zoom_level: 1.0,
enlarge_props: false,
scroll_x: 0.0,
scroll_y: 0.0,
}
}
}
impl ViewerSettings {
/// Load settings from localStorage, returning defaults if not found or invalid.
#[cfg(feature = "hydrate")]
pub fn load() -> Self {
let Some(window) = web_sys::window() else {
return Self::default();
};
let Ok(Some(storage)) = window.local_storage() else {
return Self::default();
};
let Ok(Some(json)) = storage.get_item(SETTINGS_KEY) else {
return Self::default();
};
serde_json::from_str(&json).unwrap_or_default()
}
/// Stub for SSR - returns default settings.
#[cfg(not(feature = "hydrate"))]
pub fn load() -> Self {
Self::default()
}
/// Save settings to localStorage.
#[cfg(feature = "hydrate")]
pub fn save(&self) {
let Some(window) = web_sys::window() else {
return;
};
let Ok(Some(storage)) = window.local_storage() else {
return;
};
if let Ok(json) = serde_json::to_string(self) {
let _ = storage.set_item(SETTINGS_KEY, &json);
}
}
/// Stub for SSR - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn save(&self) {}
/// Calculate the effective prop size based on current settings.
///
/// In pan mode, returns base size * zoom level.
/// In fit mode with enlarge_props, returns size adjusted for reference resolution.
/// Otherwise returns base size (caller should multiply by canvas scale).
pub fn calculate_prop_size(&self, scene_width: f64, scene_height: f64) -> f64 {
if self.panning_enabled {
// Pan mode: base size * zoom
BASE_PROP_SIZE * self.zoom_level
} else if self.enlarge_props {
// Reference scaling: ensure minimum size based on 1920x1080
let scale_w = scene_width / REFERENCE_WIDTH;
let scale_h = scene_height / REFERENCE_HEIGHT;
BASE_PROP_SIZE * scale_w.max(scale_h)
} else {
// Default: base size (will be scaled by canvas scale factor)
BASE_PROP_SIZE
}
}
/// Adjust zoom level by a delta, clamping to valid range.
///
/// If `min_zoom` is provided, uses that as the floor instead of `ZOOM_MIN`.
pub fn adjust_zoom(&mut self, delta: f64) {
self.zoom_level = (self.zoom_level + delta).clamp(ZOOM_MIN, ZOOM_MAX);
}
/// Adjust zoom level with a custom minimum.
pub fn adjust_zoom_with_min(&mut self, delta: f64, min_zoom: f64) {
let effective_min = min_zoom.max(ZOOM_MIN);
self.zoom_level = (self.zoom_level + delta).clamp(effective_min, ZOOM_MAX);
}
/// Clamp zoom level to an effective minimum.
pub fn clamp_zoom_min(&mut self, min_zoom: f64) {
let effective_min = min_zoom.max(ZOOM_MIN);
if self.zoom_level < effective_min {
self.zoom_level = effective_min;
}
}
/// Reset scroll position to origin.
pub fn reset_scroll(&mut self) {
self.scroll_x = 0.0;
self.scroll_y = 0.0;
}
}