From 44b322371c7f72df981f991b7f24c89f9ba49ab87527d2bc225ed48bb9ecc0a4 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sun, 18 Jan 2026 18:06:21 -0600 Subject: [PATCH] fix: finally got prop scaling, text scaling, canvas scaling, and bubble drawing to work --- .../src/components/avatar_canvas.rs | 542 ++++++++++-------- 1 file changed, 298 insertions(+), 244 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 3943066..d2ae953 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -226,6 +226,235 @@ fn determine_bubble_position( } } +/// Unified layout context for avatar canvas rendering. +/// +/// This struct computes all derived layout values once from the inputs, +/// providing a single source of truth for: +/// - Canvas dimensions and position +/// - Avatar positioning within the canvas +/// - Coordinate transformations between canvas-local and screen space +/// - Bubble positioning and clamping +/// +/// By centralizing these calculations, we avoid scattered, duplicated logic +/// and ensure the style closure, Effect, and draw_bubble all use consistent values. +#[derive(Clone, Copy)] +#[allow(dead_code)] // Some fields kept for potential future use +struct CanvasLayout { + // Core dimensions + prop_size: f64, + avatar_size: f64, + + // Content offset from grid center + content_x_offset: f64, + content_y_offset: f64, + + // Text scaling + text_scale: f64, + bubble_max_width: f64, + + // Canvas dimensions + canvas_width: f64, + canvas_height: f64, + + // Canvas position in screen space + canvas_screen_x: f64, + canvas_screen_y: f64, + + // Avatar center within canvas (canvas-local coordinates) + avatar_cx: f64, + avatar_cy: f64, + + // Scene boundaries for clamping + boundaries: ScreenBoundaries, + + // Bubble state + bubble_position: BubblePosition, + bubble_height_reserved: f64, + + // Content row info for positioning + empty_top_rows: usize, + empty_bottom_rows: usize, +} + +impl CanvasLayout { + /// Create a new layout from all input parameters. + fn new( + content_bounds: &ContentBounds, + prop_size: f64, + text_em_size: f64, + avatar_screen_x: f64, + avatar_screen_y: f64, + boundaries: ScreenBoundaries, + has_bubble: bool, + bubble_text: Option<&str>, + ) -> Self { + let avatar_size = prop_size * 3.0; + let text_scale = text_em_size * BASE_TEXT_SCALE; + let bubble_max_width = 200.0 * text_scale; + + // Content offsets from grid center + let content_x_offset = content_bounds.x_offset(prop_size); + let content_y_offset = content_bounds.y_offset(prop_size); + + // Empty rows for positioning elements relative to content + let empty_top_rows = content_bounds.empty_top_rows(); + let empty_bottom_rows = content_bounds.empty_bottom_rows(); + + // Content dimensions for clamping + let content_half_width = content_bounds.content_width(prop_size) / 2.0; + let content_half_height = content_bounds.content_height(prop_size) / 2.0; + + // Clamp avatar so content stays within scene + let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( + avatar_screen_x, + avatar_screen_y, + content_half_width, + content_half_height, + ); + + // Calculate bubble height and position + let bubble_height_reserved = if has_bubble { + (4.0 * 16.0 + 16.0 + 8.0 + 5.0) * text_scale + } else { + 0.0 + }; + let name_height = 20.0 * text_scale; + + // Determine bubble position (above or below) + // Use actual content height, not full 3x3 grid size + let bubble_position = if has_bubble { + let estimated_height = bubble_text + .map(|t| estimate_bubble_height(t, text_scale)) + .unwrap_or(0.0); + // clamped_y is the content center, so use content_half_height + // to find the actual top of the visible avatar content + determine_bubble_position( + clamped_y, + content_half_height, + estimated_height, + 0.0, + 0.0, + boundaries.min_y, + ) + } else { + BubblePosition::Above + }; + + // Canvas dimensions - wide enough to fit shifted bubble + let extra_margin = if has_bubble { bubble_max_width } else { 0.0 }; + let canvas_width = avatar_size.max(bubble_max_width) + extra_margin; + let canvas_height = avatar_size + bubble_height_reserved + name_height; + + // Canvas position in screen space + // The avatar grid center maps to canvas_width/2, but we need to account + // for the content offset so the visible content aligns with clamped_x/y + let canvas_x = clamped_x - avatar_size / 2.0 - content_x_offset; + let canvas_screen_x = canvas_x - (canvas_width - avatar_size) / 2.0; + + let canvas_y = clamped_y - avatar_size / 2.0 - content_y_offset; + let canvas_screen_y = match bubble_position { + BubblePosition::Above => canvas_y - bubble_height_reserved, + BubblePosition::Below => canvas_y, + }; + + // Avatar center within canvas + let avatar_cx = canvas_width / 2.0; + let avatar_cy = match bubble_position { + BubblePosition::Above => bubble_height_reserved + avatar_size / 2.0, + BubblePosition::Below => avatar_size / 2.0, + }; + + Self { + prop_size, + avatar_size, + content_x_offset, + content_y_offset, + text_scale, + bubble_max_width, + canvas_width, + canvas_height, + canvas_screen_x, + canvas_screen_y, + avatar_cx, + avatar_cy, + boundaries, + bubble_position, + bubble_height_reserved, + empty_top_rows, + empty_bottom_rows, + } + } + + /// CSS style string for positioning the canvas element. + fn css_style(&self, z_index: i32, pointer_events: &str, opacity: f64) -> String { + format!( + "position: absolute; \ + left: 0; top: 0; \ + transform: translate({}px, {}px); \ + z-index: {}; \ + pointer-events: {}; \ + width: {}px; \ + height: {}px; \ + opacity: {};", + self.canvas_screen_x, + self.canvas_screen_y, + z_index, + pointer_events, + self.canvas_width, + self.canvas_height, + opacity + ) + } + + /// Content center X in canvas-local coordinates. + fn content_center_x(&self) -> f64 { + self.avatar_cx + self.content_x_offset + } + + /// Top of avatar in canvas-local coordinates. + fn avatar_top_y(&self) -> f64 { + self.avatar_cy - self.avatar_size / 2.0 + } + + /// Bottom of avatar in canvas-local coordinates. + fn avatar_bottom_y(&self) -> f64 { + self.avatar_cy + self.avatar_size / 2.0 + } + + /// Convert canvas-local X to screen X. + fn canvas_to_screen_x(&self, x: f64) -> f64 { + self.canvas_screen_x + x + } + + /// Clamp a bubble's X position to stay within scene boundaries. + /// Takes and returns canvas-local coordinates. + fn clamp_bubble_x(&self, bubble_x: f64, bubble_width: f64) -> f64 { + // Convert to screen space + let screen_left = self.canvas_to_screen_x(bubble_x); + let screen_right = screen_left + bubble_width; + + // Calculate shifts needed to stay within bounds + let shift_right = (self.boundaries.min_x - screen_left).max(0.0); + let shift_left = (screen_right - self.boundaries.max_x).max(0.0); + + // Apply shift and clamp to canvas bounds + let shifted = bubble_x + shift_right - shift_left; + shifted.max(0.0).min(self.canvas_width - bubble_width) + } + + /// Adjustment for bubble Y position based on empty rows at top. + /// Returns the distance in pixels from grid top to content top. + fn content_top_adjustment(&self) -> f64 { + self.empty_top_rows as f64 * self.prop_size + } + + /// Adjustment for name Y position based on empty rows at bottom. + /// Returns the distance in pixels from grid bottom to content bottom. + fn content_bottom_adjustment(&self) -> f64 { + self.empty_bottom_rows as f64 * self.prop_size + } +} + /// Get a unique key for a member (for Leptos For keying). pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option, Option) { (m.member.user_id, m.member.guest_session_id) @@ -293,102 +522,29 @@ pub fn AvatarCanvas( &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; - // Get scene dimensions (use large defaults if not provided) let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - // Compute screen boundaries for avatar clamping + // Compute screen boundaries and avatar screen position let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Calculate raw avatar screen position let avatar_screen_x = m.member.position_x * sx + ox; let avatar_screen_y = m.member.position_y * sy + oy; - // Clamp avatar center so visual bounds stay within screen boundaries - // Use actual content extent rather than full 3x3 grid - let content_half_width = content_bounds.content_width(ps) / 2.0; - let content_half_height = content_bounds.content_height(ps) / 2.0; - let (clamped_x, clamped_y) = boundaries.clamp_avatar_center( + // Create unified layout - all calculations happen in one place + let layout = CanvasLayout::new( + &content_bounds, + ps, + te, avatar_screen_x, avatar_screen_y, - content_half_width, - content_half_height, + boundaries, + bubble.is_some(), + bubble.as_ref().map(|b| b.message.content.as_str()), ); - // Calculate canvas position from clamped screen coordinates, adjusted for content bounds - let canvas_x = clamped_x - avatar_size / 2.0 - x_content_offset; - let canvas_y = clamped_y - 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; - - // Determine bubble position based on available space above avatar - // This must match the logic in the Effect that draws the bubble - let bubble_position = if bubble.is_some() { - // Use clamped avatar screen position for bubble calculation - let avatar_half_height = avatar_size / 2.0 + y_content_offset; - - // Calculate bubble height using actual content (includes tail + gap) - let estimated_bubble_height = bubble - .as_ref() - .map(|b| estimate_bubble_height(&b.message.content, text_scale)) - .unwrap_or(0.0); - - determine_bubble_position( - clamped_y, - avatar_half_height, - estimated_bubble_height, - 0.0, // Already included in estimate_bubble_height - 0.0, // Already included in estimate_bubble_height - boundaries.min_y, - ) - } else { - BubblePosition::Above // Default when no bubble - }; - - // Canvas must fit avatar, text, AND bubble (positioned based on bubble location) - let canvas_width = avatar_size.max(fixed_text_width); - let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; - - // Adjust position based on bubble position - // When bubble is above: offset canvas upward to make room at top - // When bubble is below: no upward offset, bubble goes below avatar - let adjusted_y = match bubble_position { - BubblePosition::Above => canvas_y - fixed_bubble_height, - BubblePosition::Below => canvas_y, - }; - - 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 - ) + // Generate CSS style from layout + layout.css_style(z_index, pointer_events, opacity) }; // Store references for the effect @@ -422,7 +578,7 @@ pub fn AvatarCanvas( return; }; - // Calculate dimensions (same as in style closure) + // Calculate content bounds for the avatar let content_bounds = ContentBounds::from_layers( &m.avatar.skin_layer, &m.avatar.clothes_layer, @@ -430,61 +586,35 @@ pub fn AvatarCanvas( &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; + // Get scene dimensions and transform parameters + let sx = scale_x.get(); + let sy = scale_y.get(); + let ox = offset_x.get(); + let oy = offset_y.get(); + let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); + let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - // Determine bubble position early so we can position the avatar correctly - let y_content_offset = content_bounds.y_offset(ps); - let bubble_position = if bubble.is_some() { - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + // Create unified layout - same calculation as style closure + let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); + let avatar_screen_x = m.member.position_x * sx + ox; + let avatar_screen_y = m.member.position_y * sy + oy; - // Get scene dimensions (use large defaults if not provided) - let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); - let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - - // Compute screen boundaries - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Calculate avatar's screen position - let avatar_screen_y = m.member.position_y * sy + oy; - let avatar_half_height = avatar_size / 2.0 + y_content_offset; - - // Calculate bubble height using actual content (includes tail + gap) - let estimated_bubble_height = bubble - .as_ref() - .map(|b| estimate_bubble_height(&b.message.content, text_scale)) - .unwrap_or(0.0); - - determine_bubble_position( - avatar_screen_y, - avatar_half_height, - estimated_bubble_height, - 0.0, // Already included in estimate_bubble_height - 0.0, // Already included in estimate_bubble_height - boundaries.min_y, - ) - } else { - BubblePosition::Above - }; - - let canvas_width = avatar_size.max(fixed_text_width); - let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; + let layout = CanvasLayout::new( + &content_bounds, + ps, + te, + avatar_screen_x, + avatar_screen_y, + boundaries, + bubble.is_some(), + bubble.as_ref().map(|b| b.message.content.as_str()), + ); let canvas_el: &web_sys::HtmlCanvasElement = &canvas; - // Set canvas resolution - canvas_el.set_width(canvas_width as u32); - canvas_el.set_height(canvas_height as u32); + // Set canvas resolution from layout + canvas_el.set_width(layout.canvas_width as u32); + canvas_el.set_height(layout.canvas_height as u32); let Ok(Some(ctx)) = canvas_el.get_context("2d") else { return; @@ -492,16 +622,7 @@ pub fn AvatarCanvas( let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); // Clear canvas - ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); - - // Avatar center position within the canvas - // When bubble is above: avatar is below the bubble space - // When bubble is below: avatar is at the top, bubble space is below - let avatar_cx = canvas_width / 2.0; - let avatar_cy = match bubble_position { - BubblePosition::Above => fixed_bubble_height + avatar_size / 2.0, - BubblePosition::Below => avatar_size / 2.0, - }; + ctx.clear_rect(0.0, 0.0, layout.canvas_width, layout.canvas_height); // Helper to load and draw an image // Images are cached; when loaded, triggers a redraw via signal @@ -544,9 +665,9 @@ pub fn AvatarCanvas( }; // Draw all 9 positions of the avatar grid (3x3 layout) - let cell_size = ps; - let grid_origin_x = avatar_cx - avatar_size / 2.0; - let grid_origin_y = avatar_cy - avatar_size / 2.0; + let cell_size = layout.prop_size; + let grid_origin_x = layout.avatar_cx - layout.avatar_size / 2.0; + let grid_origin_y = layout.avatar_cy - layout.avatar_size / 2.0; // Draw skin layer for all 9 positions for pos in 0..9 { @@ -616,9 +737,9 @@ pub fn AvatarCanvas( // 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; - let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0; + let badge_size = 16.0 * layout.text_scale; + let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0; + let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0; ctx.begin_path(); let _ = ctx.arc( @@ -632,23 +753,21 @@ pub fn AvatarCanvas( ctx.fill(); ctx.set_fill_style_str("#000"); - ctx.set_font(&format!("bold {}px sans-serif", 10.0 * text_scale)); + ctx.set_font(&format!("bold {}px sans-serif", 10.0 * layout.text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("middle"); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } - // Calculate content bounds for name positioning - 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 name_x = layout.content_center_x(); + let name_y = layout.avatar_bottom_y() - layout.content_bottom_adjustment() + + 15.0 * layout.text_scale; + let display_name = &m.member.display_name; - ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); + ctx.set_font(&format!("{}px sans-serif", 12.0 * layout.text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("alphabetic"); - let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size) - + 15.0 * text_scale; // Black outline ctx.set_stroke_style_str("#000"); ctx.set_line_width(3.0); @@ -661,35 +780,7 @@ pub fn AvatarCanvas( if let Some(ref b) = bubble { let current_time = js_sys::Date::now() as i64; if b.expires_at >= current_time { - let content_x_offset = content_bounds.x_offset(cell_size); - let content_top_adjustment = content_bounds.empty_top_rows() as f64 * cell_size; - - // Get screen boundaries for horizontal clamping - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); - let sw = scene_width.map(|s| s.get()).unwrap_or(10000.0); - let sh = scene_height.map(|s| s.get()).unwrap_or(10000.0); - let boundaries = ScreenBoundaries::from_transform(sw, sh, sx, sy, ox, oy); - - // Avatar top and bottom Y within the canvas - let avatar_top_y = avatar_cy - avatar_size / 2.0; - let avatar_bottom_y = avatar_cy + avatar_size / 2.0; - - // Use the pre-calculated bubble_position from earlier - draw_bubble( - &ctx, - b, - avatar_cx, - avatar_top_y, - avatar_bottom_y, - content_x_offset, - content_top_adjustment, - te, - bubble_position, - Some(&boundaries), - ); + draw_bubble_with_layout(&ctx, b, &layout, te); } } }); @@ -724,33 +815,18 @@ fn normalize_asset_path(path: &str) -> String { } } -/// Draw a speech bubble relative to the avatar with boundary awareness. +/// Draw a speech bubble using the unified CanvasLayout. /// -/// # Arguments -/// * `ctx` - Canvas rendering context -/// * `bubble` - The active bubble data -/// * `center_x` - Avatar center X in canvas coordinates -/// * `top_y` - Avatar top edge Y in canvas coordinates -/// * `bottom_y` - Avatar bottom edge Y in canvas coordinates -/// * `content_x_offset` - X offset to center on content -/// * `content_top_adjustment` - Y adjustment for empty top rows -/// * `text_em_size` - Text size multiplier -/// * `position` - Whether to render above or below the avatar -/// * `boundaries` - Screen boundaries for horizontal clamping (optional) +/// This is the preferred method for drawing bubbles - it uses the layout's +/// coordinate transformation and clamping methods, ensuring consistency +/// with the canvas positioning. #[cfg(feature = "hydrate")] -fn draw_bubble( +fn draw_bubble_with_layout( ctx: &web_sys::CanvasRenderingContext2d, bubble: &ActiveBubble, - center_x: f64, - top_y: f64, - bottom_y: f64, - content_x_offset: f64, - content_top_adjustment: f64, + layout: &CanvasLayout, text_em_size: f64, - position: BubblePosition, - boundaries: Option<&ScreenBoundaries>, ) { - // Text scale independent of zoom - only affected by user's text_em_size setting let text_scale = text_em_size * BASE_TEXT_SCALE; let max_bubble_width = 200.0 * text_scale; let padding = 8.0 * text_scale; @@ -771,11 +847,7 @@ fn draw_bubble( // Measure and wrap text ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); - let lines = wrap_text( - ctx, - &bubble.message.content, - max_bubble_width - padding * 2.0, - ); + let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); // Calculate bubble dimensions let bubble_width = lines @@ -786,25 +858,14 @@ fn draw_bubble( let bubble_width = bubble_width.max(60.0 * text_scale); let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; - // Center bubble horizontally on content (not grid center) - let content_center_x = center_x + content_x_offset; + // Get content center from layout + let content_center_x = layout.content_center_x(); - // Calculate initial bubble X position (centered on content) - let mut bubble_x = content_center_x - bubble_width / 2.0; + // Calculate initial bubble X (centered on content) + let initial_bubble_x = content_center_x - bubble_width / 2.0; - // Clamp bubble horizontally to stay within drawable area - if let Some(bounds) = boundaries { - let bubble_left = bubble_x; - let bubble_right = bubble_x + bubble_width; - - if bubble_left < bounds.min_x { - // Shift right to stay within left edge - bubble_x = bounds.min_x; - } else if bubble_right > bounds.max_x { - // Shift left to stay within right edge - bubble_x = bounds.max_x - bubble_width; - } - } + // Use layout's clamping method - this handles coordinate transformation correctly + let bubble_x = layout.clamp_bubble_x(initial_bubble_x, bubble_width); // Calculate tail center - point toward content center but stay within bubble bounds let tail_center_x = content_center_x @@ -812,27 +873,20 @@ fn draw_bubble( .min(bubble_x + bubble_width - tail_size - border_radius); // Calculate vertical position based on bubble position - let bubble_y = match position { + let bubble_y = match layout.bubble_position { BubblePosition::Above => { // Position vertically closer to content when top rows are empty - let adjusted_top_y = top_y + content_top_adjustment; + let adjusted_top_y = layout.avatar_top_y() + layout.content_top_adjustment(); adjusted_top_y - bubble_height - tail_size - gap } BubblePosition::Below => { // Position below avatar with gap for tail - bottom_y + tail_size + gap + layout.avatar_bottom_y() + tail_size + gap } }; // Draw bubble background - draw_rounded_rect( - ctx, - bubble_x, - bubble_y, - bubble_width, - bubble_height, - border_radius, - ); + draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); ctx.set_fill_style_str(bg_color); ctx.fill(); ctx.set_stroke_style_str(border_color); @@ -841,7 +895,7 @@ fn draw_bubble( // Draw tail pointing to content center ctx.begin_path(); - match position { + match layout.bubble_position { BubblePosition::Above => { // Tail points DOWN toward avatar ctx.move_to(tail_center_x - tail_size, bubble_y + bubble_height); @@ -861,7 +915,7 @@ fn draw_bubble( ctx.set_stroke_style_str(border_color); ctx.stroke(); - // Draw text (re-set font in case canvas state changed) + // Draw text ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size)); ctx.set_fill_style_str(text_color); ctx.set_text_align("left");