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#[derive(Debug, Clone, PartialEq)]
11pub struct DensityCanvas {
12 core: CanvasCore<u32>,
13 max_density: u32,
14}
15
16impl DensityCanvas {
17 #[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}