Skip to main content

unicode_plot/canvas/
density.rs

1use crate::canvas::{AxisTransform, Canvas, CanvasCore, Scale, Transform2D};
2use crate::color::CanvasColor;
3
4const X_PIXELS_PER_CHAR: usize = 1;
5const Y_PIXELS_PER_CHAR: usize = 2;
6const DENSITY_DECODE: [char; 5] = [' ', '░', '▒', '▓', '█'];
7
8/// A 1x2 pixel-per-character canvas that counts hits per cell and renders
9/// density using shade block characters (`░▒▓█`).
10#[derive(Debug, Clone, PartialEq)]
11pub struct DensityCanvas {
12    core: CanvasCore<u32>,
13    max_density: u32,
14}
15
16impl DensityCanvas {
17    /// Creates a density canvas with identity axis scales.
18    ///
19    /// # Panics
20    ///
21    /// Panics when either axis transform cannot be constructed. This occurs if
22    /// plot spans are non-positive, pixel dimensions are zero, or origins are
23    /// non-finite.
24    #[must_use]
25    pub fn new(
26        char_width: usize,
27        char_height: usize,
28        origin_x: f64,
29        origin_y: f64,
30        plot_width: f64,
31        plot_height: f64,
32    ) -> Self {
33        let pixel_width = char_width.saturating_mul(X_PIXELS_PER_CHAR);
34        let pixel_height = char_height.saturating_mul(Y_PIXELS_PER_CHAR);
35        let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
36            .expect(
37                "DensityCanvas::new requires finite x-origin, positive span, and non-zero width",
38            );
39        let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
40            .expect(
41                "DensityCanvas::new requires finite y-origin, positive span, and non-zero height",
42            );
43
44        Self {
45            core: CanvasCore::new(
46                char_width,
47                char_height,
48                pixel_width,
49                pixel_height,
50                Transform2D::new(x, y),
51            ),
52            max_density: 1,
53        }
54    }
55
56    #[must_use]
57    #[cfg(test)]
58    pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
59        if row >= self.char_height() {
60            return String::new();
61        }
62
63        let mut out = String::new();
64        for col in 0..self.char_width() {
65            super::write_colored_cell(
66                &mut out,
67                self.glyph_at(col, row),
68                self.color_at(col, row),
69                color,
70            );
71        }
72
73        out
74    }
75
76    #[must_use]
77    #[cfg(test)]
78    pub(crate) fn print(&self, color: bool) -> String {
79        let mut out = String::new();
80        for row in 0..self.char_height() {
81            for col in 0..self.char_width() {
82                super::write_colored_cell(
83                    &mut out,
84                    self.glyph_at(col, row),
85                    self.color_at(col, row),
86                    color,
87                );
88            }
89            if row + 1 < self.char_height() {
90                out.push('\n');
91            }
92        }
93
94        out
95    }
96
97    fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
98        if col >= self.core.char_width || row >= self.core.char_height {
99            return None;
100        }
101
102        Some(row * self.core.char_width + col)
103    }
104
105    fn density_char(&self, value: u32) -> char {
106        let max_density = self.max_density.max(1);
107        let levels = u32::try_from(DENSITY_DECODE.len().saturating_sub(1)).unwrap_or(0);
108        let rounded = value.saturating_mul(levels).saturating_add(max_density / 2) / max_density;
109        usize::try_from(rounded)
110            .ok()
111            .and_then(|index| DENSITY_DECODE.get(index).copied())
112            .unwrap_or('█')
113    }
114}
115
116impl Canvas for DensityCanvas {
117    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
118        if x > self.pixel_width() || y > self.pixel_height() {
119            return;
120        }
121
122        let clamped_x = if x < self.pixel_width() {
123            x
124        } else {
125            x.saturating_sub(1)
126        };
127        let clamped_y = if y < self.pixel_height() {
128            y
129        } else {
130            y.saturating_sub(1)
131        };
132
133        let col = clamped_x / X_PIXELS_PER_CHAR;
134        let row = clamped_y / Y_PIXELS_PER_CHAR;
135        let Some(index) = self.cell_index(col, row) else {
136            return;
137        };
138
139        self.core.grid[index] = self.core.grid[index].saturating_add(1);
140        self.max_density = self.max_density.max(self.core.grid[index]);
141        self.core.colors[index] |= color.as_u8();
142    }
143
144    fn glyph_at(&self, col: usize, row: usize) -> char {
145        self.cell_index(col, row)
146            .map_or(' ', |index| self.density_char(self.core.grid[index]))
147    }
148
149    fn color_at(&self, col: usize, row: usize) -> CanvasColor {
150        self.cell_index(col, row)
151            .and_then(|index| CanvasColor::new(self.core.colors[index]))
152            .unwrap_or(CanvasColor::NORMAL)
153    }
154
155    fn char_width(&self) -> usize {
156        self.core.char_width
157    }
158
159    fn char_height(&self) -> usize {
160        self.core.char_height
161    }
162
163    fn pixel_width(&self) -> usize {
164        self.core.pixel_width
165    }
166
167    fn pixel_height(&self) -> usize {
168        self.core.pixel_height
169    }
170
171    fn transform(&self) -> &Transform2D {
172        &self.core.transform
173    }
174
175    fn transform_mut(&mut self) -> &mut Transform2D {
176        &mut self.core.transform
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::DensityCanvas;
183    use crate::canvas::{Canvas, draw_reference_canvas_scene, render_canvas_show};
184    use crate::color::CanvasColor;
185    use crate::test_util::assert_fixture_eq;
186
187    fn fixture_canvas() -> DensityCanvas {
188        let mut canvas = DensityCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
189        draw_reference_canvas_scene(&mut canvas);
190        canvas
191    }
192
193    #[test]
194    fn print_row_matches_fixture() {
195        let canvas = fixture_canvas();
196        let actual = canvas.print_row(2, true);
197
198        assert_fixture_eq(&actual, "tests/fixtures/canvas/density_printrow.txt");
199    }
200
201    #[test]
202    fn print_matches_fixture_with_color() {
203        let canvas = fixture_canvas();
204        let actual = canvas.print(true);
205
206        assert_fixture_eq(&actual, "tests/fixtures/canvas/density_print.txt");
207    }
208
209    #[test]
210    fn print_matches_fixture_without_color() {
211        let canvas = fixture_canvas();
212        let actual = canvas.print(false);
213
214        assert_fixture_eq(&actual, "tests/fixtures/canvas/density_print_nocolor.txt");
215    }
216
217    #[test]
218    fn show_matches_fixture_with_color() {
219        let canvas = fixture_canvas();
220        let actual = render_canvas_show(canvas, true);
221
222        assert_fixture_eq(&actual, "tests/fixtures/canvas/density_show.txt");
223    }
224
225    #[test]
226    fn show_matches_fixture_without_color() {
227        let canvas = fixture_canvas();
228        let actual = render_canvas_show(canvas, false);
229
230        assert_fixture_eq(&actual, "tests/fixtures/canvas/density_show_nocolor.txt");
231    }
232
233    #[test]
234    fn density_pixel_ors_color_bits_on_overlaps() {
235        let mut canvas = DensityCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
236        canvas.pixel(0, 0, CanvasColor::BLUE);
237        canvas.pixel(0, 0, CanvasColor::RED);
238
239        assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
240    }
241
242    #[test]
243    fn density_glyph_normalization_tracks_max_density() {
244        let mut canvas = DensityCanvas::new(4, 1, 0.0, 0.0, 1.0, 1.0);
245
246        canvas.pixel(0, 0, CanvasColor::GREEN);
247        canvas.pixel(1, 0, CanvasColor::GREEN);
248        canvas.pixel(1, 0, CanvasColor::GREEN);
249        canvas.pixel(2, 0, CanvasColor::GREEN);
250        canvas.pixel(2, 0, CanvasColor::GREEN);
251        canvas.pixel(2, 0, CanvasColor::GREEN);
252        for _ in 0..4 {
253            canvas.pixel(3, 0, CanvasColor::GREEN);
254        }
255
256        let row = (0..4)
257            .map(|col| canvas.glyph_at(col, 0))
258            .collect::<String>();
259        assert_eq!(row, "░▒▓█");
260    }
261
262    #[test]
263    fn density_pixel_clamps_upper_bounds_and_rejects_out_of_bounds() {
264        let mut canvas = DensityCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
265        canvas.pixel(1, 2, CanvasColor::YELLOW);
266        assert_eq!(canvas.glyph_at(0, 0), '█');
267
268        canvas.pixel(2, 0, CanvasColor::WHITE);
269        assert_eq!(canvas.glyph_at(0, 0), '█');
270    }
271}