use std::collections::HashMap;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style as TStyle},
};
use unicode_width::UnicodeWidthStr;
use crate::{
pick::Pick,
render::{Camera, Canvas, Overlay, TILE_DOTS, Viewport, to_world},
};
pub trait CustomLayer {
fn name(&self) -> &str;
fn draw(&self, ctx: &mut DrawContext);
fn pick(&self, _ctx: &PickContext) -> Vec<Pick> {
Vec::new()
}
fn bounds(&self) -> Option<(f64, f64, f64, f64)> {
None
}
}
type MarkerItem = (i32, (f64, f64), String, TStyle, Option<(String, TStyle)>);
type LabelItem = (i32, String, TStyle, Vec<(f64, f64)>, f64);
pub const REPEAT_DOTS: f64 = 40.0;
pub struct DrawContext<'a> {
pub camera: &'a Camera,
pub viewport: Viewport,
canvas: &'a mut Canvas,
view: [f64; 4],
size: (f64, f64),
markers: Vec<MarkerItem>,
labels: Vec<LabelItem>,
pub(crate) base: i32,
}
impl DrawContext<'_> {
pub fn dot(&self, x: f64, y: f64) -> (f64, f64) {
self.viewport.dot(x, y)
}
pub fn visible(&self, bbox: [f64; 4]) -> bool {
!(bbox[2] < self.view[0] || bbox[0] > self.view[2] || bbox[3] < self.view[1] || bbox[1] > self.view[3])
}
pub fn world_view(&self) -> [f64; 4] {
self.view
}
pub fn inside(&self, p: (f64, f64)) -> bool {
p.0 >= 0.0 && p.1 >= 0.0 && p.0 < self.size.0 && p.1 < self.size.1
}
pub fn line_dots(&mut self, pts: &[(f64, f64)], color: Color) {
for s in pts.windows(2) {
self.canvas.line(s[0], s[1], color);
}
}
pub fn line_styled(&mut self, pts: &[(f64, f64)], color: Color, width: u8, dash: Option<&[f64]>) {
let offsets: &[f64] = match width {
0 | 1 => &[0.0],
2 => &[-0.5, 0.5],
_ => &[-1.0, 0.0, 1.0],
};
for &k in offsets {
let mut phase = 0.0;
for s in pts.windows(2) {
let (a, b) = (s[0], s[1]);
let len = (b.0 - a.0).hypot(b.1 - a.1);
let (nx, ny) = if len > 0.0 { (-(b.1 - a.1) / len * k, (b.0 - a.0) / len * k) } else { (0.0, 0.0) };
let (a, b) = ((a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny));
match dash {
Some(d) => self.canvas.line_dashed(a, b, color, d, phase),
None => self.canvas.line(a, b, color),
}
phase += len;
}
}
}
pub fn line(&mut self, pts: &[(f64, f64)], color: Color) {
let d: Vec<(f64, f64)> = pts.iter().map(|&(x, y)| self.dot(x, y)).collect();
self.line_dots(&d, color);
}
pub fn line_lonlat(&mut self, pts: &[(f64, f64)], color: Color) {
let d: Vec<(f64, f64)> = pts.iter().map(|&(lon, lat)| self.viewport.dot_ll(lon, lat)).collect();
self.line_dots(&d, color);
}
pub fn fill_dots(&mut self, rings: &[Vec<(f64, f64)>], color: Color) {
self.canvas.fill(rings, color);
}
pub fn fill(&mut self, rings: &[Vec<(f64, f64)>], color: Color) {
let d: Vec<Vec<(f64, f64)>> = rings.iter().map(|r| r.iter().map(|&(x, y)| self.dot(x, y)).collect()).collect();
self.fill_dots(&d, color);
}
pub fn cell_bg(&mut self, col: u16, row: u16, color: Color) {
self.canvas.set_bg(col as usize, row as usize, color);
}
pub fn marker_dot(&mut self, p: (f64, f64), glyph: &str, style: TStyle, priority: i32, label: Option<(String, TStyle)>) {
self.markers.push((self.base.saturating_add(priority), p, glyph.to_string(), style, label));
}
pub fn marker(&mut self, lon: f64, lat: f64, glyph: &str, style: TStyle, priority: i32, label: Option<(String, TStyle)>) {
let p = self.viewport.dot_ll(lon, lat);
self.marker_dot(p, glyph, style, priority, label);
}
pub fn label_dot(&mut self, p: (f64, f64), text: &str, style: TStyle, priority: i32) {
self.label_dots(vec![p], text, style, priority);
}
pub fn label_dots(&mut self, candidates: Vec<(f64, f64)>, text: &str, style: TStyle, priority: i32) {
self.label_spaced(candidates, text, style, priority, REPEAT_DOTS);
}
pub fn label_spaced(&mut self, mut candidates: Vec<(f64, f64)>, text: &str, style: TStyle, priority: i32, repeat: f64) {
candidates.retain(|p| self.inside(*p));
if !candidates.is_empty() {
self.labels.push((self.base.saturating_add(priority), text.to_string(), style, candidates, repeat));
}
}
}
pub struct PickContext<'a> {
pub camera: &'a Camera,
pub area: Rect,
pub viewport: Viewport,
pub target: (f64, f64),
}
pub(crate) fn render_with(camera: &Camera, area: Rect, buf: &mut Buffer, draw: impl FnOnce(&mut DrawContext)) {
if area.width < 2 || area.height < 2 {
return;
}
let mut canvas = Canvas::new(area.width as usize, area.height as usize);
let (vw, vh) = (area.width as f64 * 2.0, area.height as f64 * 4.0);
let view = {
let (cx, cy) = to_world(camera.lon, camera.lat);
let s = TILE_DOTS * 2f64.powf(camera.zoom);
let (hw, hh) = (vw / 2.0 / s, vh / 2.0 / s);
[cx - hw, cy - hh, cx + hw, cy + hh]
};
let mut ctx = DrawContext {
camera,
viewport: Viewport::new(camera, area),
canvas: &mut canvas,
view,
size: (vw, vh),
markers: Vec::new(),
labels: Vec::new(),
base: 0,
};
draw(&mut ctx);
let DrawContext { mut markers, mut labels, .. } = ctx;
enum Sym {
Marker((f64, f64), String, TStyle, Option<(String, TStyle)>),
Label(String, TStyle, Vec<(f64, f64)>, f64),
}
let inside = |p: (f64, f64)| p.0 >= 0.0 && p.1 >= 0.0 && p.0 < vw && p.1 < vh;
let mut syms: Vec<(i32, usize, Sym)> = Vec::with_capacity(markers.len() + labels.len());
for (pri, p, g, st, l) in markers.drain(..) {
syms.push((pri, syms.len(), Sym::Marker(p, g, st, l)));
}
for (pri, t, st, p, repeat) in labels.drain(..) {
syms.push((pri, syms.len(), Sym::Label(t, st, p, repeat)));
}
syms.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let mut placed: HashMap<String, Vec<(f64, f64)>> = HashMap::new();
for (_, _, sym) in syms {
match sym {
Sym::Marker(p, glyph, st, label) => {
if !inside(p) {
continue;
}
let (col, row) = ((p.0 / 2.0) as i64, (p.1 / 4.0) as i64);
let lcol = col + glyph.width() as i64 + 1;
let room = canvas.width() as i64 - lcol;
let label = label.and_then(|(l, ls)| match l.width() as i64 {
w if w <= room => Some((l, ls)),
_ if room >= 4 => Some((clip(&l, room as usize - 1) + "…", ls)),
_ => None,
});
match label {
Some((l, ls)) => {
if canvas.fits(col, row, &glyph, false) && canvas.fits(lcol, row, &l, true) {
canvas.text_at(col, row, &glyph, st, false);
canvas.text_at(lcol, row, &l, ls, true);
}
}
None => {
canvas.text_at(col, row, &glyph, st, false);
}
}
}
Sym::Label(text, st, cands, repeat) => {
let near = |a: (f64, f64)| placed.get(&text).is_some_and(|ps| ps.iter().any(|b| (a.0 - b.0).hypot(a.1 - b.1) < repeat));
if cands.first().is_none_or(|p| near(*p)) {
continue;
}
for p in cands {
let col = (p.0 / 2.0) as i64 - text.width() as i64 / 2;
if canvas.text_at(col, (p.1 / 4.0) as i64, &text, st, true) {
placed.entry(text).or_default().push(p);
break;
}
}
}
}
}
canvas.flush(area, buf);
}
fn clip(s: &str, cols: usize) -> String {
let mut out = String::new();
let mut w = 0;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > cols {
break;
}
w += cw;
out.push(ch);
}
out
}
#[derive(Default)]
pub struct Markers(pub Vec<Overlay>);
impl CustomLayer for Markers {
fn name(&self) -> &str {
"markers"
}
fn draw(&self, ctx: &mut DrawContext) {
for o in &self.0 {
match o {
Overlay::Line { pts, color } => ctx.line_lonlat(pts, *color),
Overlay::Marker { lon, lat, glyph, style, label, priority } => {
ctx.marker(*lon, *lat, glyph, *style, *priority, label.clone())
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Camera;
fn draw(f: impl FnOnce(&mut DrawContext)) -> Vec<String> {
let area = Rect::new(0, 0, 30, 5);
let mut buf = Buffer::empty(area);
render_with(&Camera::new(0.0, 0.0, 0.0), area, &mut buf, f);
(0..5).map(|y| (0..30).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>()).collect()
}
#[test]
fn higher_priority_wins_whatever_its_kind() {
let st = TStyle::new();
let rows = draw(|c| {
c.marker_dot((20.0, 8.0), "M", st, 1, None);
c.label_dot((20.0, 8.0), "LABEL", st, 5);
});
assert!(rows[2].contains("LABEL") && !rows[2].contains('M'), "{rows:?}");
let rows = draw(|c| {
c.marker_dot((20.0, 8.0), "M", st, 5, None);
c.label_dot((20.0, 8.0), "LABEL", st, 1);
});
assert!(rows[2].contains('M') && !rows[2].contains("LABEL"), "{rows:?}");
}
#[test]
fn a_marker_shows_with_its_label_or_not_at_all() {
let st = TStyle::new();
let rows = draw(|c| {
c.label_dot((30.0, 8.0), "BLOCK", st, 9);
c.marker_dot((10.0, 8.0), "◆", st, 1, Some(("name here".into(), st)));
c.marker_dot((10.0, 16.0), "◆", st, 1, Some(("ok".into(), st)));
});
assert!(!rows[2].contains('◆') && rows[2].contains("BLOCK"), "{rows:?}");
assert!(rows[4].contains("◆ ok"), "{rows:?}");
}
#[test]
fn labels_at_the_edge_are_cut_short() {
let st = TStyle::new();
let rows = draw(|c| c.marker_dot((48.0, 8.0), "◆", st, 1, Some(("a long name".into(), st))));
assert!(rows[2].trim_end().ends_with("◆ a l…"), "{rows:?}");
let rows = draw(|c| c.marker_dot((56.0, 8.0), "◆", st, 1, Some(("a long name".into(), st))));
assert!(rows[2].trim_end().ends_with('◆'), "{rows:?}");
}
#[test]
fn same_text_keeps_its_distance() {
let st = TStyle::new();
let rows = draw(|c| {
c.label_dot((10.0, 2.0), "Main", st, 1);
c.label_dot((14.0, 18.0), "Main", st, 1); c.label_dot((54.0, 2.0), "Main", st, 1); });
assert_eq!(rows.join("\n").matches("Main").count(), 2, "{rows:?}");
let rows = draw(|c| {
c.label_spaced(vec![(10.0, 2.0)], "Road", st, 1, 60.0);
c.label_spaced(vec![(54.0, 2.0)], "Road", st, 1, 60.0);
});
assert_eq!(rows.join("\n").matches("Road").count(), 1, "a wider repeat distance: {rows:?}");
let rows = draw(|c| {
c.label_dot((20.0, 8.0), "Taken", st, 9);
c.label_dots(vec![(20.0, 8.0), (20.0, 16.0)], "Moved", st, 1);
});
assert!(rows[4].contains("Moved"), "{rows:?}");
}
}