use std::rc::Rc; use gpui::{ App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, }; use jiff::tz::TimeZone; use super::{ candles::{Candle, point_and_figure, renko}, quotes::moves, stage::{decimals, fit}, }; use crate::{ charts::{ ChartTooltip, Linear, Rect, Tiles, anchored, at, finish, measure, place, ring, tracked, }, theme::{ActiveTheme, TextSize}, typography::{ format::{self, system_zone}, tabular, }, }; /// A mark on a price-only chart: its slot, the prices it spans, whether it rose, and its words. struct Mark { slot: usize, span: (f64, f64), rose: bool, title: String, reading: String, } /// What both price-only charts share: a slot per mark along the bottom, prices at the right, and a tooltip for the mark under the pointer. fn board( id: ElementId, marks: Vec, boxes: Option, (base, red_up): (&mut Div, bool), window: &mut Window, cx: &mut App, ) -> impl IntoElement + use<> { let tiles: Entity = window.use_keyed_state((id.clone(), "board"), cx, |_, _| Tiles::default()); let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); let theme = cx.theme(); let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); let (inset, gutter, foot) = ( pixels(sizes.inset), pixels(sizes.gutter) * 1.25, pixels(sizes.foot), ); let frame = Rect { x: inset, y: inset, w: (f32::from(bounds.size.width) - inset - gutter).max(0.0), h: (f32::from(bounds.size.height) - inset - foot).max(0.0), }; let (low, high) = marks .iter() .fold((f64::MAX, f64::MIN), |(low, high), mark| { ( low.min(mark.span.0.min(mark.span.1)), high.max(mark.span.0.max(mark.span.1)), ) }); let ((low, high), ticks) = if marks.is_empty() { fit(0.0, 1.0) } else { fit(low, high) }; let ys = Linear::new((low, high), (frame.y + frame.h, frame.y)); let slots = marks.iter().map(|mark| mark.slot + 1).max().unwrap_or(1); let wide = frame.w / slots as f32; let (rise, fall) = moves(red_up, cx); let digits = decimals( ticks .windows(2) .next() .map_or(1.0, |pair| pair[1] - pair[0]), ) .max(2); let edge = frame.x + frame.w; let text = |words: String| { div() .flex_none() .whitespace_nowrap() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_subtle) .child(words) }; let mut words: Vec
= ticks .iter() .map(|tick| { div() .absolute() .left(Pixels::from(edge)) .top(Pixels::from(ys.at(*tick))) .h_0() .flex() .items_center() .pl_2() .child(tabular(text(format::number( *tick, digits, format::Separators::EN, )))) }) .collect(); let place_of = |mark: &Mark| { let (top, bottom) = ( ys.at(mark.span.0.max(mark.span.1)), ys.at(mark.span.0.min(mark.span.1)), ); Rect { x: frame.x + wide * mark.slot as f32, y: top, w: wide, h: bottom - top, } }; if let Some(mark) = hover.and_then(|ix| marks.get(ix)) { let rect = place_of(mark); let card = ChartTooltip::new(mark.title.clone()).row( Some(if mark.rose { rise } else { fall }), "Prices", mark.reading.clone(), ); words.push(anchored( (rect.x + rect.w, rect.y + rect.h / 2.0), rect.x > f32::from(bounds.size.width) * 0.6, card, )); } let rects: Vec<(Rect, bool)> = marks .iter() .map(|mark| (place_of(mark), mark.rose)) .collect(); let hit = rects.clone(); let (stroke, hairline, palette, corner) = ( sizes.stroke.to_pixels(rem), sizes.hairline, colors.clone(), theme.radius(crate::theme::Radius::Sm).to_pixels(rem), ); let box_px = boxes.map(|size| (ys.at(0.0) - ys.at(size)).abs()); let mut root = div().relative().w_full().h(sizes.height); root.style().refine(base.style()); tracked(root, (id, "marks").into(), &tiles, move |place| { hit.iter().position(|(rect, _)| rect.contains(place)) }) .child( canvas( |_, _, _| {}, move |bounds, _, window, _| { let origin = bounds.origin; for tick in &ticks { let line = Rect { x: frame.x, y: ys.at(*tick), w: frame.w, h: f32::from(hairline), }; window.paint_quad(fill(place(origin, line), palette.border.opacity(0.5))); } for (ix, (rect, rose)) in rects.iter().enumerate() { let ink = if *rose { rise } else { fall }; let ink = if hover.is_some_and(|on| on != ix) { ink.opacity(0.5) } else { ink }; let gap = wide * 0.12; let Some(side) = box_px else { let brick = Rect { x: rect.x + gap, y: rect.y, w: rect.w - gap * 2.0, h: rect.h, }; window.paint_quad( fill(place(origin, brick), ink.opacity(0.85)) .corner_radii(Pixels::from(f32::from(corner).min(brick.w / 4.0))), ); continue; }; let count = (rect.h / side).round().max(1.0) as usize; let half = (side.min(wide) / 2.0 - gap).max(f32::from(hairline)); for step in 0..count { let center = (rect.x + wide / 2.0, rect.y + side * (step as f32 + 0.5)); if *rose { let mut cross = PathBuilder::stroke(stroke * 0.75); cross.move_to(at(origin, (center.0 - half, center.1 - half))); cross.line_to(at(origin, (center.0 + half, center.1 + half))); cross.move_to(at(origin, (center.0 + half, center.1 - half))); cross.line_to(at(origin, (center.0 - half, center.1 + half))); finish(cross, ink, window); } else { ring( at(origin, center), Pixels::from(half), (palette.bg.opacity(0.0), ink), stroke * 0.75, window, ); } } } }, ) .absolute() .inset_0(), ) .children(words) .child(measure(tiles, |tiles| &mut tiles.bounds)) } /// Prices as bricks of one size, laid only when the close moves a whole brick past the last, so time drops out and trends stand clear. Hover reads a brick. #[derive(IntoElement)] pub struct RenkoChart { base: Div, id: ElementId, candles: Rc>, size: f64, zone: Option, red_up: bool, } impl RenkoChart { pub fn new(id: impl Into, candles: impl Into>>, size: f64) -> Self { assert!( size.is_finite() && size > 0.0, "a brick needs a positive size" ); Self { base: div(), id: id.into(), candles: candles.into(), size, zone: None, red_up: false, } } pub fn red_up(mut self) -> Self { self.red_up = true; self } /// The time zone its dates read in; the system's unless set. pub fn zone(mut self, zone: TimeZone) -> Self { self.zone = Some(zone); self } } impl Styled for RenkoChart { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for RenkoChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let zone = self .zone .clone() .unwrap_or_else(|| system_zone("RenkoChart")); let marks = renko(&self.candles, self.size) .into_iter() .enumerate() .map(|(slot, brick)| { let day = self.candles[brick.at] .time .to_zoned(zone.clone()) .strftime("%b %-d, %Y") .to_string(); let reading = format!( "{} → {}", format::number(brick.from, 2, format::Separators::EN), format::number(brick.to, 2, format::Separators::EN) ); Mark { slot, span: (brick.from, brick.to), rose: brick.to > brick.from, title: day, reading, } }) .collect(); board( self.id.clone(), marks, None, (&mut self.base, self.red_up), window, cx, ) } } /// Prices as columns of Xs where they rose and Os where they fell, a new column each time they turn by the reversal. Hover reads a column. #[derive(IntoElement)] pub struct PointFigureChart { base: Div, id: ElementId, candles: Rc>, size: f64, reversal: i64, red_up: bool, } impl PointFigureChart { /// Boxes of `size`, and a turn after `reversal` boxes the other way. pub fn new( id: impl Into, candles: impl Into>>, size: f64, reversal: i64, ) -> Self { assert!( size.is_finite() && size > 0.0 && reversal > 0, "boxes need a positive size and reversal" ); Self { base: div(), id: id.into(), candles: candles.into(), size, reversal, red_up: false, } } pub fn red_up(mut self) -> Self { self.red_up = true; self } } impl Styled for PointFigureChart { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for PointFigureChart { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let size = self.size; let marks = point_and_figure(&self.candles, size, self.reversal) .into_iter() .enumerate() .map(|(slot, column)| { let (low, high) = ( column.from.min(column.to) as f64 * size, (column.from.max(column.to) + 1) as f64 * size, ); let boxes = column.from.abs_diff(column.to) + 1; let title = format!("{} {}", boxes, if column.rising { "X" } else { "O" }); let reading = format!( "{} – {}", format::number(low, 2, format::Separators::EN), format::number(high, 2, format::Separators::EN) ); Mark { slot, span: (low, high), rose: column.rising, title, reading, } }) .collect(); board( self.id.clone(), marks, Some(size), (&mut self.base, self.red_up), window, cx, ) } }