1use densa_chart::{Axis, ChartModel, ChartPainter, ChartPalette, Plot};
5use densa_core::{Color, Point, Rect, Size};
6use densa_draw::Canvas;
7use densa_text::Font;
8use densa_ui::{
9 AccessNode, ActionKind, Event, EventCx, Handled, Key, KeyEvent, LayoutCx, Length, NamedKey,
10 Overflow, PaintCx, PointerButton, PointerEvent, PointerKind, Role, ScrollDelta, Style, Theme,
11 Widget,
12};
13
14use crate::paint::{luminance, outline};
15
16const ZOOM_STEP: f64 = 1.15;
18const NOTCH: f32 = 48.0;
20const KEY_PAN: f64 = 0.1;
22const LEAD: f64 = 0.03;
24const MIN_STEPS: f64 = 5.0;
26const MIN_SHARE: f64 = 1e-4;
28const CLICK_SLOP: f32 = 4.0;
30const PRECISION: f64 = 1e-12;
33const LABEL_CHARS: u16 = 8;
35const MAX_SHARE: f64 = 2.0;
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40enum View {
41 Follow { span: f64 },
43 Fixed(Axis),
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
49struct Pan {
50 x: f32,
51 from: Axis,
52 width: f32,
53}
54
55#[derive(Debug)]
72pub struct Chart<M> {
73 model: M,
74 view: View,
75 initial: f64,
77 label_chars: u16,
78 painter: Option<ChartPainter>,
80 built: Option<(f32, ChartPalette)>,
81 pan: Option<Pan>,
82 dragged: bool,
85 hover: Option<Point>,
87}
88
89impl<M: ChartModel> Chart<M> {
90 pub fn new(model: M, span: f64) -> Self {
93 let span = if span.is_finite() && span > 0.0 {
94 span
95 } else {
96 1.0
97 };
98 Self {
99 model,
100 view: View::Follow { span },
101 initial: span,
102 label_chars: LABEL_CHARS,
103 painter: None,
104 built: None,
105 pan: None,
106 dragged: false,
107 hover: None,
108 }
109 }
110
111 #[must_use]
113 pub fn with_label_chars(mut self, chars: u16) -> Self {
114 self.label_chars = chars;
115 self.built = None;
116 self
117 }
118
119 pub fn style() -> Style {
121 Style::column()
122 .grow(1.0)
123 .min_size(Length::Px(0.0), Length::Px(0.0))
124 .overflow(Overflow::Clip)
125 }
126
127 pub fn model(&self) -> &M {
129 &self.model
130 }
131
132 pub fn model_mut(&mut self, cx: &mut EventCx<'_>) -> &mut M {
135 cx.request_paint();
136 &mut self.model
137 }
138
139 pub fn x_axis(&self) -> Option<Axis> {
141 let (_, newest) = self.extent()?;
142 let kind = self.model.x_kind();
143 Some(match self.current() {
144 View::Follow { span } => {
145 let end = newest + span * LEAD;
146 Axis::new(end - span, end, kind)
147 }
148 View::Fixed(axis) => Axis::new(axis.start(), axis.end(), kind),
149 })
150 }
151
152 pub fn is_following(&self) -> bool {
154 matches!(self.current(), View::Follow { .. })
155 }
156
157 fn current(&self) -> View {
159 match self.view {
160 View::Fixed(axis) => self.settle(axis.start(), axis.span()),
161 follow @ View::Follow { .. } => follow,
162 }
163 }
164
165 pub fn follow(&mut self, cx: &mut EventCx<'_>) {
167 if let Some(x) = self.x_axis() {
168 self.view = View::Follow { span: x.span() };
169 cx.request_paint();
170 }
171 }
172
173 pub fn show(&mut self, cx: &mut EventCx<'_>, x: Axis) {
176 let (low, high) = self.limits();
177 let span = x.span().clamp(low, high);
178 let middle = x.start() + x.span() / 2.0;
179 self.view = self.settle(middle - span / 2.0, span);
180 cx.request_paint();
181 }
182
183 fn extent(&self) -> Option<(f64, f64)> {
185 self.model
186 .x_extent()
187 .filter(|(low, high)| low.is_finite() && high.is_finite() && low <= high)
188 }
189
190 fn limits(&self) -> (f64, f64) {
192 let Some((oldest, newest)) = self.extent() else {
193 return (f64::MIN_POSITIVE, f64::MAX);
194 };
195 let width = newest - oldest;
196 let low = match self.model.x_step() {
197 Some(step) if step.is_finite() && step > 0.0 => step * MIN_STEPS,
198 _ => width * MIN_SHARE,
199 };
200 let magnitude = oldest.abs().max(newest.abs()).max(1.0);
201 let low = low.max(magnitude * PRECISION);
202 let high = (width * MAX_SHARE).max(self.initial).max(low * 2.0);
203 (low, high)
204 }
205
206 fn settle(&self, start: f64, span: f64) -> View {
209 let kind = self.model.x_kind();
210 let Some((oldest, newest)) = self.extent() else {
211 return View::Fixed(Axis::new(start, start + span, kind));
212 };
213 let start = start.max(oldest - span);
214 if start + span >= newest + span * LEAD {
215 return View::Follow { span };
216 }
217 View::Fixed(Axis::new(start, start + span, kind))
218 }
219
220 fn zoom(&mut self, factor: f64, x: Option<f32>, rect: Rect) -> bool {
223 let Some(shown) = self.x_axis() else {
224 return false;
225 };
226 let (low, high) = self.limits();
227 let span = (shown.span() * factor).clamp(low, high);
228 if span == shown.span() || !span.is_finite() {
229 return false;
230 }
231 let area = self.x_area(rect);
232 let anchor = match (self.current(), x, area) {
233 (View::Fixed(_), Some(x), Some(area)) if (area.x0..area.x1).contains(&x) => {
234 Plot::new(area, shown, shown).x_value(x)
235 }
236 (View::Fixed(_), _, _) => shown.start() + shown.span() / 2.0,
237 (View::Follow { .. }, _, _) => {
238 self.view = View::Follow { span };
239 return true;
240 }
241 };
242 let zoomed = shown.zoomed(span / shown.span(), anchor);
243 self.view = self.settle(zoomed.start(), span);
244 true
245 }
246
247 fn pan_by(&mut self, dx: f32, rect: Rect) -> bool {
249 let (Some(shown), Some(area)) = (self.x_axis(), self.x_area(rect)) else {
250 return false;
251 };
252 let delta = f64::from(dx) / f64::from(area.width()) * shown.span();
253 self.shift(shown, delta)
254 }
255
256 fn shift(&mut self, shown: Axis, delta: f64) -> bool {
258 if delta == 0.0 || !delta.is_finite() {
259 return false;
260 }
261 let before = self.view;
262 self.view = self.settle(shown.start() + delta, shown.span());
263 self.view != before
264 }
265
266 fn x_area(&mut self, rect: Rect) -> Option<Rect> {
268 let area = self.painter.as_mut()?.plot_area(rect, true);
269 (area.width() > 0.0).then_some(area)
270 }
271
272 fn build(&mut self, px: f32, palette: ChartPalette) {
275 let chars = self.label_chars;
276 self.painter = Font::bundled_mono()
277 .ok()
278 .map(|font| ChartPainter::new(font, px, palette).with_label_chars(chars));
279 self.built = Some((px, palette));
280 }
281
282 fn pointer(&mut self, cx: &mut EventCx<'_>, pointer: &PointerEvent) -> Handled {
283 let at = pointer.position;
284 let rect = cx.rect();
285 let moved = match pointer.kind {
286 PointerKind::Scroll(delta) => {
287 self.wheel(cx, delta, at, pointer.modifiers.shift(), rect)
288 }
289 PointerKind::Down(PointerButton::Primary) if pointer.clicks >= 2 && !self.dragged => {
290 self.pan = None;
291 let following = self.is_following();
292 self.follow(cx);
293 !following
294 }
295 PointerKind::Down(PointerButton::Primary) => {
296 self.dragged = false;
297 if let (Some(from), Some(area)) = (self.x_axis(), self.x_area(rect)) {
298 let width = area.width();
299 self.pan = Some(Pan {
300 x: at.x,
301 from,
302 width,
303 });
304 }
305 cx.request_paint();
306 return Handled::Yes;
307 }
308 PointerKind::Move => match self.pan {
309 Some(pan) => {
310 self.hover = Some(at);
311 self.dragged |= (at.x - pan.x).abs() > cx.px(CLICK_SLOP);
312 let delta = f64::from(pan.x - at.x) / f64::from(pan.width) * pan.from.span();
313 let before = self.view;
314 self.view = self.settle(pan.from.start() + delta, pan.from.span());
315 self.view != before
316 }
317 None => {
318 self.hover = Some(at);
319 cx.request_paint();
320 return Handled::No;
321 }
322 },
323 PointerKind::Leave => {
324 self.hover = None;
325 cx.request_paint();
326 return Handled::No;
327 }
328 PointerKind::Up(_) | PointerKind::Cancel => {
329 let held = self.pan.take().is_some();
330 cx.request_paint();
331 return if held { Handled::Yes } else { Handled::No };
332 }
333 _ => return Handled::No,
334 };
335 self.moved(cx, moved)
336 }
337
338 fn wheel(
340 &mut self,
341 cx: &mut EventCx<'_>,
342 delta: ScrollDelta,
343 at: Point,
344 shift: bool,
345 rect: Rect,
346 ) -> bool {
347 let notch = cx.px(NOTCH);
348 let (zoom, dx) = match delta {
349 ScrollDelta::Lines(dx, dy) if shift && dx == 0.0 => (0.0, dy * notch),
350 ScrollDelta::Lines(dx, dy) => (dy, dx * notch),
351 ScrollDelta::Pixels(dx, dy) if shift && dx == 0.0 => (0.0, dy),
352 ScrollDelta::Pixels(dx, dy) => (dy / notch, dx),
353 _ => (0.0, 0.0),
354 };
355 let mut moved = false;
356 if zoom != 0.0 && zoom.is_finite() {
357 moved |= self.zoom(ZOOM_STEP.powf(f64::from(zoom)), Some(at.x), rect);
358 }
359 if dx != 0.0 && dx.is_finite() {
360 moved |= self.pan_by(dx, rect);
361 }
362 moved
363 }
364
365 fn key(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Handled {
366 let m = key.modifiers;
367 if m.shift() || m.control() || m.alt() || m.logo() {
368 return Handled::No;
369 }
370 let rect = cx.rect();
371 let Some(shown) = self.x_axis() else {
372 return Handled::No;
373 };
374 let moved = match key.key {
375 Key::Named(NamedKey::ArrowLeft) => self.shift(shown, -shown.span() * KEY_PAN),
376 Key::Named(NamedKey::ArrowRight) => self.shift(shown, shown.span() * KEY_PAN),
377 Key::Named(NamedKey::ArrowUp) => self.zoom(1.0 / ZOOM_STEP, None, rect),
378 Key::Named(NamedKey::ArrowDown) => self.zoom(ZOOM_STEP, None, rect),
379 Key::Named(NamedKey::End) => {
380 let following = self.is_following();
381 self.follow(cx);
382 !following
383 }
384 Key::Named(NamedKey::Home) => {
385 let oldest = self.extent().map_or(shown.start(), |(oldest, _)| oldest);
386 let before = self.view;
387 self.view = self.settle(oldest, shown.span());
388 self.view != before
389 }
390 _ => return Handled::No,
391 };
392 self.moved(cx, moved);
393 Handled::Yes
394 }
395
396 fn moved(&mut self, cx: &mut EventCx<'_>, moved: bool) -> Handled {
398 if moved {
399 cx.request_paint();
400 cx.emit(ActionKind::Changed);
401 Handled::Yes
402 } else {
403 Handled::No
404 }
405 }
406
407 fn paint_chart(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
409 let rect = cx.rect();
410 self.view = self.current();
412 let x = self.x_axis();
413 let hover = self.hover.filter(|_| self.pan.is_none());
414 let inside = canvas.state();
415 canvas.clip(rect);
416 if let Some((_, palette)) = self.built {
417 canvas.fill_rect(rect, palette.background);
418 }
419 let Some(painter) = self.painter.as_mut() else {
420 canvas.restore(inside);
421 return;
422 };
423 if let Some(x) = x {
424 let panes = self.model.panes().max(1);
425 let weight = |pane| {
426 let weight = f64::from(self.model.pane_weight(pane));
427 if weight.is_finite() {
428 weight.max(0.0)
429 } else {
430 0.0
431 }
432 };
433 let total: f64 = (0..panes).map(weight).sum();
434 let (mut top, mut before) = (f64::from(rect.top()), 0.0);
435 for pane in 0..panes {
436 before += if total > 0.0 {
437 weight(pane) / total
438 } else {
439 1.0 / panes as f64
441 };
442 let last = pane + 1 == panes;
443 let bottom = f64::from(rect.top()) + f64::from(rect.height()) * before;
445 let bounds = Rect::from_edges(rect.left(), top as f32, rect.right(), bottom as f32);
446 top = bottom;
447 let title = self.model.pane_title(pane);
448 let bounds = if title.is_empty() {
449 bounds
450 } else {
451 let below = (bounds.y0 + painter.title_height()).min(bounds.y1);
453 Rect::from_edges(bounds.x0, below, bounds.x1, bounds.y1)
454 };
455 let area = painter.plot_area(bounds, last);
456 let y = self
457 .model
458 .y_axis(pane, &x)
459 .unwrap_or_else(|| Axis::number(0.0, 1.0));
460 let plot = Plot::new(area, x, y);
461 let model = &self.model;
462 let names = |index| model.category(index);
463 painter.paint_axes_named(canvas, &plot, last, &names);
464 model.paint(pane, &plot, canvas, painter.palette());
465 painter.paint_title(canvas, &plot, title);
466 if let Some(at) = hover {
467 painter.paint_crosshair_named(canvas, &plot, at, last, &names);
468 }
469 }
470 }
471 if cx.is_focused() {
472 let line = cx.px(1.0).round().max(1.0);
473 outline(canvas, rect, line, cx.theme().focus);
474 }
475 canvas.restore(inside);
476 }
477}
478
479fn palette(theme: &Theme) -> ChartPalette {
481 let mut palette = ChartPalette::new();
482 palette.background = theme.surface;
483 palette.grid = theme.text.with_alpha(0.06);
484 palette.axis = theme.border;
485 palette.label = theme.text_dim;
486 palette.line = theme.accent;
487 palette.crosshair = theme.text_dim.with_alpha(0.7);
488 palette.marker = theme.raised;
489 palette.marker_text = theme.text;
490 if luminance(theme.surface) > 0.5 {
492 palette.up = Color::srgb(0x1A7F37);
493 palette.down = Color::srgb(0xCF222E);
494 }
495 palette
496}
497
498impl<M: ChartModel + 'static> Widget for Chart<M> {
499 fn layout(&mut self, cx: &mut LayoutCx<'_>, _size: Size) {
500 let px = cx.px(cx.theme().font_size * 0.9).round();
501 let palette = palette(cx.theme());
502 if self.built != Some((px, palette)) {
503 self.build(px, palette);
504 }
505 }
506
507 fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
508 self.model.prepare();
509 self.paint_chart(cx, canvas);
510 }
511
512 fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
513 match event {
514 Event::Pointer(pointer) => self.pointer(cx, pointer),
515 Event::Key(key) if key.pressed => self.key(cx, key),
516 Event::FocusGained | Event::FocusLost => {
517 cx.request_paint();
518 Handled::No
519 }
520 _ => Handled::No,
521 }
522 }
523
524 fn accepts_focus(&self) -> bool {
525 true
526 }
527
528 fn accessibility(&self, _cx: &PaintCx<'_>, node: &mut AccessNode) {
529 node.set_role(Role::Image);
530 let description = self.model.describe();
531 node.set_label(if description.is_empty() {
532 "Chart".to_owned()
533 } else {
534 description
535 });
536 }
537}