Skip to main content

forge/items/
charts.rs

1//! Per-spec horizontal bar chart for the items report.
2
3use anyhow::{Context, Result};
4use charming::{
5    Chart,
6    component::{
7        Aria, Axis, Grid, Toolbox,
8        toolbox::{DataView, Feature, Restore, SaveAsImage},
9    },
10    datatype::CompositeValue,
11    element::{
12        AxisLabel, AxisPointer, AxisPointerType, AxisType, Color, ColorBy, Emphasis, Label,
13        LabelPosition, NameLocation, Tooltip, Trigger,
14    },
15    series::Bar,
16};
17use wowlab_types::constants::HUNDRED;
18
19use super::types::{ItemCell, ItemMatrix, SpecRow};
20
21const AXIS_NAME_GAP: f64 = 28.0;
22const Y_AXIS_LABEL_FONT_SIZE: f64 = 11.0;
23const BAR_LABEL_FONT_SIZE: f64 = 12.0;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26#[non_exhaustive]
27pub(super) enum Mode {
28    Pct,
29    Abs,
30}
31
32impl Mode {
33    fn axis_name(self) -> &'static str {
34        match self {
35            Mode::Pct => "% DPS gain over baseline",
36            Mode::Abs => "Δ DPS over baseline",
37        }
38    }
39
40    fn label_format(self) -> &'static str {
41        match self {
42            Mode::Pct => "{c}%",
43            Mode::Abs => "{c}",
44        }
45    }
46}
47
48fn round2(v: f64) -> f64 {
49    (v * HUNDRED).round() / HUNDRED
50}
51
52fn to_json(chart: &Chart) -> Result<String> {
53    let mut value: serde_json::Value =
54        serde_json::to_value(chart).context("failed to serialize items chart")?;
55    // Charming 0.6 lacks setters for disabling the default label halo.
56
57    if let Some(series) = value.get_mut("series").and_then(|s| s.as_array_mut()) {
58        for s in series.iter_mut() {
59            if let Some(label) = s
60                .as_object_mut()
61                .and_then(|m| m.get_mut("label"))
62                .and_then(|v| v.as_object_mut())
63            {
64                label.insert("textBorderWidth".into(), serde_json::json!(0));
65                label.insert("textBorderColor".into(), serde_json::json!("transparent"));
66                label.insert("textShadowBlur".into(), serde_json::json!(0));
67            }
68        }
69    }
70
71    serde_json::to_string(&value).context("failed to re-serialize items chart")
72}
73
74pub(super) fn spec_bar_chart(matrix: &ItemMatrix, spec: &SpecRow, mode: Mode) -> Result<String> {
75    let mut ranked: Vec<(usize, f64)> = spec
76        .cells
77        .iter()
78        .enumerate()
79        .filter_map(|(i, c)| match c {
80            ItemCell::Ok { delta, pct, .. } => {
81                let v = match mode {
82                    Mode::Pct => *pct,
83                    Mode::Abs => *delta,
84                };
85
86                Some((i, round2(v)))
87            }
88            ItemCell::Error(_) => None,
89        })
90        .collect();
91
92    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
93
94    let labels: Vec<String> = ranked
95        .iter()
96        .map(|(idx, _)| {
97            // BOUNDS: idx enumerates spec.cells, built in lockstep with matrix.items.
98            matrix.items[*idx].key.clone()
99        })
100        .collect();
101    let values: Vec<f64> = ranked.iter().map(|(_, v)| *v).collect();
102
103    let chart = Chart::new()
104        .aria(Aria::new().enabled(true))
105        .tooltip(
106            Tooltip::new()
107                .trigger(Trigger::Axis)
108                .axis_pointer(AxisPointer::new().type_(AxisPointerType::Shadow)),
109        )
110        .toolbox(
111            Toolbox::new().right("0%").top("0%").feature(
112                Feature::new()
113                    .save_as_image(SaveAsImage::new())
114                    .restore(Restore::new())
115                    .data_view(DataView::new().read_only(true)),
116            ),
117        )
118        .grid(
119            Grid::new()
120                .contain_label(true)
121                .left("2%")
122                .right("8%")
123                .bottom("12%")
124                .top("10%"),
125        )
126        .x_axis(
127            Axis::new()
128                .type_(AxisType::Value)
129                .name(mode.axis_name())
130                .name_location(NameLocation::Middle)
131                .name_gap(AXIS_NAME_GAP),
132        )
133        .y_axis(
134            Axis::new()
135                .type_(AxisType::Category)
136                .inverse(true)
137                .axis_label(AxisLabel::new().font_size(Y_AXIS_LABEL_FONT_SIZE))
138                .data(labels),
139        )
140        .series(
141            Bar::new()
142                .name(mode.axis_name())
143                .color_by(ColorBy::Data)
144                .label(
145                    Label::new()
146                        .show(true)
147                        .position(LabelPosition::Right)
148                        .color(Color::Value("oklch(0.985 0.005 270)".into()))
149                        .font_size(BAR_LABEL_FONT_SIZE)
150                        .font_weight("600")
151                        .formatter(mode.label_format())
152                        .shadow_blur(0.0)
153                        .border_width(0.0),
154                )
155                .emphasis(Emphasis::new().disabled(true))
156                .bar_width(CompositeValue::String("60%".into()))
157                .data(values),
158        );
159
160    to_json(&chart)
161}