Skip to main content

forge/items/
report.rs

1// #t(file: rust_alloc_in_loop) CLI binary, building markup in loops is fine.
2
3//! Items matrix report: console summary + HTML overview using the shared shell.
4
5use anyhow::{Context, Result};
6use maud::{Markup, html};
7use wowlab_common::output;
8use wowlab_fs::{artifact::GeneratedTextFile, path::Path};
9use wowlab_types::constants::HUNDRED;
10
11use super::{
12    charts,
13    types::{ItemCell, ItemMatrix, ItemMeta},
14};
15use crate::report::{Page, chart_box};
16
17const ITEMS_JS: &str = include_str!("report.js");
18const TOP_N_CONSOLE: usize = 5;
19const TIER_BEST_PCT: f64 = 5.0;
20const TIER_GOOD_PCT: f64 = 1.5;
21
22fn item_link(item: &ItemMeta) -> Markup {
23    // #t(rust_hardcoded_url) wowlab.gg inspect URL is the canonical link surface.
24    let href = format!("https://wowlab.gg/inspect/item/{}", item.id);
25
26    html! {
27        a href=(href) target="_blank" rel="noopener" { (item.key) }
28    }
29}
30
31fn ilvl_label(item: &ItemMeta) -> String {
32    if item.item_level > 0 {
33        item.item_level.to_string()
34    } else {
35        "?".to_string()
36    }
37}
38
39fn distinct_ilvls(matrix: &ItemMatrix) -> Vec<i32> {
40    let mut levels: Vec<i32> = matrix
41        .items
42        .iter()
43        .map(|i| i.item_level)
44        .filter(|&l| l > 0)
45        .collect();
46
47    levels.sort_unstable();
48    levels.dedup();
49
50    levels
51}
52
53pub(super) fn print_console_summary(matrix: &ItemMatrix) {
54    output::blank();
55    output::header("Per-spec top contributors");
56
57    for spec in &matrix.specs {
58        let mut ranked: Vec<(usize, f64)> = spec
59            .cells
60            .iter()
61            .enumerate()
62            .filter_map(|(i, c)| match c {
63                ItemCell::Ok { delta, .. } => Some((i, *delta)),
64                ItemCell::Error(_) => None,
65            })
66            .collect();
67
68        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
69
70        output::subheader(&format!(
71            "{} (baseline {:.0} DPS)",
72            spec.slug, spec.baseline_dps
73        ));
74
75        for (idx, delta) in ranked.iter().take(TOP_N_CONSOLE) {
76            // BOUNDS: idx enumerates spec.cells, built in lockstep with matrix.items.
77            let item = &matrix.items[*idx];
78            let pct = if spec.baseline_dps > 0.0 {
79                (delta / spec.baseline_dps) * HUNDRED
80            } else {
81                0.0
82            };
83
84            output::detail(&format!(
85                "  {:<32} {:>10} ({:+.2}%)",
86                item.key,
87                format!("{delta:+.0}"),
88                pct,
89            ));
90        }
91    }
92}
93
94pub(super) fn generate(output_path: &Path, matrix: &ItemMatrix) -> Result<()> {
95    let mut page = Page::new(
96        "Item Value Matrix",
97        "Per-spec DPS contribution for every registered trinket",
98    )
99    .footer_command("cargo forge items")
100    .header_extras(render_header_controls(matrix))
101    .tab("spec", "Spec")
102    .tab("matrix", "Matrix")
103    .section("spec", render_spec_view(matrix))
104    .section("matrix", render_matrix(matrix));
105
106    let scenarios = ["patchwerk"];
107
108    for spec in &matrix.specs {
109        for (mode, mode_key) in [(charts::Mode::Pct, "pct"), (charts::Mode::Abs, "abs")] {
110            for scenario in scenarios {
111                let json = charts::spec_bar_chart(matrix, spec, mode).with_context(|| {
112                    format!(
113                        "failed to build chart for {} ({mode_key}, {scenario})",
114                        spec.slug
115                    )
116                })?;
117
118                page = page.data_script(
119                    format!("items-bars-{mode_key}-{}-{scenario}", spec.slug),
120                    json,
121                );
122            }
123        }
124    }
125
126    let id_map: serde_json::Value = matrix
127        .items
128        .iter()
129        .map(|i| (i.key.clone(), serde_json::json!(i.id)))
130        .collect::<serde_json::Map<_, _>>()
131        .into();
132
133    page = page.data_script("items-name-to-id", id_map.to_string());
134
135    page = page.extra_js(ITEMS_JS);
136    let rendered = page.render();
137
138    GeneratedTextFile::new(output_path, &rendered)
139        .persist()
140        .with_context(|| format!("failed to write report to {}", output_path.display()))?;
141
142    Ok(())
143}
144
145fn render_spec_view(matrix: &ItemMatrix) -> Markup {
146    let first = matrix.specs.first().map_or("", |s| s.slug.as_str());
147
148    html! {
149        @for spec in &matrix.specs {
150            div class="spec-view" data-spec=(spec.slug.as_str()) hidden[spec.slug != first] {
151                div class="columns is-variable is-3" {
152                    div class="column is-two-thirds" {
153                        (chart_box(
154                            &format!("{} — baseline {:.0} DPS", spec.slug, spec.baseline_dps),
155                            "chart-tall",
156                            &format!("items-bars-{}", spec.slug),
157                        ))
158                    }
159                    div class="column is-one-third" {
160                        (render_spec_table(matrix, spec))
161                    }
162                }
163            }
164        }
165    }
166}
167
168fn render_spec_table(matrix: &ItemMatrix, spec: &super::types::SpecRow) -> Markup {
169    let ranked = ranked_cells(spec);
170    let caption = format!("{} item ranking by Δ DPS", spec.slug);
171
172    html! {
173        div class="box spec-detail" {
174            h3 class="chart-label" { "Detail (sorted by Δ DPS)" }
175            div class="table-container" {
176                table class="table is-narrow is-hoverable is-fullwidth" {
177                    caption class="is-sr-only" { (caption) }
178                    thead {
179                        tr {
180                            th scope="col" { "Item" }
181                            th scope="col" class="has-text-right" { "iLvl" }
182                            th scope="col" class="has-text-right" { "Δ DPS" }
183                            th scope="col" class="has-text-right" { "%" }
184                        }
185                    }
186                    tbody {
187                        @for (idx, _dps, delta, pct) in ranked.iter() {
188                            @let item = &matrix.items[*idx];
189                            tr class="ilvl-row" data-ilvl=(item.item_level) {
190                                td { (item_link(item)) }
191                                td class="has-text-right" { (ilvl_label(item)) }
192                                td class="has-text-right" { (format!("{delta:+.0}")) }
193                                td class=(format!("has-text-right {}", tier_class(*pct))) {
194                                    (format!("{pct:+.2}%"))
195                                }
196                            }
197                        }
198                    }
199                }
200            }
201        }
202    }
203}
204
205fn render_matrix(matrix: &ItemMatrix) -> Markup {
206    html! {
207        div class="box" {
208            h3 class="chart-label" {
209                (format!(
210                    "{} items x {} specs ({} iterations, {}s fights)",
211                    matrix.items.len(),
212                    matrix.specs.len(),
213                    matrix.iterations,
214                    matrix.duration_secs,
215                ))
216            }
217            div class="table-container" {
218                table class="table is-narrow is-hoverable matrix-table" {
219                    caption class="is-sr-only" {
220                        "Items vs specs DPS contribution matrix. Item names are rows; specs are rotated column headers."
221                    }
222                    thead {
223                        tr {
224                            th scope="col" { "Item" }
225                            th scope="col" class="has-text-right" { "iLvl" }
226                            @for spec in &matrix.specs {
227                                th scope="col" class="has-text-right matrix-spec" { (spec.slug) }
228                            }
229                        }
230                    }
231                    tbody {
232                        @for (i, item) in matrix.items.iter().enumerate() {
233                            tr class="ilvl-row" data-ilvl=(item.item_level) {
234                                td class="matrix-item" { (item_link(item)) }
235                                td class="has-text-right matrix-item" { (ilvl_label(item)) }
236                                @for spec in &matrix.specs {
237                                    @let cell = &spec.cells[i];
238                                    (matrix_cell(cell, spec.baseline_dps))
239                                }
240                            }
241                        }
242                    }
243                }
244            }
245        }
246    }
247}
248
249fn tier_class(pct: f64) -> &'static str {
250    if pct >= TIER_BEST_PCT {
251        "tier-best"
252    } else if pct >= TIER_GOOD_PCT {
253        "tier-good"
254    } else if pct.abs() < TIER_GOOD_PCT {
255        "tier-meh"
256    } else {
257        "tier-bad"
258    }
259}
260
261fn matrix_cell(cell: &ItemCell, _baseline: f64) -> Markup {
262    match cell {
263        ItemCell::Ok { pct, .. } => {
264            let tier = tier_class(*pct);
265            let label = format!("{pct:+.1}%");
266
267            html! {
268                td class=(format!("matrix-cell {tier}")) title=(format!("{pct:+.2}%")) {
269                    (label)
270                }
271            }
272        }
273        ItemCell::Error(msg) => html! {
274            td class="matrix-cell tier-na" title=(msg.as_str()) { "err" }
275        },
276    }
277}
278
279fn render_header_controls(matrix: &ItemMatrix) -> Markup {
280    let first = matrix.specs.first().map_or("", |s| s.slug.as_str());
281    let levels = distinct_ilvls(matrix);
282
283    html! {
284        div class="is-flex is-flex-wrap-wrap is-align-items-center mb-3"
285            style="gap: 12px;" {
286            div class="control" {
287                div class="select is-small" {
288                    select #spec-select
289                        aria-label="Select spec"
290                        onchange="itemsSelectSpec(this.value)" {
291                        @for spec in &matrix.specs {
292                            option value=(spec.slug.as_str()) selected[spec.slug == first] {
293                                (format!("{} — baseline {:.0} DPS", spec.slug, spec.baseline_dps))
294                            }
295                        }
296                    }
297                }
298            }
299
300            div #scenario-group
301                class="buttons has-addons is-small mb-0"
302                role="group"
303                aria-label="Encounter scenario" {
304                button type="button" class="button is-small is-active"
305                    data-scenario="patchwerk"
306                    aria-pressed="true"
307                    onclick="itemsSelectScenario('patchwerk')" {
308                    "Patchwerk"
309                }
310            }
311
312            div #mode-group
313                class="buttons has-addons is-small mb-0"
314                role="group"
315                aria-label="Value display mode" {
316                button type="button" class="button is-small is-active"
317                    data-mode="pct"
318                    aria-pressed="true"
319                    onclick="itemsSelectMode('pct')" { "%" }
320                button type="button" class="button is-small"
321                    data-mode="abs"
322                    aria-pressed="false"
323                    onclick="itemsSelectMode('abs')" { "Δ DPS" }
324            }
325
326            div #ilvl-group
327                class="buttons has-addons is-small mb-0"
328                role="group"
329                aria-label="Item level filter" {
330                button type="button" class="button is-small is-active"
331                    data-ilvl="all"
332                    aria-pressed="true"
333                    onclick="itemsSelectIlvl('all')" { "All ilvl" }
334                @for lvl in &levels {
335                    button type="button" class="button is-small"
336                        data-ilvl=(lvl)
337                        aria-pressed="false"
338                        onclick=(format!("itemsSelectIlvl('{lvl}')")) {
339                        (lvl)
340                    }
341                }
342            }
343
344            div #limit-group
345                class="buttons has-addons is-small mb-0"
346                role="group"
347                aria-label="Show top N items" {
348                button type="button" class="button is-small"
349                    data-limit="5"
350                    aria-pressed="false"
351                    onclick="itemsSelectLimit('5')" { "Top 5" }
352                button type="button" class="button is-small"
353                    data-limit="10"
354                    aria-pressed="false"
355                    onclick="itemsSelectLimit('10')" { "Top 10" }
356                button type="button" class="button is-small is-active"
357                    data-limit="20"
358                    aria-pressed="true"
359                    onclick="itemsSelectLimit('20')" { "Top 20" }
360                button type="button" class="button is-small"
361                    data-limit="all"
362                    aria-pressed="false"
363                    onclick="itemsSelectLimit('all')" { "All" }
364            }
365        }
366    }
367}
368
369fn ranked_cells(spec: &super::types::SpecRow) -> Vec<(usize, f64, f64, f64)> {
370    let mut rows: Vec<(usize, f64, f64, f64)> = spec
371        .cells
372        .iter()
373        .enumerate()
374        .filter_map(|(i, c)| match c {
375            ItemCell::Ok { dps, delta, pct } => Some((i, *dps, *delta, *pct)),
376            ItemCell::Error(_) => None,
377        })
378        .collect();
379
380    rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
381
382    rows
383}