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