Skip to main content

egui_charts/chart/overlays/
footprint.rs

1//! Footprint cells painted through the chart's existing price and time mapping.
2
3use crate::chart::coords::ChartMapping;
4use crate::data::{Footprint, FootprintBar, FootprintLevel, NodeSensitivity};
5use crate::model::Bar;
6use crate::theme::ThemeExt;
7use egui::epaint::{Galley, Mesh, Shape};
8use egui::{Align2, Color32, FontId, Painter, Rect, Stroke, StrokeKind, pos2, vec2};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// The smallest and largest font size a footprint number is drawn at, in points.
14const FONT_LIMITS: [f32; 2] = [8.0, 24.0];
15
16/// Numbers shown in a footprint column.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum FootprintMode {
19    /// Sell volume on the left, buy volume on the right.
20    BidAsk,
21    /// All executed volume, including unknown sides.
22    Volume,
23    /// Buy minus sell volume.
24    Delta,
25    /// Delta followed by total volume.
26    DeltaVolume,
27    /// Only the larger of the sell and buy volume of the row, at the left of the cell when sells
28    /// dominate and at the right when buys do, centred on a tie. The other side stays out.
29    DominantSide,
30}
31
32/// What determines a footprint cell's colour.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub enum FootprintColor {
35    /// Volume relative to the largest row of this bar.
36    Volume,
37    /// Delta as a fraction of the row's classified volume.
38    Delta,
39    /// Dominant bid or ask side, using the configured comparison.
40    Imbalance,
41    /// The neutral colour for every cell, whatever was traded.
42    Fixed,
43    /// The buy colour for a positive delta and the sell colour for a negative one, with the
44    /// opacity growing with the delta's absolute size relative to the bar's largest. A zero delta
45    /// takes the neutral colour at full opacity.
46    DeltaOpacity,
47}
48
49/// Footprint appearance. Supply colours from the host's theme. Serializable, so a host can keep
50/// it with its chart settings; fields missing from older documents take their defaults.
51#[derive(Clone, Debug, Serialize, Deserialize)]
52#[serde(default)]
53pub struct FootprintConfig {
54    /// One to four columns per bar; only the first four entries are drawn.
55    pub modes: Vec<FootprintMode>,
56    /// Draw proportional volume profiles behind the numbers.
57    pub profile: bool,
58    /// Cell colouring rule.
59    pub color_by: FootprintColor,
60    /// Buy colour.
61    pub ask_color: Color32,
62    /// Sell colour.
63    pub bid_color: Color32,
64    /// Text colour and POC outline.
65    pub text_color: Color32,
66    /// Cell fill for balanced or unclassified executions.
67    pub neutral_color: Color32,
68    /// Group adjacent rows automatically to at least this screen height. `None` keeps the
69    /// aggregation's manual grouping. Grouping is an integer multiple of the input step.
70    pub auto_row_height: Option<f32>,
71    /// Hide numbers when a row is shorter than this height. A column uses compact values and
72    /// fonts down to 8 points before switching to a profile overview without numbers.
73    pub min_text_height: f32,
74    /// Dominance multiple, normally 3 (300%).
75    pub imbalance_ratio: f64,
76    /// Inclusive minimum dominant-side volume for cell imbalances and stacked zones, in
77    /// instrument volume units after row grouping. Overrides the study config's minimum when
78    /// drawn by the chart. Zero preserves all ratio signals; negative or non-finite, none.
79    pub imbalance_min_volume: f64,
80    /// Compare to the opposite side one price away instead of at the same price.
81    pub diagonal: bool,
82    /// Outline the highest-volume row when its cells are large enough for the stroke.
83    pub show_poc: bool,
84    /// Shade the contiguous value area containing this share of the bar volume.
85    pub value_area: Option<f64>,
86    /// Trade profiles: the guide along the top edge of the value area (VAH); `None` hides it.
87    pub value_area_high: Option<Stroke>,
88    /// Trade profiles: the guide along the bottom edge of the value area (VAL); `None` hides it.
89    pub value_area_low: Option<Stroke>,
90    /// Trade profiles: how volume nodes are chosen.
91    pub node_sensitivity: NodeSensitivity,
92    /// Prefer 1.2K for 1,200 and 3.4M for 3,400,000, even when every digit would fit.
93    /// Otherwise compact units are only a width-fitting fallback.
94    pub abbreviate: bool,
95    /// Write zero volumes as 0. Otherwise they are left blank, so a one-sided row shows only
96    /// the side that traded.
97    pub show_zero: bool,
98    /// Preferred smallest and largest font size of the numbers, in points. A row's size follows
99    /// its volume relative to the bar's largest row; equal values request one size. Both are held
100    /// within 8 and 24 points. Fitting to the cell can reduce the size to 8 points; columns that
101    /// cannot fit readable numbers use an overview without them.
102    pub font_size: [f32; 2],
103    /// Write the ask/bid ratio of the highest and lowest row of each bar above and below it,
104    /// or `n/a` when the bid is zero and the ratio is undefined.
105    pub extreme_ratio: bool,
106    /// Outline the rows of the bar's pullback range, see [`pullback_range`].
107    pub pullback: bool,
108    /// Reserve a per-bar statistics table below the chart's price pane.
109    /// Used by [`crate::widget::Chart::show_footprint`].
110    pub statistics: bool,
111    /// Annotations calculated from the displayed rows after executions or grouping change.
112    /// `None` hides them. Used by [`crate::widget::Chart::show_footprint`].
113    #[cfg(feature = "studies")]
114    pub studies: Option<crate::studies::FootprintStudyConfig>,
115}
116
117impl Default for FootprintConfig {
118    fn default() -> Self {
119        Self {
120            modes: vec![FootprintMode::BidAsk],
121            profile: true,
122            color_by: FootprintColor::Delta,
123            ask_color: crate::tokens::DESIGN_TOKENS.semantic.chart.bullish,
124            bid_color: crate::tokens::DESIGN_TOKENS.semantic.chart.bearish,
125            text_color: Color32::WHITE,
126            neutral_color: Color32::GRAY,
127            auto_row_height: Some(16.0),
128            min_text_height: 12.0,
129            imbalance_ratio: 3.0,
130            imbalance_min_volume: 0.0,
131            diagonal: true,
132            show_poc: true,
133            value_area: Some(0.7),
134            value_area_high: Some(Stroke::new(1.0, Color32::GRAY)),
135            value_area_low: Some(Stroke::new(1.0, Color32::GRAY)),
136            node_sensitivity: NodeSensitivity::default(),
137            abbreviate: false,
138            show_zero: true,
139            font_size: [10.0, 10.0],
140            extreme_ratio: false,
141            pullback: false,
142            statistics: false,
143            #[cfg(feature = "studies")]
144            studies: None,
145        }
146    }
147}
148
149/// The rows of a bar between its close and the extreme it reached beyond the close: from the
150/// close up to the highest traded row when the bar closed at or above its open, and from the
151/// lowest traded row up to the close when it closed below. Returns inclusive level keys of
152/// `flow`, or `None` when the bar has no executions or closed on its extreme row. The range
153/// follows the bar as it develops, and it only selects rows: their executed volume is never
154/// changed.
155pub fn pullback_range(bar: &Bar, flow: &Footprint) -> Option<(i64, i64)> {
156    let source = flow.bar_at(bar.time)?;
157    let low = *source.levels.first_key_value()?.0;
158    let high = *source.levels.last_key_value()?.0;
159    let close = flow.level_of(bar.close)?.clamp(low, high);
160    let range = if bar.close >= bar.open {
161        (close, high)
162    } else {
163        (low, close)
164    };
165    (range.0 < range.1).then_some(range)
166}
167
168/// The width of a column of cells at `bar_spacing` points per bar, its half-point gap included,
169/// or `None` when cells would be under a point wide. Those are unreadable and, painted without
170/// antialiasing, can fall between pixel centres and draw nothing at all.
171pub(crate) fn footprint_column_width(bar_spacing: f32, config: &FootprintConfig) -> Option<f32> {
172    let columns = config.modes.len().min(4);
173    let width = bar_spacing * 0.92 / columns as f32;
174    (columns > 0 && bar_spacing.is_finite() && width - 0.5 >= 1.0).then_some(width)
175}
176
177/// Draws visible footprint bars, bounded by the visible bar range and clipped to `mapping.rect`.
178///
179/// For the chart widget use [`crate::widget::Chart::show_footprint`] to keep cells below its
180/// legend and cursor. Standalone hosts supply their pane mapping and source bars. Use only
181/// time-based bars whose timestamps lie inside their aggregation slots. Text layout uses
182/// egui's galley cache; rows below the minimum height
183/// never request a layout. Row bands extend half an instrument tick below their first
184/// tick and above their last tick, including grouped rows. Fractional volumes below 100 retain
185/// up to three decimals; nonzero magnitudes below 0.001 use scientific notation. Only display
186/// grouping runs during painting. Cells under a point wide are too narrow to read and are skipped
187/// before looking up executions. A price spine occupies a separate gutter to the left of each
188/// bar: the opening tick points left, the closing tick right, and the colour follows price direction.
189pub fn render_footprint(
190    painter: &Painter,
191    mapping: &ChartMapping,
192    bars: &[Bar],
193    flow: &Footprint,
194    config: &FootprintConfig,
195) {
196    let Some(width) = footprint_column_width(mapping.bar_spacing, config) else {
197        return;
198    };
199    if bars.is_empty() {
200        return;
201    }
202    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
203    let first = mapping
204        .x_to_idx(mapping.rect.left())
205        .saturating_sub(1)
206        .min(bars.len());
207    let end = mapping
208        .x_to_idx(mapping.rect.right())
209        .saturating_add(2)
210        .min(bars.len());
211    // One mesh avoids one tessellation job and allocation per cell at wide zooms.
212    let background = painter.add(Shape::Noop);
213    let mut mesh = Mesh::default();
214    let columns = config.modes.len().min(4);
215    let text_background = painter.ctx().active_theme().chart.bg.to_opaque();
216    // Use the existing half-gap so price markers do not shrink execution columns.
217    let gutter = (mapping.bar_spacing * 0.04).min(2.5);
218    // Bars cut by dropped executions are drawn as candles, not as complete footprint bars.
219    let complete_from = flow.complete_from();
220    for (index, bar) in bars.iter().enumerate().take(end).skip(first) {
221        let Some(source) = flow
222            .bar_at(bar.time)
223            .filter(|_| complete_from.is_some_and(|from| bar.time >= from))
224        else {
225            continue;
226        };
227        let x = mapping.idx_to_x(index);
228        let left = x - mapping.bar_spacing * 0.46;
229        let price_left = (left - gutter).max(mapping.rect.left());
230        let price_right = left.max(price_left);
231        let spine = (price_left + price_right) * 0.5;
232        let color = if bar.close > bar.open {
233            config.ask_color
234        } else if bar.close < bar.open {
235            config.bid_color
236        } else {
237            config.text_color
238        };
239        let stroke = Stroke::new((gutter * 0.2).clamp(0.25, 1.0), color);
240        let [high, low, open, close] =
241            [bar.high, bar.low, bar.open, bar.close].map(|price| mapping.price_to_y(price));
242        if price_right > price_left && [high, low, open, close].iter().all(|y| y.is_finite()) {
243            painter.line_segment([pos2(spine, high), pos2(spine, low)], stroke);
244            painter.line_segment(
245                [pos2(spine, open), pos2(spine, close)],
246                Stroke::new(stroke.width * 2.0, color),
247            );
248            painter.line_segment([pos2(price_left, open), pos2(spine, open)], stroke);
249            painter.line_segment([pos2(spine, close), pos2(price_right, close)], stroke);
250        }
251        // Automatic grouping uses the local price mapping, including log and inverted scales.
252        let group = row_group(mapping, bar, flow, config);
253        let grouped;
254        let shown = if group > 1 {
255            grouped = group_bar(source, group);
256            &grouped
257        } else {
258            source
259        };
260        let max_volume = shown
261            .levels
262            .values()
263            .map(|row| row.volume())
264            .fold(0.0, f64::max);
265        let max_delta = shown
266            .levels
267            .values()
268            .map(|row| row.delta().abs())
269            .fold(0.0, f64::max);
270        let area = config.value_area.and_then(|share| shown.value_area(share));
271        let poc = config.show_poc.then(|| shown.poc()).flatten();
272        let pullback = config
273            .pullback
274            .then(|| pullback_range(bar, flow))
275            .flatten()
276            .map(|(low, high)| (low.div_euclid(group), high.div_euclid(group)));
277        let text_rows: Vec<_> = shown
278            .levels
279            .values()
280            .filter(|row| {
281                let price =
282                    row.level as f64 * flow.price_step() * group as f64 - flow.tick_size() * 0.5;
283                let y1 = mapping.price_to_y(price);
284                let y2 = mapping.price_to_y(price + flow.price_step() * group as f64);
285                let center = (y1 + y2) * 0.5;
286                y1.is_finite()
287                    && y2.is_finite()
288                    && center - FONT_LIMITS[0] * 0.5 >= mapping.rect.top()
289                    && center + FONT_LIMITS[0] * 0.5 <= mapping.rect.bottom()
290                    && (y2 - y1).abs() >= config.min_text_height.max(10.0)
291                    && Rect::from_min_max(
292                        pos2(left, y1.min(y2)),
293                        pos2(x + mapping.bar_spacing * 0.46, y1.max(y2)),
294                    )
295                    .intersects(mapping.rect)
296            })
297            .collect();
298        let text: Vec<_> = config
299            .modes
300            .iter()
301            .take(columns)
302            .map(|mode| {
303                column_galleys(&painter, &text_rows, max_volume, *mode, config, width - 2.5)
304            })
305            .collect();
306        for row in shown.levels.values() {
307            let price =
308                row.level as f64 * flow.price_step() * group as f64 - flow.tick_size() * 0.5;
309            let y1 = mapping.price_to_y(price);
310            let y2 = mapping.price_to_y(price + flow.price_step() * group as f64);
311            if !y1.is_finite() || !y2.is_finite() {
312                continue;
313            }
314            for (column, mode) in config.modes.iter().take(columns).enumerate() {
315                let left = x - mapping.bar_spacing * 0.46 + column as f32 * width;
316                let rect = Rect::from_min_max(
317                    pos2(left, y1.min(y2)),
318                    pos2(left + width - 0.5, y1.max(y2)),
319                );
320                if !rect.intersects(mapping.rect) {
321                    continue;
322                }
323                let color = cell_color(shown, row, max_volume, max_delta, config);
324                let share = if config.profile && max_volume > 0.0 {
325                    (row.volume() / max_volume) as f32
326                } else {
327                    1.0
328                };
329                let fill = Rect::from_min_size(
330                    rect.min,
331                    vec2(rect.width() * share, rect.height().max(1.0)),
332                );
333                let mut fills = [
334                    area.is_some_and(|(low, high)| (low..=high).contains(&row.level))
335                        .then_some((rect, config.neutral_color.gamma_multiply(0.12))),
336                    Some((fill, color.gamma_multiply(0.5))),
337                    None,
338                ];
339                if config.color_by == FootprintColor::Imbalance
340                    && shown.imbalance(
341                        row.level,
342                        config.imbalance_ratio,
343                        config.diagonal,
344                        config.imbalance_min_volume,
345                    ) == (true, true)
346                {
347                    let (bid, ask) = fill.split_left_right_at_x(fill.center().x);
348                    fills[1] = Some((bid, config.bid_color.gamma_multiply(0.5)));
349                    fills[2] = Some((ask, config.ask_color.gamma_multiply(0.5)));
350                }
351                for &(fill, color) in fills.iter().flatten() {
352                    mesh.add_colored_rect(fill.intersect(mapping.rect), color);
353                }
354                if pullback.is_some_and(|(low, high)| (low..=high).contains(&row.level)) {
355                    painter.rect_stroke(
356                        rect,
357                        0,
358                        Stroke::new(1.0, config.neutral_color),
359                        StrokeKind::Inside,
360                    );
361                }
362                if poc == Some(row.level) && rect.width() >= 12.0 && rect.height() >= 4.0 {
363                    painter.rect_stroke(
364                        rect,
365                        0,
366                        Stroke::new(1.0, config.text_color),
367                        StrokeKind::Inside,
368                    );
369                }
370                if rect.height() >= config.min_text_height.max(10.0)
371                    && let Some(galley) = text[column]
372                        .as_ref()
373                        .and_then(|galleys| galleys.get(&row.level))
374                {
375                    let mut galley = galley.clone();
376                    if !galley.is_empty() && galley.size().y > rect.height() {
377                        let size = galley.job.sections[0].format.font_id.size;
378                        for size in [
379                            (size * rect.height() / galley.size().y).max(FONT_LIMITS[0]),
380                            FONT_LIMITS[0],
381                        ] {
382                            galley = painter.layout_no_wrap(
383                                galley.text().to_owned(),
384                                FontId::monospace(size),
385                                config.text_color,
386                            );
387                            if galley.size().y <= rect.height() {
388                                break;
389                            }
390                        }
391                    }
392                    if !galley.is_empty() && galley.size().y <= rect.height() {
393                        let center = rect.center().x;
394                        let x = match mode {
395                            FootprintMode::DominantSide if row.bid > row.ask => rect.left() + 1.0,
396                            FootprintMode::DominantSide if row.ask > row.bid => {
397                                rect.right() - 1.0 - galley.size().x
398                            }
399                            _ => center - galley.size().x / 2.0,
400                        };
401                        let at = pos2(x, rect.center().y - galley.size().y / 2.0);
402                        // Text is whole or absent: a cell cut by the plot edge shows no number.
403                        if mapping
404                            .rect
405                            .contains_rect(Rect::from_min_size(at, galley.size()))
406                        {
407                            // Replay the cell fills over an opaque patch so underlays cannot
408                            // show through the numbers, keeping their existing cell colours.
409                            let backing = Rect::from_min_size(at, galley.size())
410                                .expand2(vec2(2.0, 1.0))
411                                .intersect(rect)
412                                .intersect(mapping.rect);
413                            mesh.add_colored_rect(backing, text_background);
414                            for &(fill, color) in fills.iter().flatten() {
415                                if fill.intersects(backing) {
416                                    mesh.add_colored_rect(fill.intersect(backing), color);
417                                }
418                            }
419                            painter.galley(at, galley, config.text_color);
420                        }
421                    }
422                }
423            }
424        }
425        if config.extreme_ratio {
426            let step = flow.price_step() * group as f64;
427            let first = shown.levels.first_key_value();
428            let last = shown.levels.last_key_value();
429            // A bar of one row has a single extreme.
430            for (extreme, high) in [
431                (last, true),
432                (first.filter(|_| shown.levels.len() > 1), false),
433            ] {
434                let Some((&level, row)) = extreme else {
435                    continue;
436                };
437                let edge = mapping
438                    .price_to_y((level + i64::from(high)) as f64 * step - flow.tick_size() * 0.5);
439                let text = row.ratio().map_or_else(
440                    || "A/B n/a".to_owned(),
441                    |ratio| {
442                        format!(
443                            "A/B {}",
444                            egui::emath::format_with_decimals_in_range(ratio, 0..=2)
445                        )
446                    },
447                );
448                let galley =
449                    painter.layout_no_wrap(text, FontId::monospace(10.0), config.text_color);
450                let anchor = if high {
451                    Align2::CENTER_BOTTOM
452                } else {
453                    Align2::CENTER_TOP
454                };
455                let bounds = anchor.anchor_size(pos2(x, edge), galley.size());
456                if edge.is_finite()
457                    && galley.size().x <= mapping.bar_spacing
458                    && mapping.rect.contains_rect(bounds)
459                {
460                    painter.galley(bounds.min, galley, config.text_color);
461                }
462            }
463        }
464    }
465    painter.set(background, Shape::mesh(mesh));
466}
467
468/// The font sizes of the smallest and the largest row: the configured pair within the readable
469/// limits, in ascending order; the default size for one that is not a number.
470fn font_limits(sizes: [f32; 2]) -> [f32; 2] {
471    let [a, b] = sizes.map(|size| {
472        if size.is_nan() {
473            10.0
474        } else {
475            size.clamp(FONT_LIMITS[0], FONT_LIMITS[1])
476        }
477    });
478    [a.min(b), a.max(b)]
479}
480
481/// Fit the whole column before painting, so its largest executions never disappear alone.
482fn column_galleys(
483    painter: &Painter,
484    rows: &[&FootprintLevel],
485    max_volume: f64,
486    mode: FootprintMode,
487    config: &FootprintConfig,
488    width: f32,
489) -> Option<BTreeMap<i64, Arc<Galley>>> {
490    if rows.is_empty() || width < 18.0 {
491        return None;
492    }
493    let [smallest, largest] = font_limits(config.font_size);
494    for compact in [false, true] {
495        let mut fits = true;
496        let galleys = rows
497            .iter()
498            .map(|row| {
499                let mut text = cell_text(mode, row, config.abbreviate || compact, config.show_zero);
500                if compact {
501                    text = text.replace(" × ", "×").replace(" / ", "/");
502                }
503                let share = if max_volume > 0.0 {
504                    (row.volume() / max_volume) as f32
505                } else {
506                    0.0
507                };
508                let size = smallest + (largest - smallest) * share;
509                let mut galley = painter.layout_no_wrap(
510                    text.clone(),
511                    FontId::monospace(size),
512                    config.text_color,
513                );
514                if galley.size().x > width {
515                    let size = (size * width / galley.size().x).max(FONT_LIMITS[0]);
516                    galley =
517                        painter.layout_no_wrap(text, FontId::monospace(size), config.text_color);
518                }
519                if galley.size().x > width && size > FONT_LIMITS[0] {
520                    galley = painter.layout_no_wrap(
521                        galley.text().to_owned(),
522                        FontId::monospace(FONT_LIMITS[0]),
523                        config.text_color,
524                    );
525                }
526                fits &= galley.size().x <= width;
527                (row.level, galley)
528            })
529            .collect();
530        if fits {
531            return Some(galleys);
532        }
533    }
534    None
535}
536
537/// The text of one cell in `mode`; empty when every number in it is a hidden zero.
538fn cell_text(
539    mode: FootprintMode,
540    row: &FootprintLevel,
541    abbreviate: bool,
542    show_zero: bool,
543) -> String {
544    let hidden = |value: f64| value == 0.0 && !show_zero;
545    let number = |value: f64| {
546        if hidden(value) {
547            String::new()
548        } else {
549            volume_text(value, abbreviate)
550        }
551    };
552    let signed = |value: f64| match number(value) {
553        text if text.is_empty() || value < 0.0 => text,
554        text => format!("+{text}"),
555    };
556    match mode {
557        FootprintMode::BidAsk if hidden(row.bid) && hidden(row.ask) => String::new(),
558        FootprintMode::BidAsk => format!("{} × {}", number(row.bid), number(row.ask)),
559        FootprintMode::Volume => number(row.volume()),
560        FootprintMode::Delta => signed(row.delta()),
561        FootprintMode::DeltaVolume if hidden(row.delta()) && hidden(row.volume()) => String::new(),
562        FootprintMode::DeltaVolume => {
563            format!("{} / {}", signed(row.delta()), number(row.volume()))
564        }
565        FootprintMode::DominantSide => number(row.bid.max(row.ask)),
566    }
567}
568
569fn volume_text(value: f64, abbreviate: bool) -> String {
570    if abbreviate && value.abs() >= 1000.0 {
571        // 999,950 and above round to 1000.0K at one decimal, so they take the next unit.
572        let (scaled, unit) = if value.abs() >= 999_950.0 {
573            (value / 1e6, 'M')
574        } else {
575            (value / 1e3, 'K')
576        };
577        let text = format!("{scaled:.1}");
578        return format!("{}{unit}", text.strip_suffix(".0").unwrap_or(&text));
579    }
580    if value != 0.0 && value.abs() < 0.001 {
581        format!("{value:.2e}")
582    } else {
583        egui::emath::format_with_decimals_in_range(
584            value,
585            0..=if value.abs() < 100.0 { 3 } else { 0 },
586        )
587    }
588}
589
590fn row_group(mapping: &ChartMapping, bar: &Bar, flow: &Footprint, config: &FootprintConfig) -> i64 {
591    let raw_height =
592        (mapping.price_to_y(bar.close + flow.price_step()) - mapping.price_to_y(bar.close)).abs();
593    config
594        .auto_row_height
595        .filter(|h| h.is_finite() && *h > 0.0)
596        .map_or(1, |height| {
597            (height / raw_height.max(0.01))
598                .ceil()
599                .clamp(1.0, 1_000_000.0) as i64
600        })
601}
602
603fn group_bar(source: &FootprintBar, group: i64) -> FootprintBar {
604    let mut shown = FootprintBar {
605        time: source.time,
606        levels: BTreeMap::new(),
607        bid: source.bid,
608        ask: source.ask,
609        unknown: source.unknown,
610        min_delta: source.min_delta,
611        max_delta: source.max_delta,
612    };
613    for row in source.levels.values() {
614        let key = row.level.div_euclid(group);
615        let target = shown.levels.entry(key).or_insert_with(|| FootprintLevel {
616            level: key,
617            ..Default::default()
618        });
619        target.bid += row.bid;
620        target.ask += row.ask;
621        target.unknown += row.unknown;
622    }
623    shown
624}
625
626fn cell_color(
627    bar: &FootprintBar,
628    row: &FootprintLevel,
629    max_volume: f64,
630    max_delta: f64,
631    config: &FootprintConfig,
632) -> Color32 {
633    match config.color_by {
634        FootprintColor::Fixed => config.neutral_color,
635        FootprintColor::DeltaOpacity if row.delta() == 0.0 => config.neutral_color,
636        FootprintColor::DeltaOpacity => {
637            let side = if row.delta() > 0.0 {
638                config.ask_color
639            } else {
640                config.bid_color
641            };
642            side.gamma_multiply((row.delta().abs() / max_delta) as f32)
643        }
644        FootprintColor::Volume => config.neutral_color.gamma_multiply(if max_volume > 0.0 {
645            (row.volume() / max_volume) as f32
646        } else {
647            0.0
648        }),
649        FootprintColor::Delta if row.bid + row.ask == 0.0 => config.neutral_color,
650        FootprintColor::Delta => {
651            let share = if row.bid + row.ask > 0.0 {
652                row.ask / (row.bid + row.ask)
653            } else {
654                0.5
655            };
656            config
657                .bid_color
658                .lerp_to_gamma(config.ask_color, share as f32)
659        }
660        FootprintColor::Imbalance => {
661            match bar.imbalance(
662                row.level,
663                config.imbalance_ratio,
664                config.diagonal,
665                config.imbalance_min_volume,
666            ) {
667                (true, false) => config.bid_color,
668                (false, true) => config.ask_color,
669                _ => config.neutral_color,
670            }
671        }
672    }
673}
674
675/// Draws a compact per-bar statistics table under the footprint at the same x coordinates.
676/// Rows show total, bid, ask, delta, minimum and maximum intrabar delta, cumulative delta,
677/// delta percentage, and ask/bid ratio (undefined for zero volume or bid). Bars before
678/// [`Footprint::complete_from`] get no column and no share of the cumulative delta.
679/// Shared labels occupy the left margin. Numeric columns stay aligned with their bars and
680/// use unabbreviated volume values when they fit, then compact units. Percentages have one
681/// decimal and ask/bid ratios two decimals.
682/// A column cut by the margin or plot edge is omitted as a whole. When the complete table
683/// cannot fit at a readable size, a zoom-in or resize notice replaces it; values remain in
684/// [`FootprintBar`].
685/// A view without complete executions reports that statistics are unavailable.
686pub fn render_footprint_statistics(
687    painter: &Painter,
688    mapping: &ChartMapping,
689    bars: &[Bar],
690    flow: &Footprint,
691    text_color: Color32,
692) {
693    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
694    let labels = [
695        "Vol", "Bid", "Ask", "Delta", "Min", "Max", "CVD", "Delta %", "Ask/Bid",
696    ];
697    let row_height = (mapping.rect.height() / labels.len() as f32).min(12.0);
698    let font = FontId::monospace(10.0);
699    let labels: Vec<_> = labels
700        .into_iter()
701        .map(|label| painter.layout_no_wrap(label.into(), font.clone(), text_color))
702        .collect();
703    let rows_fit = labels.iter().all(|label| label.size().y <= row_height);
704    let label_width = labels
705        .iter()
706        .map(|label| label.size().x)
707        .fold(0.0, f32::max)
708        + 8.0;
709    let mut cumulative = 0.0;
710    let complete = flow.complete_from();
711    let mut has_data = false;
712    let mut columns = Vec::new();
713    for (i, bar) in bars.iter().enumerate() {
714        let Some(row) = flow
715            .bar_at(bar.time)
716            .filter(|_| complete.is_some_and(|from| bar.time >= from))
717        else {
718            continue;
719        };
720        cumulative += row.delta();
721        let x = mapping.idx_to_x(i);
722        if !mapping.is_x_visible(x) {
723            continue;
724        }
725        has_data = true;
726        if x - mapping.bar_spacing * 0.5 < mapping.rect.left() + label_width
727            || x + mapping.bar_spacing * 0.5 > mapping.rect.right()
728        {
729            continue;
730        }
731        let values = [
732            Some(row.volume()),
733            Some(row.bid),
734            Some(row.ask),
735            Some(row.delta()),
736            Some(row.min_delta),
737            Some(row.max_delta),
738            Some(cumulative),
739            row.delta_percent(),
740            (row.bid > 0.0).then(|| row.ask / row.bid),
741        ];
742        columns.push((x, values));
743    }
744    let mut size = 10.0;
745    let mut fitted = Vec::new();
746    if mapping.bar_spacing >= 22.0 && rows_fit {
747        'fitting: for abbreviate in [false, true] {
748            for font_size in [10.0, FONT_LIMITS[0]] {
749                size = font_size;
750                fitted = columns
751                    .iter()
752                    .map(|(x, values)| {
753                        (
754                            *x,
755                            values
756                                .iter()
757                                .copied()
758                                .enumerate()
759                                .map(|(line, value)| {
760                                    let text = value.map_or_else(
761                                        || "n/a".to_owned(),
762                                        |value| match line {
763                                            7 => format!("{value:.1}%"),
764                                            8 => format!("{value:.2}:1"),
765                                            _ => volume_text(value, abbreviate),
766                                        },
767                                    );
768                                    painter.layout_no_wrap(
769                                        text,
770                                        FontId::monospace(size),
771                                        text_color,
772                                    )
773                                })
774                                .collect::<Vec<_>>(),
775                        )
776                    })
777                    .collect::<Vec<_>>();
778                if fitted.iter().all(|(_, values)| {
779                    values
780                        .iter()
781                        .all(|value| value.size().x <= mapping.bar_spacing - 4.0)
782                }) {
783                    break 'fitting;
784                }
785            }
786        }
787    }
788    if fitted.is_empty()
789        || fitted.iter().any(|(_, values)| {
790            values
791                .iter()
792                .any(|value| value.size().x > mapping.bar_spacing - 4.0)
793        })
794    {
795        painter.text(
796            mapping.rect.center(),
797            Align2::CENTER_CENTER,
798            if !has_data {
799                "Execution statistics unavailable"
800            } else if !rows_fit {
801                "Enlarge chart for per-bar statistics"
802            } else {
803                "Zoom in for per-bar statistics"
804            },
805            font,
806            text_color,
807        );
808        return;
809    }
810    for (line, label) in labels.into_iter().enumerate() {
811        painter.galley(
812            pos2(
813                mapping.rect.left() + 2.0,
814                mapping.rect.top() + row_height * line as f32,
815            ),
816            label,
817            text_color,
818        );
819    }
820    for (x, values) in fitted {
821        for (line, value) in values.into_iter().enumerate() {
822            let at = pos2(
823                x - value.size().x * 0.5,
824                mapping.rect.top() + row_height * line as f32 + (10.0 - size) * 0.5,
825            );
826            painter.galley(at, value, text_color);
827        }
828    }
829}
830
831#[cfg(feature = "studies")]
832#[derive(Clone, Default)]
833struct FootprintStudyCache {
834    source: Vec<(FootprintBar, i64)>,
835    config: Option<crate::studies::FootprintStudyConfig>,
836    studies: std::sync::Arc<[crate::studies::FootprintStudy]>,
837}
838
839/// Cache calculations per chart, including display grouping and execution corrections.
840#[cfg(feature = "studies")]
841pub(crate) fn render_footprint_annotations(
842    painter: &Painter,
843    mapping: &ChartMapping,
844    bars: &[Bar],
845    flow: &Footprint,
846    config: &FootprintConfig,
847    chart_id: egui::Id,
848) {
849    let Some(settings) = &config.studies else {
850        return;
851    };
852    let settings = crate::studies::FootprintStudyConfig {
853        imbalance_min_volume: config.imbalance_min_volume,
854        ..settings.clone()
855    };
856    let complete = flow.complete_from();
857    let source: Vec<_> = bars
858        .iter()
859        .filter(|bar| complete.is_some_and(|from| bar.time >= from))
860        .filter_map(|bar| {
861            flow.bar_at(bar.time)
862                .map(|row| (row, row_group(mapping, bar, flow, config)))
863        })
864        .collect();
865    let studies = painter.ctx().data_mut(|data| {
866        let cache = data
867            .get_temp_mut_or_default::<FootprintStudyCache>(chart_id.with("footprint_annotations"));
868        if cache.config.as_ref() != Some(&settings)
869            || cache.source.len() != source.len()
870            || !cache
871                .source
872                .iter()
873                .zip(&source)
874                .all(|((old, group), (new, next_group))| old == *new && group == next_group)
875        {
876            cache.source = source
877                .iter()
878                .map(|(bar, group)| ((*bar).clone(), *group))
879                .collect();
880            let grouped: Vec<_> = source
881                .iter()
882                .map(|(bar, group)| (group_bar(bar, *group), *group))
883                .collect();
884            let rows: Vec<_> = grouped.iter().map(|(bar, group)| (bar, *group)).collect();
885            cache.studies = crate::studies::footprint_studies_on_rows(&rows, &settings).into();
886            cache.config = Some(settings);
887        }
888        cache.studies.clone()
889    });
890    render_footprint_studies(painter, mapping, bars, flow, &studies, config);
891}
892
893/// Paints footprint study levels and zones. Compute `studies` only after executions change.
894/// Stacks, naked POCs and unfinished levels extend to their first revisit, their expiry or the
895/// last displayed bar, in full colour while unvisited and dimmed once revisited or expired;
896/// local annotations stay anchored to their originating bar. Delta percentage and ratio markers
897/// are triangles outside the bar: buy markers above it, sell markers below, the delta marker
898/// nearest and the ratio marker beyond it. Captions avoid every footprint and its marker rows; details are
899/// available on hover when a caption has no room. Overview marks shrink with bar spacing and
900/// zones become faint centre lines. Prices use the aggregation's row step.
901#[cfg(feature = "studies")]
902#[cfg_attr(docsrs, doc(cfg(feature = "studies")))]
903pub fn render_footprint_studies(
904    painter: &Painter,
905    mapping: &ChartMapping,
906    bars: &[Bar],
907    flow: &Footprint,
908    studies: &[crate::studies::FootprintStudy],
909    config: &FootprintConfig,
910) {
911    use crate::studies::{FootprintStudyKind, FootprintStudyState};
912    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
913    let indices: BTreeMap<_, _> = bars
914        .iter()
915        .enumerate()
916        .filter_map(|(i, bar)| flow.bar_at(bar.time).map(|flow_bar| (flow_bar.time, i)))
917        .collect();
918    let occupied: BTreeMap<_, _> = bars
919        .iter()
920        .enumerate()
921        .filter_map(|(index, bar)| {
922            let source = flow.bar_at(bar.time)?;
923            let group = row_group(mapping, bar, flow, config);
924            let low = source.levels.first_key_value()?.0.div_euclid(group);
925            let high = source.levels.last_key_value()?.0.div_euclid(group);
926            let step = flow.price_step() * group as f64;
927            let y1 = mapping.price_to_y((low as f64 * step - flow.tick_size() * 0.5).min(bar.low));
928            let y2 = mapping
929                .price_to_y(((high as f64 + 1.0) * step - flow.tick_size() * 0.5).max(bar.high));
930            let x = mapping.idx_to_x(index);
931            let rect = Rect::from_min_max(
932                pos2(x - mapping.bar_spacing * 0.46, y1.min(y2)),
933                pos2(x + mapping.bar_spacing * 0.46, y1.max(y2)),
934            );
935            // The two threshold-marker rows extend up to 18 points beyond either extreme.
936            rect.intersects(mapping.rect)
937                .then_some((index, rect.expand2(vec2(0.0, 18.0))))
938        })
939        .collect();
940    let mut labels: Vec<Rect> = Vec::new();
941    let mut hovered = Vec::new();
942    let pointer = painter
943        .ctx()
944        .pointer_hover_pos()
945        .filter(|p| mapping.rect.contains(*p) && painter.clip_rect().contains(*p));
946    let detail = mapping.bar_spacing >= 48.0;
947    for study in studies {
948        let Some(&start) = indices.get(&study.start) else {
949            continue;
950        };
951        let end = study
952            .end
953            .and_then(|time| indices.get(&time).copied())
954            .unwrap_or(bars.len().saturating_sub(1));
955        let left = mapping.idx_to_x(start) - mapping.bar_spacing * 0.46;
956        let right = mapping.idx_to_x(end) + mapping.bar_spacing * 0.46;
957        let y1 = mapping.price_to_y(study.low as f64 * flow.price_step() - flow.tick_size() * 0.5);
958        let y2 = mapping
959            .price_to_y((study.high as f64 + 1.0) * flow.price_step() - flow.tick_size() * 0.5);
960        if !y1.is_finite() || !y2.is_finite() {
961            continue;
962        }
963        let rect = Rect::from_min_max(pos2(left, y1.min(y2)), pos2(right, y1.max(y2)));
964        if !rect.intersects(mapping.rect) {
965            continue;
966        }
967        let color = match study.kind {
968            FootprintStudyKind::StackedBid
969            | FootprintStudyKind::DeltaBid
970            | FootprintStudyKind::RatioBid => config.bid_color,
971            FootprintStudyKind::StackedAsk
972            | FootprintStudyKind::DeltaAsk
973            | FootprintStudyKind::RatioAsk => config.ask_color,
974            _ => config.text_color,
975        };
976        let color = match study.state {
977            FootprintStudyState::Revisited | FootprintStudyState::Expired => {
978                color.gamma_multiply(0.45)
979            }
980            _ => color,
981        };
982        let label = match study.kind {
983            FootprintStudyKind::StackedBid => "Bid stack",
984            FootprintStudyKind::StackedAsk => "Ask stack",
985            FootprintStudyKind::UnfinishedHigh => "UH",
986            FootprintStudyKind::UnfinishedLow => "UL",
987            FootprintStudyKind::NakedPoc => "POC",
988            FootprintStudyKind::DeltaPoc => "Delta POC",
989            FootprintStudyKind::VolumeCluster => "Cluster",
990            FootprintStudyKind::SinglePrint => "Single",
991            FootprintStudyKind::DeltaAsk => "Buy delta %",
992            FootprintStudyKind::DeltaBid => "Sell delta %",
993            FootprintStudyKind::RatioAsk => "Buy ask/bid ratio",
994            FootprintStudyKind::RatioBid => "Sell bid/ask ratio",
995        };
996        let marker = match study.kind {
997            FootprintStudyKind::DeltaAsk | FootprintStudyKind::DeltaBid => Some(0.0),
998            FootprintStudyKind::RatioAsk | FootprintStudyKind::RatioBid => Some(1.0),
999            _ => None,
1000        };
1001        if let Some(order) = marker {
1002            let above = matches!(
1003                study.kind,
1004                FootprintStudyKind::DeltaAsk | FootprintStudyKind::RatioAsk
1005            );
1006            let (edge, away) = if above {
1007                (rect.top(), -1.0)
1008            } else {
1009                (rect.bottom(), 1.0)
1010            };
1011            let radius = (mapping.bar_spacing * 0.2).min(4.0);
1012            let tip = edge + away * (radius * 0.75 + radius * 2.25 * order);
1013            let base = tip + away * radius * 1.5;
1014            let x = (rect.left() + rect.right()) / 2.0;
1015            painter.add(Shape::convex_polygon(
1016                vec![pos2(x, tip), pos2(x - radius, base), pos2(x + radius, base)],
1017                if detail {
1018                    color
1019                } else {
1020                    color.gamma_multiply(0.5)
1021                },
1022                Stroke::NONE,
1023            ));
1024            let bounds = Rect::from_min_max(
1025                pos2(x - radius, tip.min(base)),
1026                pos2(x + radius, tip.max(base)),
1027            );
1028            if pointer.is_some_and(|p| bounds.expand(2.0).contains(p)) {
1029                let source = flow.bar_at(bars[start].time);
1030                let value = match study.kind {
1031                    FootprintStudyKind::DeltaAsk | FootprintStudyKind::DeltaBid => source
1032                        .and_then(FootprintBar::delta_percent)
1033                        .map(|value| format!("{value:.1}%")),
1034                    _ => source.and_then(|bar| {
1035                        let (side, opposite) = if above {
1036                            (bar.ask, bar.bid)
1037                        } else {
1038                            (bar.bid, bar.ask)
1039                        };
1040                        (opposite > 0.0).then(|| format!("{:.2}:1", side / opposite))
1041                    }),
1042                };
1043                hovered.push(format!("{label}: {}", value.as_deref().unwrap_or("n/a")));
1044            }
1045            continue;
1046        }
1047        if pointer.is_some_and(|p| rect.expand(2.0).contains(p)) {
1048            let state = match study.state {
1049                FootprintStudyState::Unvisited => "unvisited",
1050                FootprintStudyState::Revisited => "revisited",
1051                FootprintStudyState::Expired => "expired",
1052                FootprintStudyState::Local => "this bar",
1053            };
1054            hovered.push(format!(
1055                "{label}: {} to {} ({state})",
1056                study.low as f64 * flow.price_step(),
1057                study.high as f64 * flow.price_step(),
1058            ));
1059        }
1060        if !detail {
1061            painter.line_segment(
1062                [rect.left_center(), rect.right_center()],
1063                Stroke::new(0.5, color.gamma_multiply(0.3)),
1064            );
1065            continue;
1066        }
1067        if matches!(
1068            study.kind,
1069            FootprintStudyKind::StackedBid
1070                | FootprintStudyKind::StackedAsk
1071                | FootprintStudyKind::VolumeCluster
1072        ) {
1073            painter.rect_filled(rect, 0, color.gamma_multiply(0.12));
1074        }
1075        {
1076            let (at, anchor) = if matches!(
1077                study.kind,
1078                FootprintStudyKind::StackedAsk
1079                    | FootprintStudyKind::StackedBid
1080                    | FootprintStudyKind::NakedPoc
1081                    | FootprintStudyKind::UnfinishedHigh
1082                    | FootprintStudyKind::UnfinishedLow
1083            ) {
1084                (rect.right_top() - vec2(0.0, 3.0), Align2::RIGHT_BOTTOM)
1085            } else {
1086                (rect.left_center() - vec2(3.0, 0.0), Align2::RIGHT_CENTER)
1087            };
1088            let galley = painter.layout_no_wrap(label.into(), FontId::monospace(10.0), color);
1089            let bounds = anchor.anchor_size(at, galley.size());
1090            let bar = occupied.get(&start).copied().unwrap_or(rect);
1091            let alternatives = [
1092                bounds,
1093                Align2::CENTER_BOTTOM
1094                    .anchor_size(pos2(bar.center().x, bar.top() - 3.0), galley.size()),
1095                Align2::CENTER_TOP
1096                    .anchor_size(pos2(bar.center().x, bar.bottom() + 3.0), galley.size()),
1097            ];
1098            if let Some(bounds) = alternatives.into_iter().find(|bounds| {
1099                mapping.rect.contains_rect(*bounds)
1100                    && !labels
1101                        .iter()
1102                        .any(|label| label.expand(1.0).intersects(*bounds))
1103                    && !occupied
1104                        .values()
1105                        .any(|cell| cell.expand(1.0).intersects(*bounds))
1106            }) {
1107                painter.galley(bounds.min, galley, color);
1108                labels.push(bounds);
1109            } else {
1110                let x = mapping.idx_to_x(start) + mapping.bar_spacing * 0.46 + 0.5;
1111                painter.circle_filled(pos2(x, rect.center().y), 0.75, color);
1112            }
1113        }
1114        painter.rect_stroke(rect, 0, Stroke::new(1.0, color), StrokeKind::Inside);
1115    }
1116    if let Some(pointer) = pointer.filter(|_| !hovered.is_empty()) {
1117        hovered.sort();
1118        hovered.dedup();
1119        egui::Tooltip::always_open(
1120            painter.ctx().clone(),
1121            painter.layer_id(),
1122            egui::Id::unique((
1123                "footprint_study_details",
1124                mapping.rect.min.x.to_bits(),
1125                mapping.rect.min.y.to_bits(),
1126            )),
1127            pointer,
1128        )
1129        .show(|ui| {
1130            for text in &hovered {
1131                ui.label(text);
1132            }
1133        });
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::model::{BarClock, OrderSide, Tick, Timeframe};
1141    use crate::scales::{PriceMap, PriceRange, PriceTransform};
1142
1143    /// Rows cover the nearest-tick band; spacing that leaves cells under a point wide produces no
1144    /// geometry.
1145    #[test]
1146    fn rendered_rows_surround_their_instrument_ticks() {
1147        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1148        // y(P) = (high - P) / (high - 99) * 160, calculated independently.
1149        #[rustfmt::skip]
1150        let cases = [
1151            (1.63, 1.0, 1, None, 100.0, 102.0, None),
1152            (1.64, 1.0, 1, None, 100.0, 102.0, Some((80.0, 133.33333))),
1153            (100.0, 1.0, 1, None, 100.0, 102.0, Some((80.0, 133.33333))),
1154            (100.0, 0.25, 1, None, 100.25, 102.0, Some((86.66667, 100.0))),
1155            (100.0, 1.0, 4, None, 100.0, 106.0, Some((57.142857, 148.57143))),
1156            (100.0, 1.0, 1, Some(75.0), 100.0, 106.0, Some((57.142857, 148.57143))),
1157        ];
1158        for (spacing, tick, ticks, auto, price, high, extent) in cases {
1159            let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), tick, ticks).unwrap();
1160            flow.push(&Tick::new(time, price, 3.0, Some(OrderSide::Buy)))
1161                .unwrap();
1162            let bars = [Bar::new(time, price, price, price, price, 3.0)];
1163            let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1164            let map = ChartMapping::new(
1165                rect,
1166                spacing,
1167                0,
1168                0,
1169                0.0,
1170                PriceMap::new(
1171                    PriceTransform::default(),
1172                    PriceRange::new(99.0, high),
1173                    0.0,
1174                    160.0,
1175                    false,
1176                ),
1177            );
1178            let config = FootprintConfig {
1179                auto_row_height: auto,
1180                show_poc: false,
1181                value_area: None,
1182                ..Default::default()
1183            };
1184            let mut output = egui::Context::default().run_ui(
1185                egui::RawInput {
1186                    screen_rect: Some(rect),
1187                    ..Default::default()
1188                },
1189                |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1190            );
1191            output.textures_delta.clear();
1192            let Some((top, bottom)) = extent else {
1193                assert!(
1194                    output.shapes.is_empty(),
1195                    "spacing {spacing}: unreadable cells"
1196                );
1197                continue;
1198            };
1199            let extent = output
1200                .shapes
1201                .iter()
1202                .filter_map(|s| match &s.shape {
1203                    Shape::Mesh(mesh) => Some(mesh),
1204                    _ => None,
1205                })
1206                .flat_map(|mesh| mesh.vertices.iter())
1207                .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| {
1208                    (lo.min(v.pos.y), hi.max(v.pos.y))
1209                });
1210            assert!(
1211                (extent.0 - top).abs() < 0.01 && (extent.1 - bottom).abs() < 0.01,
1212                "spacing {spacing}, tick {tick}, ticks {ticks}, auto {auto:?}: {extent:?}, expected {top}..{bottom}"
1213            );
1214        }
1215    }
1216
1217    /// A bar cut by dropped executions is left to its candle, not drawn as a complete bar.
1218    #[test]
1219    fn bar_cut_by_dropped_executions_is_not_drawn() {
1220        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1221        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1222        let trades = [100.0, 100.25].map(|price| Tick::new(time, price, 3.0, Some(OrderSide::Buy)));
1223        trades.iter().for_each(|trade| _ = flow.push(trade));
1224        flow.drop_through(time, &trades[1..]);
1225        assert!(flow.bar_at(time).is_some());
1226        let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 3.0)];
1227        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1228        let map = ChartMapping::new(
1229            rect,
1230            240.0,
1231            0,
1232            0,
1233            0.0,
1234            PriceMap::new(
1235                PriceTransform::default(),
1236                PriceRange::new(99.0, 102.0),
1237                0.0,
1238                160.0,
1239                false,
1240            ),
1241        );
1242        let config = FootprintConfig::default();
1243        let mut output = egui::Context::default().run_ui(
1244            egui::RawInput {
1245                screen_rect: Some(rect),
1246                ..Default::default()
1247            },
1248            |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1249        );
1250        output.textures_delta.clear();
1251        assert!(output.shapes.iter().all(|shape| match &shape.shape {
1252            Shape::Text(_) => false,
1253            Shape::Mesh(mesh) => mesh.is_empty(),
1254            _ => true,
1255        }));
1256    }
1257
1258    #[test]
1259    fn four_modes_hide_small_text_and_group_adjacent_rows() {
1260        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1261        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1262        for (price, size, side) in [
1263            (100.0, 2.0, OrderSide::Sell),
1264            (100.0, 3.0, OrderSide::Buy),
1265            (100.25, 4.0, OrderSide::Buy),
1266        ] {
1267            flow.push(&Tick::new(time, price, size, Some(side)))
1268                .unwrap();
1269        }
1270        let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 9.0)];
1271        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1272        let mapping = |high| {
1273            ChartMapping::new(
1274                rect,
1275                240.0,
1276                0,
1277                0,
1278                0.0,
1279                PriceMap::new(
1280                    PriceTransform::default(),
1281                    PriceRange::new(99.0, high),
1282                    0.0,
1283                    160.0,
1284                    false,
1285                ),
1286            )
1287        };
1288        let map = mapping(102.0);
1289        // Rows are 13.3 points high, or 10.5 with a top of 102.8: still tall enough for the
1290        // default 10-point numbers. The last case puts the plot edge through the first column:
1291        // its numbers are left out.
1292        for (minimum, auto, high, left, expected) in [
1293            (14.0, None, 102.0, 0.0, 0),
1294            (10.0, None, 102.0, 0.0, 8),
1295            (10.0, Some(30.0), 102.0, 0.0, 4),
1296            (10.0, None, 102.8, 0.0, 8),
1297            (10.0, None, 102.0, 30.0, 6),
1298        ] {
1299            let map = mapping(high);
1300            let plot = ChartMapping::new(
1301                Rect::from_min_max(pos2(left, 0.0), rect.max),
1302                240.0,
1303                0,
1304                0,
1305                0.0,
1306                map.price,
1307            );
1308            let ctx = egui::Context::default();
1309            let config = FootprintConfig {
1310                modes: vec![
1311                    FootprintMode::BidAsk,
1312                    FootprintMode::Volume,
1313                    FootprintMode::Delta,
1314                    FootprintMode::DeltaVolume,
1315                ],
1316                min_text_height: minimum,
1317                auto_row_height: auto,
1318                ..Default::default()
1319            };
1320            let mut output = ctx.run_ui(
1321                egui::RawInput {
1322                    screen_rect: Some(rect),
1323                    ..Default::default()
1324                },
1325                |ui| render_footprint(ui.painter(), &plot, &bars, &flow, &config),
1326            );
1327            output.textures_delta.clear();
1328            let texts: Vec<_> = output
1329                .shapes
1330                .iter()
1331                .filter_map(|shape| match &shape.shape {
1332                    Shape::Text(text) => Some(text.galley.text()),
1333                    _ => None,
1334                })
1335                .collect();
1336            assert_eq!(
1337                texts.len(),
1338                expected,
1339                "minimum {minimum}, auto {auto:?}, top {high}, left {left}: {texts:?}"
1340            );
1341            if auto.is_some() {
1342                assert_eq!(texts, ["2 × 7", "9", "+5", "+5 / 9"]);
1343            }
1344        }
1345        // Both diagonals can dominate at the same price; keep both side colours.
1346        let mut dual = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1347        for (price, size, side) in [
1348            (100.0, 1.0, OrderSide::Sell),
1349            (100.25, 9.0, OrderSide::Sell),
1350            (100.25, 12.0, OrderSide::Buy),
1351            (100.5, 2.0, OrderSide::Buy),
1352        ] {
1353            dual.push(&Tick::new(time, price, size, Some(side)))
1354                .unwrap();
1355        }
1356        let config = FootprintConfig {
1357            color_by: FootprintColor::Imbalance,
1358            auto_row_height: None,
1359            value_area: None,
1360            show_poc: false,
1361            ..Default::default()
1362        };
1363        let ctx = egui::Context::default();
1364        for (minimum, bid, ask) in [(0.0, true, true), (10.0, false, true), (13.0, false, false)] {
1365            let config = FootprintConfig {
1366                imbalance_min_volume: minimum,
1367                ..config.clone()
1368            };
1369            let mut output = ctx.run_ui(
1370                egui::RawInput {
1371                    screen_rect: Some(rect),
1372                    ..Default::default()
1373                },
1374                |ui| render_footprint(ui.painter(), &map, &bars, &dual, &config),
1375            );
1376            output.textures_delta.clear();
1377            for (color, expected) in [(config.bid_color, bid), (config.ask_color, ask)] {
1378                assert_eq!(
1379                    output.shapes.iter().any(|shape| match &shape.shape {
1380                        Shape::Mesh(mesh) => mesh
1381                            .vertices
1382                            .iter()
1383                            .any(|v| v.color == color.gamma_multiply(0.5)),
1384                        _ => false,
1385                    }),
1386                    expected,
1387                    "each diagonal side must reach minimum {minimum}"
1388                );
1389            }
1390        }
1391        // Fractional executions must not be labelled as zero in any display mode.
1392        let mut small = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1393        small
1394            .push(&Tick::new(time, 100.0, 0.125, Some(OrderSide::Sell)))
1395            .unwrap();
1396        small
1397            .push(&Tick::new(time, 100.0, 0.375, Some(OrderSide::Buy)))
1398            .unwrap();
1399        for (mode, expected) in [
1400            (FootprintMode::BidAsk, "0.125 × 0.375"),
1401            (FootprintMode::Volume, "0.5"),
1402            (FootprintMode::Delta, "+0.25"),
1403            (FootprintMode::DeltaVolume, "+0.25 / 0.5"),
1404        ] {
1405            let config = FootprintConfig {
1406                modes: vec![mode],
1407                min_text_height: 10.0,
1408                auto_row_height: None,
1409                ..Default::default()
1410            };
1411            let mut output = ctx.run_ui(
1412                egui::RawInput {
1413                    screen_rect: Some(rect),
1414                    ..Default::default()
1415                },
1416                |ui| render_footprint(ui.painter(), &map, &bars, &small, &config),
1417            );
1418            output.textures_delta.clear();
1419            assert!(
1420                output.shapes.iter().any(|shape| match &shape.shape {
1421                    Shape::Text(text) => text.galley.text() == expected,
1422                    _ => false,
1423                }),
1424                "missing {expected}"
1425            );
1426        }
1427        // The highest and lowest rows carry their ask/bid ratio; a zero bid is undefined, not
1428        // infinite. Here 100.25 holds buys only and 100.0 holds 2 sells against 3 buys.
1429        let config = FootprintConfig {
1430            modes: vec![FootprintMode::Volume],
1431            extreme_ratio: true,
1432            auto_row_height: None,
1433            ..Default::default()
1434        };
1435        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1436        for (price, size, side) in [
1437            (100.0, 2.0, OrderSide::Sell),
1438            (100.0, 3.0, OrderSide::Buy),
1439            (100.25, 4.0, OrderSide::Buy),
1440        ] {
1441            flow.push(&Tick::new(time, price, size, Some(side)))
1442                .unwrap();
1443        }
1444        let mut output = ctx.run_ui(
1445            egui::RawInput {
1446                screen_rect: Some(rect),
1447                ..Default::default()
1448            },
1449            |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1450        );
1451        output.textures_delta.clear();
1452        for expected in ["A/B n/a", "A/B 1.5"] {
1453            assert!(
1454                output.shapes.iter().any(|shape| match &shape.shape {
1455                    Shape::Text(text) => text.galley.text() == expected,
1456                    _ => false,
1457                }),
1458                "missing {expected}"
1459            );
1460        }
1461    }
1462
1463    /// The same minimum gates grouped cells and zones, and changing it refreshes cached zones.
1464    #[cfg(feature = "studies")]
1465    #[test]
1466    fn imbalance_minimum_applies_after_grouping_to_cells_and_zones() {
1467        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1468        let bars = [Bar::new(time, 100.0, 107.0, 100.0, 107.0, 39.0)];
1469        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1470        let map = ChartMapping::new(
1471            rect,
1472            120.0,
1473            0,
1474            0,
1475            0.0,
1476            PriceMap::new(
1477                PriceTransform::default(),
1478                PriceRange::new(98.0, 110.0),
1479                0.0,
1480                160.0,
1481                false,
1482            ),
1483        );
1484        for ticks in [1, 2] {
1485            let ctx = egui::Context::default();
1486            let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, ticks).unwrap();
1487            // Each raw row has less than 10 on either side; pairs sum to asks 12, 9, 15
1488            // against bids of 1 one grouped row below.
1489            for (price, bid, ask) in [
1490                (100.0, 1.0, 0.0),
1491                (102.0, 1.0, 12.0),
1492                (104.0, 1.0, 9.0),
1493                (106.0, 0.0, 15.0),
1494            ] {
1495                for offset in [0.0, 1.0] {
1496                    for (size, side) in [(bid / 2.0, OrderSide::Sell), (ask / 2.0, OrderSide::Buy)]
1497                    {
1498                        flow.push(&Tick::new(time, price + offset, size, Some(side)))
1499                            .unwrap();
1500                    }
1501                }
1502            }
1503            for (minimum, middle, zone) in [
1504                (0.0, true, true),
1505                (9.0, true, true),
1506                (10.0, false, false),
1507                (0.0, true, true),
1508            ] {
1509                let config = FootprintConfig {
1510                    color_by: FootprintColor::Imbalance,
1511                    profile: false,
1512                    value_area: None,
1513                    show_poc: false,
1514                    auto_row_height: (ticks == 1).then_some(20.0),
1515                    imbalance_min_volume: minimum,
1516                    studies: Some(crate::studies::FootprintStudyConfig {
1517                        naked_poc: false,
1518                        delta_poc: false,
1519                        unfinished_auctions: false,
1520                        ..Default::default()
1521                    }),
1522                    ..Default::default()
1523                };
1524                let mut output = ctx.run_ui(
1525                    egui::RawInput {
1526                        screen_rect: Some(rect),
1527                        ..Default::default()
1528                    },
1529                    |ui| {
1530                        render_footprint(ui.painter(), &map, &bars, &flow, &config);
1531                        render_footprint_annotations(
1532                            ui.painter(),
1533                            &map,
1534                            &bars,
1535                            &flow,
1536                            &config,
1537                            egui::Id::unique("minimum"),
1538                        );
1539                    },
1540                );
1541                output.textures_delta.clear();
1542                for (price, active) in [(102.0, true), (104.0, middle), (106.0, true)] {
1543                    let top = map.price_to_y(price + 1.5);
1544                    let color = if active {
1545                        config.ask_color
1546                    } else {
1547                        config.neutral_color
1548                    }
1549                    .gamma_multiply(0.5);
1550                    let filled = output.shapes.iter().any(|s| match &s.shape {
1551                        Shape::Mesh(mesh) => mesh
1552                            .vertices
1553                            .iter()
1554                            .any(|v| (v.pos.y - top).abs() < 0.01 && v.color == color),
1555                        _ => false,
1556                    });
1557                    assert!(filled, "ticks {ticks}, minimum {minimum}, price {price}");
1558                }
1559                let has_zone = output.shapes.iter().any(
1560                    |s| matches!(&s.shape, Shape::Rect(r) if r.stroke.color == config.ask_color),
1561                );
1562                assert_eq!(has_zone, zone, "ticks {ticks}, minimum {minimum}");
1563            }
1564        }
1565    }
1566
1567    /// Shared row labels and complete numeric columns survive narrow detail widths; a short pane
1568    /// reports unavailable detail instead of clipping a row. The first bar has 13 buys and 3 sells:
1569    /// delta 10, 62.5%, ask/bid 4.33:1; the second adds 2500 sells.
1570    #[test]
1571    fn statistics_keep_shared_rows_and_aligned_values_or_explain_the_overview() {
1572        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1573        let next = time + chrono::TimeDelta::minutes(1);
1574        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1575        for (time, bid, ask) in [(time, 3.0, 13.0), (next, 2500.0, 0.0)] {
1576            for (size, side) in [(bid, OrderSide::Sell), (ask, OrderSide::Buy)] {
1577                if size > 0.0 {
1578                    flow.push(&Tick::new(time, 100.0, size, Some(side)))
1579                        .unwrap();
1580                }
1581            }
1582        }
1583        let bars = [
1584            Bar::new(time, 100.0, 100.0, 100.0, 100.0, 16.0),
1585            Bar::new(next, 100.0, 100.0, 100.0, 100.0, 2500.0),
1586        ];
1587        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 112.0));
1588        let empty = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1589        for (spacing, available, large, height) in [
1590            (60.0, true, false, 112.0),
1591            (40.0, true, false, 112.0),
1592            (3.0, true, false, 112.0),
1593            (60.0, false, false, 112.0),
1594            (40.0, true, true, 112.0),
1595            (60.0, true, false, 90.0),
1596        ] {
1597            let rect = Rect::from_min_size(rect.min, vec2(rect.width(), height));
1598            let mut shown = flow.clone();
1599            let mut bars = bars.clone();
1600            if large {
1601                shown
1602                    .push(&Tick::new(
1603                        next,
1604                        100.0,
1605                        123_454_289.0,
1606                        Some(OrderSide::Sell),
1607                    ))
1608                    .unwrap();
1609                bars[1].volume = 123_456_789.0;
1610            }
1611            let map = ChartMapping::new(
1612                rect,
1613                spacing,
1614                0,
1615                1,
1616                0.0,
1617                PriceMap::new(
1618                    PriceTransform::default(),
1619                    PriceRange::new(99.0, 102.0),
1620                    0.0,
1621                    112.0,
1622                    false,
1623                ),
1624            );
1625            let mut output = egui::Context::default().run_ui(
1626                egui::RawInput {
1627                    screen_rect: Some(rect),
1628                    ..Default::default()
1629                },
1630                |ui| {
1631                    render_footprint_statistics(
1632                        ui.painter(),
1633                        &map,
1634                        &bars,
1635                        if available { &shown } else { &empty },
1636                        Color32::WHITE,
1637                    )
1638                },
1639            );
1640            output.textures_delta.clear();
1641            let texts: Vec<_> = output
1642                .shapes
1643                .iter()
1644                .filter_map(|s| match &s.shape {
1645                    Shape::Text(t) => Some(t),
1646                    _ => None,
1647                })
1648                .collect();
1649            for text in &texts {
1650                assert!(
1651                    rect.contains_rect(Rect::from_min_size(text.pos, text.galley.size())),
1652                    "statistics text is clipped at height {height}: {} {:?}",
1653                    text.galley.text(),
1654                    Rect::from_min_size(text.pos, text.galley.size()),
1655                );
1656            }
1657            if spacing == 3.0 || !available || height == 90.0 {
1658                assert_eq!(texts.len(), 1);
1659                assert_eq!(
1660                    texts[0].galley.text(),
1661                    if !available {
1662                        "Execution statistics unavailable"
1663                    } else if height == 90.0 {
1664                        "Enlarge chart for per-bar statistics"
1665                    } else {
1666                        "Zoom in for per-bar statistics"
1667                    }
1668                );
1669                continue;
1670            }
1671            assert_eq!(
1672                texts.len(),
1673                27,
1674                "nine labels and two complete columns at {spacing}"
1675            );
1676            assert_eq!(
1677                texts
1678                    .iter()
1679                    .filter(|t| t.galley.text() == "Delta %")
1680                    .count(),
1681                1
1682            );
1683            for (column, expected) in [
1684                ["16", "3", "13", "10", "-3", "10", "10", "62.5%", "4.33:1"],
1685                if large {
1686                    [
1687                        "123.5M", "123.5M", "0", "-123.5M", "-123.5M", "0", "-123.5M", "-100.0%",
1688                        "0.00:1",
1689                    ]
1690                } else {
1691                    [
1692                        "2500", "2500", "0", "-2500", "-2500", "0", "-2490", "-100.0%", "0.00:1",
1693                    ]
1694                },
1695            ]
1696            .into_iter()
1697            .enumerate()
1698            {
1699                for (line, expected) in expected.into_iter().enumerate() {
1700                    let text = texts[9 + column * 9 + line];
1701                    assert_eq!(text.galley.text(), expected);
1702                    assert!(
1703                        (text.pos.x + text.galley.size().x * 0.5 - map.idx_to_x(column)).abs()
1704                            < 0.01
1705                    );
1706                }
1707            }
1708        }
1709    }
1710
1711    /// OHLC ticks use exact prices even inside grouped rows and on inverted mappings.
1712    /// Identical execution delta must not hide opposite price directions.
1713    #[test]
1714    fn price_spines_identify_open_close_and_direction_without_covering_values() {
1715        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1716        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1717        for price in [100.0, 100.25, 100.5, 100.75, 101.0] {
1718            flow.push(&Tick::new(time, price, 3.0, Some(OrderSide::Buy)))
1719                .unwrap();
1720        }
1721        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1722        for spacing in [60.0, 3.0] {
1723            for inverted in [false, true] {
1724                for (open, close) in [(100.25, 100.75), (100.75, 100.25), (100.5, 100.5)] {
1725                    let map = ChartMapping::new(
1726                        rect,
1727                        spacing,
1728                        0,
1729                        0,
1730                        0.0,
1731                        PriceMap::new(
1732                            PriceTransform::default(),
1733                            PriceRange::new(99.0, 103.0),
1734                            0.0,
1735                            160.0,
1736                            inverted,
1737                        ),
1738                    );
1739                    let bars = [Bar::new(time, open, 101.0, 100.0, close, 15.0)];
1740                    let config = FootprintConfig::default();
1741                    let mut output = egui::Context::default().run_ui(
1742                        egui::RawInput {
1743                            screen_rect: Some(rect),
1744                            ..Default::default()
1745                        },
1746                        |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1747                    );
1748                    output.textures_delta.clear();
1749                    let lines: Vec<_> = output
1750                        .shapes
1751                        .iter()
1752                        .filter_map(|s| match &s.shape {
1753                            Shape::LineSegment { points, stroke } => Some((points, stroke)),
1754                            _ => None,
1755                        })
1756                        .collect();
1757                    assert_eq!(
1758                        lines.len(),
1759                        4,
1760                        "price spine and body with opening and closing ticks"
1761                    );
1762                    // y(P) = (103 - P) * 40; inversion gives 160 - y(P).
1763                    let y = |price: f64| {
1764                        let y = (103.0 - price) as f32 * 40.0;
1765                        if inverted { 160.0 - y } else { y }
1766                    };
1767                    assert_eq!(lines[0].0.map(|p| p.y), [y(101.0), y(100.0)]);
1768                    assert_eq!(lines[2].0.map(|p| p.y), [y(open); 2]);
1769                    assert_eq!(lines[3].0.map(|p| p.y), [y(close); 2]);
1770                    assert!(lines[2].0[0].x < lines[2].0[1].x);
1771                    assert!(lines[3].0[0].x < lines[3].0[1].x);
1772                    let color = if close > open {
1773                        config.ask_color
1774                    } else if close < open {
1775                        config.bid_color
1776                    } else {
1777                        config.text_color
1778                    };
1779                    assert!(lines.iter().all(|(_, stroke)| stroke.color == color));
1780                    for shape in &output.shapes {
1781                        if let Shape::Text(text) = &shape.shape {
1782                            assert!(text.pos.x > lines[3].0[1].x);
1783                        }
1784                    }
1785                }
1786            }
1787        }
1788    }
1789
1790    /// A column keeps its largest values at detail zoom and switches all its numbers off
1791    /// together when even compact text is too wide. POC strokes must not dominate the overview.
1792    #[test]
1793    fn detail_columns_keep_large_values_and_overview_preserves_fills() {
1794        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1795        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1796        for (price, bid, ask) in [
1797            (100.0, 1234.0, 3456.0),
1798            (100.25, 1.0, 2.0),
1799            (100.5, 3456.0, 1234.0),
1800        ] {
1801            for (size, side) in [(bid, OrderSide::Sell), (ask, OrderSide::Buy)] {
1802                flow.push(&Tick::new(time, price, size, Some(side)))
1803                    .unwrap();
1804            }
1805        }
1806        let bars = [Bar::new(time, 100.0, 100.5, 100.0, 100.5, 9383.0)];
1807        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1808        for mode in [FootprintMode::BidAsk, FootprintMode::DeltaVolume] {
1809            for (spacing, numbers, outlines, clipped) in [
1810                (60.0, 3, 1, false),
1811                (57.0, 3, 1, false),
1812                (22.0, 0, 1, false),
1813                (3.0, 0, 0, false),
1814                (60.0, 3, 1, true),
1815            ] {
1816                let mut visible = flow.clone();
1817                let mut bars = bars.clone();
1818                if clipped {
1819                    // A print cut by the top edge must not suppress the fully visible numbers.
1820                    visible
1821                        .push(&Tick::new(time, 102.0, 1e20, Some(OrderSide::Buy)))
1822                        .unwrap();
1823                    bars[0].high = 102.0;
1824                    bars[0].close = 102.0;
1825                    bars[0].volume += 1e20;
1826                }
1827                let map = ChartMapping::new(
1828                    rect,
1829                    spacing,
1830                    0,
1831                    0,
1832                    0.0,
1833                    PriceMap::new(
1834                        PriceTransform::default(),
1835                        PriceRange::new(99.0, 102.0),
1836                        0.0,
1837                        160.0,
1838                        false,
1839                    ),
1840                );
1841                let config = FootprintConfig {
1842                    modes: vec![mode],
1843                    auto_row_height: None,
1844                    ..Default::default()
1845                };
1846                let mut output = egui::Context::default().run_ui(
1847                    egui::RawInput {
1848                        screen_rect: Some(rect),
1849                        ..Default::default()
1850                    },
1851                    |ui| render_footprint(ui.painter(), &map, &bars, &visible, &config),
1852                );
1853                output.textures_delta.clear();
1854                let texts: Vec<_> = output
1855                    .shapes
1856                    .iter()
1857                    .filter_map(|s| match &s.shape {
1858                        Shape::Text(t) => Some(t),
1859                        _ => None,
1860                    })
1861                    .collect();
1862                assert_eq!(texts.len(), numbers, "{mode:?}, spacing {spacing}");
1863                for text in texts {
1864                    assert!(
1865                        text.pos.x >= map.idx_to_x(0) - spacing * 0.46
1866                            && text.pos.x + text.galley.size().x
1867                                <= map.idx_to_x(0) + spacing * 0.46,
1868                        "execution values stay inside their column"
1869                    );
1870                    assert!(
1871                        text.galley
1872                            .job
1873                            .sections
1874                            .iter()
1875                            .all(|section| section.format.font_id.size >= 8.0)
1876                    );
1877                }
1878                assert_eq!(output.shapes.iter().filter(|s| matches!(&s.shape, Shape::Rect(r) if r.stroke.color == config.text_color)).count(), outlines, "POC at {spacing}");
1879                assert!(
1880                    output
1881                        .shapes
1882                        .iter()
1883                        .any(|s| matches!(&s.shape, Shape::Mesh(m) if !m.is_empty()))
1884                );
1885            }
1886        }
1887    }
1888
1889    /// Guards the number and colour choices: abbreviation switches units before one decimal
1890    /// would round up to 1000, a hidden zero leaves only the side that traded, the dominant
1891    /// side is the only number of its cell, delta opacity follows the share of the bar's largest
1892    /// delta, and font limits keep numbers readable whatever the host asks for.
1893    #[test]
1894    fn number_colour_and_font_choices_follow_their_documented_rules() {
1895        let row = |bid, ask, unknown| FootprintLevel {
1896            level: 0,
1897            bid,
1898            ask,
1899            unknown,
1900        };
1901        let config = |abbreviate, show_zero| FootprintConfig {
1902            abbreviate,
1903            show_zero,
1904            ..Default::default()
1905        };
1906        #[rustfmt::skip]
1907        let texts = [
1908            (FootprintMode::Volume, row(0.0, 1234.0, 0.0), config(true, true), "1.2K"),
1909            (FootprintMode::Volume, row(0.0, 1234.0, 0.0), config(false, true), "1234"),
1910            (FootprintMode::Volume, row(0.0, 999.0, 0.0), config(true, true), "999"),
1911            (FootprintMode::Volume, row(0.0, 1000.0, 0.0), config(true, true), "1K"),
1912            (FootprintMode::Volume, row(0.0, 999_949.0, 0.0), config(true, true), "999.9K"),
1913            (FootprintMode::Volume, row(0.0, 999_950.0, 0.0), config(true, true), "1M"),
1914            (FootprintMode::Volume, row(0.0, 3_400_000.0, 0.0), config(true, true), "3.4M"),
1915            (FootprintMode::Delta, row(2500.0, 0.0, 0.0), config(true, true), "-2.5K"),
1916            (FootprintMode::BidAsk, row(0.0, 7.0, 0.0), config(false, false), " × 7"),
1917            (FootprintMode::BidAsk, row(0.0, 0.0, 4.0), config(false, false), ""),
1918            (FootprintMode::BidAsk, row(0.0, 0.0, 4.0), config(false, true), "0 × 0"),
1919            (FootprintMode::Delta, row(3.0, 3.0, 0.0), config(false, false), ""),
1920            (FootprintMode::DominantSide, row(5.0, 3.0, 0.0), config(false, true), "5"),
1921            (FootprintMode::DominantSide, row(2.0, 3.0, 9.0), config(false, true), "3"),
1922        ];
1923        for (mode, row, config, expected) in texts {
1924            assert_eq!(
1925                cell_text(mode, &row, config.abbreviate, config.show_zero),
1926                expected,
1927                "{mode:?} {row:?}"
1928            );
1929        }
1930        let bar = FootprintBar {
1931            time: "2026-01-05T10:00:00Z".parse().unwrap(),
1932            levels: BTreeMap::new(),
1933            bid: 0.0,
1934            ask: 0.0,
1935            unknown: 0.0,
1936            min_delta: 0.0,
1937            max_delta: 0.0,
1938        };
1939        let colors = |color_by, row: FootprintLevel| {
1940            let config = FootprintConfig {
1941                color_by,
1942                ..Default::default()
1943            };
1944            (cell_color(&bar, &row, 10.0, 6.0, &config), config)
1945        };
1946        let (color, config) = colors(FootprintColor::DeltaOpacity, row(2.0, 8.0, 0.0));
1947        assert_eq!(color, config.ask_color.gamma_multiply(1.0));
1948        let (color, config) = colors(FootprintColor::DeltaOpacity, row(5.0, 2.0, 0.0));
1949        assert_eq!(color, config.bid_color.gamma_multiply(0.5));
1950        let (color, config) = colors(FootprintColor::DeltaOpacity, row(4.0, 4.0, 1.0));
1951        assert_eq!(color, config.neutral_color);
1952        let (color, config) = colors(FootprintColor::Fixed, row(2.0, 8.0, 0.0));
1953        assert_eq!(color, config.neutral_color);
1954        assert_eq!(font_limits([4.0, 100.0]), [8.0, 24.0]);
1955        assert_eq!(font_limits([18.0, 12.0]), [12.0, 18.0]);
1956        assert_eq!(font_limits([f32::NAN, 12.0]), [10.0, 12.0]);
1957    }
1958
1959    /// Guards the pullback rule against hand-worked bars: levels 100, 100.25 and 100.5 are keys
1960    /// 400 to 402. A bar that closed up selects the close row to the high, one that closed down
1961    /// the low to the close row, one that closed on its extreme row none, and a close outside the
1962    /// executed rows is held to them.
1963    #[test]
1964    fn the_pullback_range_runs_from_the_close_to_the_extreme_it_left() {
1965        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1966        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1967        for price in [100.0, 100.25, 100.5] {
1968            flow.push(&Tick::new(time, price, 1.0, Some(OrderSide::Buy)))
1969                .unwrap();
1970        }
1971        for (open, close, expected) in [
1972            (100.0, 100.25, Some((401, 402))),
1973            (100.5, 100.25, Some((400, 401))),
1974            (100.0, 100.5, None),
1975            (100.5, 100.0, None),
1976            (100.0, 99.0, None),
1977            (100.0, 101.0, None),
1978        ] {
1979            let bar = Bar::new(time, open, 100.5, 100.0, close, 3.0);
1980            assert_eq!(pullback_range(&bar, &flow), expected, "{open} to {close}");
1981        }
1982    }
1983
1984    /// Guards that the painted study annotations follow their public state: an unvisited zone
1985    /// keeps its colour, a revisited or expired one is dimmed, and a buy marker is a triangle
1986    /// above the bar's top row.
1987    #[cfg(feature = "studies")]
1988    #[test]
1989    fn studies_are_painted_as_their_state_and_kind_say() {
1990        use crate::studies::{FootprintStudy, FootprintStudyKind, FootprintStudyState};
1991        let time = "2026-01-05T10:00:00Z".parse().unwrap();
1992        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1993        flow.push(&Tick::new(time, 100.0, 1.0, Some(OrderSide::Buy)))
1994            .unwrap();
1995        let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 1.0)];
1996        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1997        let map = ChartMapping::new(
1998            rect,
1999            60.0,
2000            0,
2001            0,
2002            0.0,
2003            PriceMap::new(
2004                PriceTransform::default(),
2005                PriceRange::new(99.0, 102.0),
2006                0.0,
2007                160.0,
2008                false,
2009            ),
2010        );
2011        let study = |kind, state, low| FootprintStudy {
2012            start: time,
2013            end: Some(time),
2014            low,
2015            high: low,
2016            kind,
2017            state,
2018        };
2019        let studies = [
2020            study(
2021                FootprintStudyKind::StackedAsk,
2022                FootprintStudyState::Unvisited,
2023                400,
2024            ),
2025            study(
2026                FootprintStudyKind::StackedAsk,
2027                FootprintStudyState::Revisited,
2028                401,
2029            ),
2030            study(
2031                FootprintStudyKind::StackedAsk,
2032                FootprintStudyState::Expired,
2033                402,
2034            ),
2035            study(
2036                FootprintStudyKind::DeltaAsk,
2037                FootprintStudyState::Local,
2038                400,
2039            ),
2040        ];
2041        let config = FootprintConfig::default();
2042        let ctx = egui::Context::default();
2043        let mut output = ctx.run_ui(
2044            egui::RawInput {
2045                screen_rect: Some(rect),
2046                ..Default::default()
2047            },
2048            |ui| render_footprint_studies(ui.painter(), &map, &bars, &flow, &studies, &config),
2049        );
2050        output.textures_delta.clear();
2051        let strokes: Vec<_> = output
2052            .shapes
2053            .iter()
2054            .filter_map(|shape| match &shape.shape {
2055                Shape::Rect(rect) if rect.stroke.width > 0.0 => Some(rect.stroke.color),
2056                _ => None,
2057            })
2058            .collect();
2059        let dimmed = config.ask_color.gamma_multiply(0.45);
2060        assert_eq!(strokes, [config.ask_color, dimmed, dimmed]);
2061        let top = map.price_to_y(100.125);
2062        let triangle = output.shapes.iter().find_map(|shape| match &shape.shape {
2063            Shape::Path(path) if path.fill == config.ask_color => Some(path.points.clone()),
2064            _ => None,
2065        });
2066        assert!(
2067            triangle.is_some_and(|points| points.len() == 3 && points.iter().all(|p| p.y < top)),
2068            "buy marker above the bar"
2069        );
2070        // A local label anchored beside the second bar used to cover the first bar's values.
2071        let next = time + chrono::TimeDelta::minutes(1);
2072        flow.push(&Tick::new(next, 100.0, 2.0, Some(OrderSide::Buy)))
2073            .unwrap();
2074        let bars = [
2075            bars[0].clone(),
2076            Bar::new(next, 100.0, 100.0, 100.0, 100.0, 2.0),
2077        ];
2078        let local = FootprintStudy {
2079            start: next,
2080            end: Some(next),
2081            low: 400,
2082            high: 400,
2083            kind: FootprintStudyKind::SinglePrint,
2084            state: FootprintStudyState::Local,
2085        };
2086        for spacing in [60.0, 3.0] {
2087            let map = ChartMapping {
2088                bar_spacing: spacing,
2089                base_idx: 1,
2090                ..map.clone()
2091            };
2092            let mut output = ctx.run_ui(
2093                egui::RawInput {
2094                    screen_rect: Some(rect),
2095                    ..Default::default()
2096                },
2097                |ui| {
2098                    render_footprint(ui.painter(), &map, &bars, &flow, &config);
2099                    render_footprint_studies(
2100                        ui.painter(),
2101                        &map,
2102                        &bars,
2103                        &flow,
2104                        &[
2105                            local.clone(),
2106                            FootprintStudy {
2107                                kind: FootprintStudyKind::VolumeCluster,
2108                                ..local.clone()
2109                            },
2110                            FootprintStudy {
2111                                kind: FootprintStudyKind::DeltaPoc,
2112                                ..local.clone()
2113                            },
2114                            FootprintStudy {
2115                                kind: FootprintStudyKind::DeltaAsk,
2116                                high: 401,
2117                                ..local.clone()
2118                            },
2119                            FootprintStudy {
2120                                kind: FootprintStudyKind::RatioAsk,
2121                                high: 401,
2122                                ..local.clone()
2123                            },
2124                            studies[3].clone(),
2125                        ],
2126                        &config,
2127                    );
2128                },
2129            );
2130            output.textures_delta.clear();
2131            let texts: Vec<_> = output
2132                .shapes
2133                .iter()
2134                .filter_map(|s| match &s.shape {
2135                    Shape::Text(t) => Some(t),
2136                    _ => None,
2137                })
2138                .collect();
2139            for (index, caption) in texts
2140                .iter()
2141                .enumerate()
2142                .filter(|(_, t)| ["Single", "Cluster", "Delta POC"].contains(&t.galley.text()))
2143            {
2144                let bounds = Rect::from_min_size(caption.pos, caption.galley.size());
2145                assert!(
2146                    texts.iter().enumerate().all(|(other, t)| other == index
2147                        || !bounds.intersects(Rect::from_min_size(t.pos, t.galley.size()))),
2148                    "caption over execution values"
2149                );
2150                for shape in &output.shapes {
2151                    if let Shape::Path(path) = &shape.shape {
2152                        assert!(
2153                            !bounds.intersects(Rect::from_points(&path.points)),
2154                            "caption over threshold marker"
2155                        );
2156                    }
2157                }
2158            }
2159            if spacing == 60.0 {
2160                let marker = output
2161                    .shapes
2162                    .iter()
2163                    .find_map(|s| match &s.shape {
2164                        Shape::Circle(circle) => Some(circle),
2165                        _ => None,
2166                    })
2167                    .expect("a caption without room leaves a marker");
2168                assert!(
2169                    marker.center.x > map.idx_to_x(1) + spacing * 0.46,
2170                    "annotation marker uses the gap after the cells, away from the price spine"
2171                );
2172            }
2173            if spacing == 3.0 {
2174                assert!(
2175                    output
2176                        .shapes
2177                        .iter()
2178                        .all(|s| !matches!(&s.shape, Shape::Rect(r) if r.stroke.width > 0.0)),
2179                    "no historical boxes at overview zoom"
2180                );
2181                for shape in &output.shapes {
2182                    if let Shape::Path(path) = &shape.shape {
2183                        let (lo, hi) = path
2184                            .points
2185                            .iter()
2186                            .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
2187                                (lo.min(p.x), hi.max(p.x))
2188                            });
2189                        assert!(
2190                            hi - lo <= spacing * 0.8,
2191                            "overview triangle stays within its bar"
2192                        );
2193                    }
2194                }
2195            }
2196        }
2197        let map = ChartMapping { base_idx: 1, ..map };
2198        let at = pos2(map.idx_to_x(1), map.price_to_y(100.0));
2199        let mut details = false;
2200        // Tooltips need a sizing frame before their text is painted.
2201        for _ in 0..3 {
2202            let mut output = ctx.run_ui(
2203                egui::RawInput {
2204                    screen_rect: Some(rect),
2205                    events: vec![egui::Event::PointerMoved(at)],
2206                    ..Default::default()
2207                },
2208                |ui| {
2209                    render_footprint_studies(
2210                        ui.painter(),
2211                        &map,
2212                        &bars,
2213                        &flow,
2214                        std::slice::from_ref(&local),
2215                        &config,
2216                    )
2217                },
2218            );
2219            output.textures_delta.clear();
2220            details |= output.shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.text().starts_with("Single: 100 to 100")));
2221        }
2222        assert!(details, "compact annotations retain hover details");
2223    }
2224}