1use 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 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 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}