diff --git a/crates/sds-core/examples/heatmap.rs b/crates/sds-core/examples/heatmap.rs index 7df38e5..8e51920 100644 --- a/crates/sds-core/examples/heatmap.rs +++ b/crates/sds-core/examples/heatmap.rs @@ -5,16 +5,20 @@ //! //! cargo run --release -p sds-core --example heatmap -- /tmp/heatmap.html //! -//! Three boards by three power-mean exponents, nine maps on one page and one -//! colour scale across all of them. `plan/candidates.md` asks for the encoding: -//! **lightness carries the total** - how much is happening at a hex, both -//! directions together - and **hue carries the ratio**, our damage out against -//! their damage in. An additive blend of two colours would put "high in both" -//! and "middling in both" on the lightness axis alone, which is the axis the eye -//! compares worst. +//! Three boards by three power-mean exponents. `plan/candidates.md` asks for +//! the encoding: **lightness carries the total** - how much is happening at a +//! hex, both directions together - and **hue carries the ratio**, our damage out +//! against their damage in. An additive blend of two colours would put "high in +//! both" and "middling in both" on the lightness axis alone, which is the axis +//! the eye compares worst. //! -//! Each board also gets its routes: the stand each exponent picks, and the walk -//! the pathfinder actually took to it, from `Search::path_to`. +//! Both axes are **ranks**, pooled per channel across the three regimes of a +//! board - see [`sds_core::heatmap::Ranks`] for why the absolute values cannot +//! carry the hue. The absolute values stay in the tooltips. +//! +//! Each board gets two rows: the scored hexes with each enemy's reachable set +//! drawn as a hull, and then the same three heatmaps with the top five walks +//! the defence list would take drawn over them. //! //! Nothing here is tuned to make the picture look good. If a board comes out //! flat, that is the result, and the page says so. @@ -25,9 +29,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; -use sds_core::heatmap::{HexCell, HexMap, Scale, Shade}; +use sds_core::heatmap::{convex_hull, HexCell, HexMap, Ranks, Scale, Shade}; use sds_core::hex::{translated, Stand}; -use sds_core::pathfind::{MoveBoard, Search}; +use sds_core::pathfind::Search; use sds_core::stands::{score_stands, Params, Ranking, StandScore}; use sds_core::volley::VolleyCache; use sds_core::wire::Coord; @@ -36,63 +40,53 @@ mod common; use common::{Scene, BOARDS, ENEMIES, OUR_MP, START, THEIR_MP, TOP_K}; /// Pixels per unit of the board's own geometry, where a hex is 2 units tall. -const UNIT: f32 = 13.0; +const UNIT: f32 = 15.0; /// A hex's circumradius in those units: the flat-top hexagon whose centres sit /// at `((3x + 2)/sqrt(3), 2y + (x & 1) + 1)`. const RADIUS: f32 = 1.154_700_5; /// Drawn a shade smaller, so the grid reads as hexes rather than as a field. const INSET: f32 = 0.94; -const PAD: f32 = 6.0; +const PAD: f32 = 7.0; + +/// How many walks per regime the path panels draw. +const TOP_PATHS: usize = 5; /// One way of collapsing where the enemies might be. struct Regime { + /// The letter the panels are compared by. + letter: &'static str, /// The CSS class that gives this regime its colour, everywhere on the page. key: &'static str, name: &'static str, maths: &'static str, reads: &'static str, exponent: f32, - /// Stroke weight and dash, so three routes crossing the same hexes stay - /// three routes. - stroke: &'static str, - /// Pixels to shift this regime's line sideways off the hex centres, and the - /// radius of the ring on its chosen hex. Two regimes often pick the same - /// stand, and the route to a stand is one route - drawn on top of each - /// other they would read as one regime having decided. - spread: f32, - ring: f32, } const REGIMES: [Regime; 3] = [ Regime { + letter: "A", key: "r-min", name: "minimax", maths: "p \u{2192} -\u{221e}", reads: "they find our worst case", exponent: f32::NEG_INFINITY, - stroke: r##"stroke-width="2.6""##, - spread: -2.4, - ring: 0.66, }, Regime { + letter: "B", key: "r-mid", name: "middling", maths: "p = -1", reads: "they lean towards our worst case", exponent: -1.0, - stroke: r##"stroke-width="2.6" stroke-dasharray="7 5""##, - spread: 0.0, - ring: 0.50, }, Regime { + letter: "C", key: "r-mean", name: "mean", maths: "p = 1", reads: "they move without regard to us", exponent: 1.0, - stroke: r##"stroke-width="2.6" stroke-dasharray="2 5""##, - spread: 2.4, - ring: 0.34, }, ]; @@ -103,36 +97,27 @@ fn centre(hex: Coord) -> (f32, f32) { (cx * UNIT + PAD, cy * UNIT + PAD) } -/// The six corners of a flat-top hexagon, as an SVG points list. -fn corners(cx: f32, cy: f32, radius: f32) -> String { +/// The six corners of a flat-top hexagon, as `(x, y)` pairs. +fn corner_points(cx: f32, cy: f32, radius: f32) -> Vec<(f32, f32)> { (0..6) .map(|i| { let angle = std::f32::consts::PI / 3.0 * i as f32; - format!( - "{:.1},{:.1}", - cx + radius * angle.cos(), - cy + radius * angle.sin() - ) + (cx + radius * angle.cos(), cy + radius * angle.sin()) }) - .collect::>() - .join(" ") + .collect() } -/// What a hex is made of. The class carries the colour, so the page can be -/// read in either theme without the renderer knowing which one. -fn terrain_class(board: &MoveBoard, at: Coord) -> &'static str { - let hex = board.hex(at); - if hex.impassable { - "t-block" - } else if hex.depth > 0 { - "t-water" - } else if hex.terrain_mp >= 2 { - "t-heavy" - } else if hex.terrain_mp >= 1 { - "t-light" - } else { - "t-clear" - } +/// The same, as an SVG points list. +fn corners(cx: f32, cy: f32, radius: f32) -> String { + points_list(&corner_points(cx, cy, radius)) +} + +fn points_list(points: &[(f32, f32)]) -> String { + points + .iter() + .map(|(x, y)| format!("{x:.1},{y:.1}")) + .collect::>() + .join(" ") } /// A shade as two custom properties. The stylesheet turns them into a colour, @@ -150,7 +135,8 @@ fn extent(scene: &Scene) -> (f32, f32) { (right + RADIUS * UNIT + PAD, bottom + UNIT + PAD) } -/// The board itself: terrain, and the hexes we cannot stand in. +/// The bare grid. One neutral fill everywhere, because the whole fill now +/// belongs to the heat - terrain is drawn on top by [`terrain`]. fn ground(scene: &Scene, svg: &mut String) { for y in 0..scene.message.height { for x in 0..scene.message.width { @@ -158,35 +144,176 @@ fn ground(scene: &Scene, svg: &mut String) { let (cx, cy) = centre(at); let _ = write!( svg, - r##""##, - terrain_class(&scene.board, at), + r##""##, corners(cx, cy, RADIUS * UNIT * INSET), ); - let level = scene.board.hex(at).level; - if level != 0 { + } + } +} + +/// A conifer, drawn about a point: canopy and trunk. +fn tree(cx: f32, cy: f32, size: f32) -> String { + format!( + r##""##, + cx, + cy - size, + cx + size * 0.62, + cy + size * 0.28, + cx - size * 0.62, + cy + size * 0.28, + cx - size * 0.17, + cy + size * 0.28, + cx + size * 0.17, + cy + size * 0.28, + cx + size * 0.17, + cy + size, + cx - size * 0.17, + cy + size, + ) +} + +/// A stroke-only glyph, drawn twice: a thick halo in the page colour and the +/// line itself over it. A filled shape gets its halo from `paint-order`, but a +/// path with no fill has only the one stroke to give. +fn stroked(d: &str) -> String { + format!(r##""##) +} + +/// What is in a hex, drawn over the heat rather than instead of it. +/// +/// SVG shapes and nothing else. No emoji and no font glyph beyond the level's +/// digits: the page has to render the same everywhere, and it may not fetch a +/// font. +fn terrain(scene: &Scene, svg: &mut String) { + for y in 0..scene.message.height { + for x in 0..scene.message.width { + let at = Coord::new(x, y); + let hex = scene.board.hex(at); + let (cx, cy) = centre(at); + let below = cy + UNIT * 0.30; + if hex.impassable { + let r = UNIT * 0.26; + svg.push_str(&stroked(&format!( + "M{:.1},{:.1} L{:.1},{:.1} M{:.1},{:.1} L{:.1},{:.1}", + cx - r, + below - r, + cx + r, + below + r, + cx + r, + below - r, + cx - r, + below + r, + ))); + } else if hex.depth > 0 { + // Two waves, so depth 1 and a hex that merely looks blue are + // not the same mark. + let w = UNIT * 0.30; + for row in 0..hex.depth.min(3) { + let y = below - UNIT * 0.13 + row as f32 * UNIT * 0.20; + svg.push_str(&stroked(&format!( + "M{:.1},{:.1} q{:.1},{:.1} {:.1},0 t{:.1},0", + cx - w, + y, + w * 0.5, + -UNIT * 0.17, + w, + w, + ))); + } + } else if hex.terrain_mp >= 2 { + // Heavy woods: two trees. Light woods: one. Same shape, so the + // difference is a count rather than a colour. + svg.push_str(&tree(cx - UNIT * 0.20, below, UNIT * 0.24)); + svg.push_str(&tree(cx + UNIT * 0.20, below, UNIT * 0.24)); + } else if hex.terrain_mp >= 1 { + svg.push_str(&tree(cx, below, UNIT * 0.26)); + } + if hex.level != 0 { + // A plaque rather than a bare numeral: the fill under it is now + // the heat colour and can be any lightness. + let (px, py) = (cx + UNIT * 0.42, cy - UNIT * 0.34); let _ = write!( svg, - r##"{level}"##, - cx, - cy - RADIUS * UNIT * 0.40, + r##"{}"##, + px - UNIT * 0.24, + py - UNIT * 0.22, + UNIT * 0.48, + UNIT * 0.44, + px, + py + UNIT * 0.16, + hex.level, ); } } } } +/// Each enemy's `M` as a tinted hull. +/// +/// The hull of the **corners** of every hex the enemy can reach, not of their +/// centres, so the shape contains the hexes it is about rather than cutting +/// through the outer ring of them. Each successive hull is pulled a little +/// further in towards its own centroid, because three enemies four MP apart +/// produce hulls that share long stretches of edge and one drawn over another +/// reads as one enemy. +fn hulls(scene: &Scene, svg: &mut String) { + for (index, foe) in scene.foes().iter().enumerate() { + let hexes: BTreeSet<(i32, i32)> = foe + .may_be + .iter() + .map(|p| (p.stand.hex.x, p.stand.hex.y)) + .collect(); + let mut points: Vec<(f32, f32)> = Vec::new(); + for (x, y) in &hexes { + let (cx, cy) = centre(Coord::new(*x, *y)); + points.extend(corner_points(cx, cy, RADIUS * UNIT * INSET)); + } + let hull = convex_hull(&points); + if hull.len() < 3 { + continue; + } + let centroid = ( + hull.iter().map(|p| p.0).sum::() / hull.len() as f32, + hull.iter().map(|p| p.1).sum::() / hull.len() as f32, + ); + let pull = index as f32 * 2.2; + let drawn: Vec<(f32, f32)> = hull + .iter() + .map(|(x, y)| { + let (dx, dy) = (centroid.0 - x, centroid.1 - y); + let len = (dx * dx + dy * dy).sqrt().max(0.001); + (x + dx / len * pull, y + dy / len * pull) + }) + .collect(); + let _ = write!( + svg, + r##"enemy {} can reach {} hexes on {THEIR_MP} MP"##, + points_list(&drawn), + index + 1, + hexes.len(), + ); + } +} + /// Both sides' start hexes, drawn over whatever else is in them. fn starts(svg: &mut String) { - for (at, label, class) in - std::iter::once((START, "us", "us")).chain(ENEMIES.iter().map(|at| (*at, "E", "them"))) - { - let (cx, cy) = centre(at); + let (cx, cy) = centre(START); + let _ = write!( + svg, + r##"us"##, + corners(cx, cy, RADIUS * UNIT * INSET), + cx, + cy + 4.0, + ); + for (index, at) in ENEMIES.iter().enumerate() { + let (cx, cy) = centre(*at); let _ = write!( svg, - r##"{label}"##, + r##"E{}"##, corners(cx, cy, RADIUS * UNIT * INSET), cx, cy + 4.0, + index + 1, ); } } @@ -196,34 +323,51 @@ struct Panel<'a> { scene: &'a Scene, map: &'a HexMap, scale: Scale, + ranks: &'a Ranks, offence: BTreeSet<(i32, i32)>, defence: BTreeSet<(i32, i32)>, } -fn draw_heat(panel: &Panel<'_>) -> String { +/// The heat itself, shared by both rows. +fn heat_cells(panel: &Panel<'_>, svg: &mut String) { + for cell in panel.map.cells() { + let (cx, cy) = centre(cell.hex); + let _ = write!( + svg, + r##"{}"##, + shade_vars(panel.ranks.shade(cell)), + corners(cx, cy, RADIUS * UNIT * INSET), + describe(cell, panel.scale, panel.ranks), + ); + } +} + +/// Row one: the scored hexes, the enemies' reachable sets, and the top-K marks. +fn draw_heat(panel: &Panel<'_>, regime: &Regime) -> String { let (width, height) = extent(panel.scene); let mut svg = String::new(); let _ = write!( svg, - r##""##, - panel.scene.name, + r##""##, + panel.scene.name, regime.name, ); ground(panel.scene, &mut svg); + heat_cells(panel, &mut svg); + terrain(panel.scene, &mut svg); + hulls(panel.scene, &mut svg); for cell in panel.map.cells() { let (cx, cy) = centre(cell.hex); - let _ = write!( - svg, - r##"{}"##, - shade_vars(panel.scale.shade(cell)), - corners(cx, cy, RADIUS * UNIT * INSET * 0.80), - describe(cell, panel.scale), - ); let key = (cell.hex.x, cell.hex.y); let on_offence = panel.offence.contains(&key); let on_defence = panel.defence.contains(&key); if on_offence || on_defence { - svg.push_str(&mark(cx, cy, on_offence, on_defence)); + svg.push_str(&mark( + cx - UNIT * 0.42, + cy - UNIT * 0.30, + on_offence, + on_defence, + )); } } @@ -235,7 +379,7 @@ fn draw_heat(panel: &Panel<'_>) -> String { /// The top-K marks: a triangle up for offence, down for defence, a diamond for /// a hex on both lists. fn mark(cx: f32, cy: f32, offence: bool, defence: bool) -> String { - let r = RADIUS * UNIT * 0.32; + let r = UNIT * 0.26; let points = if offence && defence { format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", @@ -264,49 +408,67 @@ fn mark(cx: f32, cy: f32, offence: bool, defence: bool) -> String { } /// The tooltip on a hex we can stand in. -fn describe(cell: &HexCell, scale: Scale) -> String { - let shade = scale.shade(cell); +/// +/// Absolute damage first, then where that sits in the pool. The absolute pair +/// is how a reader checks the ranking is not lying, and the note on the page +/// says why "taken" is the larger of the two everywhere. +fn describe(cell: &HexCell, scale: Scale, ranks: &Ranks) -> String { + let shade = ranks.shade(cell); format!( - "({}, {}) deal {:.1} take {:.1} total {:.2} balance {:.2} {} stands", - cell.hex.x, cell.hex.y, cell.deal, cell.take, shade.total, shade.balance, cell.stands + "({}, {}) deal {:.1} of {:.1} max, rank {:.2} | take {:.1} of {:.1} max, rank {:.2} | total {:.2} lean {:.2} | {} stands", + cell.hex.x, + cell.hex.y, + cell.deal, + scale.deal_max, + ranks.deal_rank(cell), + cell.take, + scale.take_max, + ranks.take_rank(cell), + shade.total, + shade.balance, + cell.stands, ) } -/// One regime's pick, and the walk that reaches it. +/// One walk the ranking would take, and where it sits on the list. struct Route { - regime: usize, + rank: usize, stand: Stand, mp_spent: i32, hexes_moved: i32, hexes: Vec, } -/// The route map: plain terrain, and one line per regime. -fn draw_routes(scene: &Scene, routes: &[Route], label: &str) -> String { - let (width, height) = extent(scene); +/// Row two: the same heatmap, with this regime's top five walks over it. +fn draw_paths(panel: &Panel<'_>, regime: &Regime, routes: &[Route]) -> String { + let (width, height) = extent(panel.scene); let mut svg = String::new(); let _ = write!( svg, - r##""##, - scene.name, + r##""##, + panel.scene.name, regime.name, ); - ground(scene, &mut svg); - - for route in routes { - let regime = ®IMES[route.regime]; + ground(panel.scene, &mut svg); + heat_cells(panel, &mut svg); + terrain(panel.scene, &mut svg); + + // Weakest first, so the top pick is drawn last and nothing crosses over it. + for route in routes.iter().rev() { + let strength = 1.0 - (route.rank - 1) as f32 * 0.17; + let width = if route.rank == 1 { 3.6 } else { 2.1 }; + // Each rank on its own offset from the hex centres, so five routes + // through one hex are five lines. + let spread = (route.rank as f32 - 1.0) * 1.9; let points: Vec<(f32, f32)> = route.hexes.iter().map(|hex| centre(*hex)).collect(); - // Each segment shifted along its own normal, so three routes over the - // same hexes are three parallel lines rather than one. for pair in points.windows(2) { let ((x1, y1), (x2, y2)) = (pair[0], pair[1]); let (dx, dy) = (x2 - x1, y2 - y1); let length = (dx * dx + dy * dy).sqrt().max(0.001); - let (nx, ny) = (-dy / length * regime.spread, dx / length * regime.spread); + let (nx, ny) = (-dy / length * spread, dx / length * spread); let _ = write!( svg, - r##""##, + r##""##, regime.key, - regime.stroke, x1 + nx, y1 + ny, x2 + nx, @@ -319,16 +481,27 @@ fn draw_routes(scene: &Scene, routes: &[Route], label: &str) -> String { let (ax, ay) = centre(translated(route.stand.hex, route.stand.facing, 1)); let (dx, dy) = (ax - cx, ay - cy); let length = (dx * dx + dy * dy).sqrt().max(0.001); - let reach = RADIUS * UNIT * INSET * regime.ring; + let badge = if route.rank == 1 { 7.0 } else { 5.6 }; let _ = write!( svg, - r##""##, + r##""##, regime.key, - corners(cx, cy, reach), - cx + dx / length * reach, - cy + dy / length * reach, - cx + dx / length * (reach + RADIUS * UNIT * 0.34), - cy + dy / length * (reach + RADIUS * UNIT * 0.34), + cx + dx / length * badge, + cy + dy / length * badge, + cx + dx / length * (badge + UNIT * 0.44), + cy + dy / length * (badge + UNIT * 0.44), + ); + let _ = write!( + svg, + r##"{rank}rank {rank}: ({x}, {y}) f{facing}, {mp} MP, {hexes} hexes"##, + key = regime.key, + rank = route.rank, + x = route.stand.hex.x, + y = route.stand.hex.y, + facing = route.stand.facing, + mp = route.mp_spent, + hexes = route.hexes_moved, + ty = cy + 3.2, ); } @@ -337,11 +510,11 @@ fn draw_routes(scene: &Scene, routes: &[Route], label: &str) -> String { svg } -/// The two-axis key: hue across, lightness down. -fn legend(scale: Scale, brightest: f32) -> String { +/// The two-axis key: lean across, total down. +fn legend() -> String { let mut out = String::new(); out.push_str( - r##""##, + r##""##, ); let (steps, rows) = (9, 5); for row in 0..rows { @@ -353,31 +526,24 @@ fn legend(scale: Scale, brightest: f32) -> String { out, r##""##, shade_vars(Shade { total, balance }), - 46 + step * 30, - 18 + row * 22, + 48 + step * 30, + 20 + row * 22, ); } let _ = write!( out, - r##"{total:.2}"##, - 34 + row * 22 + r##"{total:.2}"##, + 36 + row * 22 ); } let _ = write!( out, - r##"all of it dealt by us -all of it taken by us -total -hue across · lightness down"##, + r##"ranks high to deal +ranks high to take +total +lean across · total down · both are ranks"##, ); out.push_str(""); - let _ = write!( - out, - r##"

Total 1.00 is a hex at both maxima: {:.1} damage dealt and {:.1} taken. -The brightest hex anywhere on this page is total {brightest:.2}. Nothing is normalised per map, so a -colour means the same thing on all nine.

"##, - scale.deal_max, scale.take_max, - ); out } @@ -388,27 +554,21 @@ fn escape(text: &str) -> String { /// A regime's name as a coloured chip, used wherever a regime is named. fn chip(regime: &Regime) -> String { format!( - r##"{} {}"##, - regime.key, regime.name, regime.maths + r##"{} {} {}"##, + regime.key, regime.letter, regime.name, regime.maths ) } -/// The stand each regime puts first on one of the two lists, with the walk that -/// reaches it. -fn routes_for( - scene: &Scene, - rankings: &[Ranking], - top: impl Fn(&Ranking) -> Option, -) -> Vec { +/// The top five stands on one of the two lists, with the walk to each. +fn routes_for(scene: &Scene, ranking: &Ranking, list: impl Fn(&Ranking) -> &[usize]) -> Vec { let mut routes: Vec = Vec::new(); - for (index, ranking) in rankings.iter().enumerate() { - let Some(at) = top(ranking) else { continue }; - let score = &ranking.scored[at]; + for (rank, at) in list(ranking).iter().take(TOP_PATHS).enumerate() { + let score = &ranking.scored[*at]; let Some(walk) = scene.search.path_to(score.reach.stand) else { continue; }; routes.push(Route { - regime: index, + rank: rank + 1, stand: score.reach.stand, mp_spent: score.reach.mp_spent, hexes_moved: score.reach.hexes_moved, @@ -418,24 +578,13 @@ fn routes_for( routes } -/// What the routes say in words, including which regimes agreed. -/// -/// Said plainly rather than drawn twice: two regimes choosing the same stand is -/// a result, and one line drawn over another would read as one regime. +/// One regime's five, as a list under its panel. fn routes_caption(routes: &[Route]) -> String { - let mut out = String::from(r##"
    "##); - let mut shared: BTreeMap<(i32, i32, i32), Vec> = BTreeMap::new(); - for route in routes { - shared - .entry((route.stand.hex.x, route.stand.hex.y, route.stand.facing)) - .or_default() - .push(route.regime); - } + let mut out = String::from(r##"
      "##); for route in routes { let _ = write!( out, - r##"
    1. {} walks to ({}, {}) f{} · {} MP, {} hexes
    2. "##, - chip(®IMES[route.regime]), + r##"
    3. ({}, {}) f{} · {} MP, {} hexes
    4. "##, route.stand.hex.x, route.stand.hex.y, route.stand.facing, @@ -443,25 +592,86 @@ fn routes_caption(routes: &[Route]) -> String { route.hexes_moved, ); } - out.push_str("
"); - let agreed: Vec = shared - .values() - .filter(|regimes| regimes.len() > 1) - .map(|regimes| { - regimes + out.push_str(""); + out +} + +/// Which stands more than one regime put in its top five, said in words. +/// +/// Said rather than drawn: three panels each own their lines, and a stand two +/// regimes both chose would otherwise be invisible - it would look like one +/// line in one panel and one line in another, with nothing joining them. +fn agreement(picks: &[Vec], list: &str) -> String { + let mut shared: BTreeMap<(i32, i32, i32), Vec<(usize, usize)>> = BTreeMap::new(); + for (regime, routes) in picks.iter().enumerate() { + for route in routes { + shared + .entry((route.stand.hex.x, route.stand.hex.y, route.stand.facing)) + .or_default() + .push((regime, route.rank)); + } + } + let sets: Vec> = picks + .iter() + .map(|routes| { + routes .iter() - .map(|at| REGIMES[*at].name) - .collect::>() - .join(" and ") + .map(|r| (r.stand.hex.x, r.stand.hex.y, r.stand.facing)) + .collect() }) .collect(); - if !agreed.is_empty() { + + let mut out = String::from(r##"

where the three agree

"##); + let mut lines: Vec = Vec::new(); + for (a, b) in [(0, 1), (0, 2), (1, 2)] { + let overlap = sets[a].intersection(&sets[b]).count(); + let same_order = picks[a] + .iter() + .zip(picks[b].iter()) + .all(|(x, y)| x.stand == y.stand) + && picks[a].len() == picks[b].len(); + lines.push(format!( + "
  • {} and {} share {overlap} of {TOP_PATHS}{}
  • ", + REGIMES[a].name, + REGIMES[b].name, + if same_order { + ", in the same order - the two panels are the same picture" + } else { + "" + }, + )); + } + let _ = write!(out, r##"
      {}
    "##, lines.concat()); + + let all_three: Vec = shared + .iter() + .filter(|(_, who)| who.len() == 3) + .map(|(stand, who)| { + format!( + "({}, {}) f{} at ranks {}", + stand.0, + stand.1, + stand.2, + who.iter() + .map(|(_, rank)| rank.to_string()) + .collect::>() + .join("/") + ) + }) + .collect(); + if all_three.is_empty() { + let _ = write!( + out, + r##"

    No stand is on all three {list} lists.

    "## + ); + } else { let _ = write!( out, - r##"

    {} pick the same stand, so one line covers both.

    "##, - agreed.join("; "), + r##"

    On all three {list} lists: {}. Ranks are given A/B/C.

    "##, + all_three.join("; "), ); } + out.push_str("
    "); out } @@ -478,8 +688,6 @@ fn main() { let scenes: Vec = BOARDS.iter().map(|name| Scene::build(name)).collect(); - // Every board at every exponent, scored before anything is drawn: the scale - // is shared across all nine, and it cannot be known until they all exist. let mut rankings: Vec> = Vec::new(); for scene in &scenes { let mover = scene.mover(); @@ -509,38 +717,28 @@ fn main() { .iter() .map(|row| row.iter().map(HexMap::of).collect()) .collect(); + // Absolute, for the tooltips and for saying what the two channels are + // worth. Ranks, pooled over one board's three regimes, for the colour. let scale = Scale::covering(maps.iter().flatten()); - let brightest = maps - .iter() - .flatten() - .flat_map(|map| map.cells()) - .map(|cell| scale.shade(cell).total) - .fold(0.0_f32, f32::max); + let ranks: Vec = maps.iter().map(Ranks::pooled).collect(); let mut body = String::new(); - body.push_str(&prologue()); - body.push_str(&format!( - r##"

    The colour

    -

    Two numbers per hex, and they are kept apart. Lightness carries the total: how much -damage passes through that hex in a turn, ours and theirs added. Hue carries the ratio: -blue where nearly all of it is dealt by us, red where nearly all of it is taken by us, and violet in -between, where both sides trade heavily. A hex that is quiet in both directions sits at the bottom of -the lightness ramp, where hue cannot be read at all, which is the honest place for a ratio of nothing -to nothing.

    -

    An additive blend of two colours was the obvious alternative and is the wrong one: it puts -“heavy in both” and “middling in both” on the lightness axis alone, and lightness -is the axis the eye compares worst.

    -
    {}
    "##, - legend(scale, brightest) - )); + body.push_str(&prologue(scale)); + body.push_str(&key_section()); body.push_str(&how_to_read()); for (index, scene) in scenes.iter().enumerate() { let row = &rankings[index]; let board_maps = &maps[index]; + let board_ranks = &ranks[index]; let _ = write!( body, - r##"

    {}

    {} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction

    "##, + r##"

    {}

    {} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool

    +

    Scored, with each enemy’s reach

    +

    The hulls are the three enemies’ M: every hex each of them can be standing in when we +arrive, on {THEIR_MP} MP from where they are now. Every scored hex on the panel was scored against every +position inside all three.

    +
    "##, escape(&scene.name), scene.stands().len(), board_maps[0].cells().len(), @@ -555,12 +753,14 @@ is the axis the eye compares worst.

    .iter() .map(|foe| foe.may_be.len()) .sum::(), + board_ranks.len(), ); for (regime, (ranking, map)) in REGIMES.iter().zip(row.iter().zip(board_maps.iter())) { let panel = Panel { scene, map, scale, + ranks: board_ranks, offence: hexes_of(&mut ranking.best_offence()), defence: hexes_of(&mut ranking.best_defence()), }; @@ -570,36 +770,70 @@ is the axis the eye compares worst.

    }; let _ = write!( body, - r##"
    {} {}
    {}
    {}

    in woods or beside them: {}/{TOP_K} of the offence list, {}/{TOP_K} of the defence list

    "##, + r##"
    {} · {} {}
    {}
    {}

    in woods or beside them: {}/{TOP_K} of the offence list, {}/{TOP_K} of the defence list

    "##, regime.key, + regime.letter, regime.name, regime.maths, regime.reads, - draw_heat(&panel), + draw_heat(&panel, regime), cover(&mut ranking.best_offence()), cover(&mut ranking.best_defence()), ); } body.push_str("
    "); - let offence_routes = routes_for(scene, row, |r| r.by_offence.first().copied()); - let defence_routes = routes_for(scene, row, |r| r.by_defence.first().copied()); + let defence: Vec> = row + .iter() + .map(|ranking| routes_for(scene, ranking, |r| &r.by_defence)) + .collect(); + let offence: Vec> = row + .iter() + .map(|ranking| routes_for(scene, ranking, |r| &r.by_offence)) + .collect(); let _ = write!( body, - r##"

    The walk each regime would take

    -

    The stand at the top of each list, and the route the pathfinder recorded reaching it - not a line -drawn between two points. A turn costs a movement point and stays in its hex, so the MP is usually -more than the hexes.

    -
    most damage dealt
    {}{}
    -
    least damage taken
    {}{}
    "##, - draw_routes(scene, &offence_routes, "offence"), - routes_caption(&offence_routes), - draw_routes(scene, &defence_routes, "defence"), - routes_caption(&defence_routes), + r##"

    The five walks each regime would take

    +

    The top {TOP_PATHS} of the defence list - least damage taken - drawn over that +regime’s own heatmap, so a route can be read against the ground it crosses. Defence rather than +offence because the exponent demonstrably moves this list and barely moves the other one. Rank 1 is the +heaviest line and carries the largest badge; 2 to 5 fade in order. Each rank runs on its own offset from +the hex centres, so five walks through one hex are five lines. The route is the walk the pathfinder +recorded, not a line drawn between two points: a turn costs a movement point and stays in its hex, so +the MP is usually more than the hexes.

    +
    "##, ); + for (regime, (routes, (ranking, map))) in REGIMES + .iter() + .zip(defence.iter().zip(row.iter().zip(board_maps.iter()))) + { + let panel = Panel { + scene, + map, + scale, + ranks: board_ranks, + offence: BTreeSet::new(), + defence: BTreeSet::new(), + }; + let _ = ranking; + let _ = write!( + body, + r##"
    {} · {} {}
    {}{}
    "##, + regime.key, + regime.letter, + regime.name, + regime.maths, + draw_paths(&panel, regime, routes), + routes_caption(routes), + ); + } + body.push_str("
    "); + body.push_str(&agreement(&defence, "defence")); + body.push_str(&agreement(&offence, "offence")); + body.push_str("
    "); } - body.push_str(&findings(&scenes, &rankings)); + body.push_str(&findings(&scenes, &rankings, &ranks, scale)); let page = format!( r##" @@ -614,9 +848,15 @@ more than the hexes.

    std::fs::write(&out_path, page).expect("write the page"); println!("wrote {out_path}"); println!( - "scale: {:.1} dealt, {:.1} taken; brightest hex on the page is total {brightest:.2}", - scale.deal_max, scale.take_max + "absolute maxima: {:.1} dealt, {:.1} taken (ratio {:.2} on {} enemies)", + scale.deal_max, + scale.take_max, + scale.take_max / scale.deal_max.max(0.001), + ENEMIES.len(), ); + for (name, pool) in BOARDS.iter().zip(ranks.iter()) { + println!("{name}: {} values in each rank pool", pool.len()); + } } /// The opening, and the mechanism the pictures are of. @@ -624,14 +864,14 @@ more than the hexes.

    /// Mechanism only. The one slot that would be written in somebody's voice is /// left as a marked placeholder, because `CLAUDE.md` keeps prominent prose in /// human hands and a generator is not a human. -fn prologue() -> String { +fn prologue(scale: Scale) -> String { format!( r##"

    sds · movement candidates

    What the estimator thinks of a board

    A bot deciding where to move scores every place it could stand. This page draws those scores on the board they were computed for: three maps, three ways of guessing what the enemy -will do, and the walk the bot would actually take under each.

    +will do, and the five walks the bot would actually take under each.

    PLACEHOLDER-QUILLON-MARGRAVE — the opening paragraph in a human voice goes here. Everything below it is mechanism and can stay as written.

    @@ -648,7 +888,7 @@ two different offers.
    M
    Every position an enemy could be in when we get there. We choose L. We do not choose M, so each of our stands is scored not against one enemy position but against all of -them.
    +them. Drawn on every panel as a tinted hull, one per enemy.
    N
    Which enemy. There are three, which is the smallest number that makes the next part interesting: against a single enemy the two aggregations below are the same number.
    @@ -678,7 +918,46 @@ so.

    mean fixing an exchange rate between damage dealt and damage taken, and that rate is a tactical opinion that changes with the unit's role, with how much armour it has left, and with whether its side is winning. It belongs to whatever is choosing, not to the estimator.

    -"## +

    Why “taken” is three times “dealt” everywhere

    +

    This fixture is one of ours against three of theirs. Because offence +is a maximum over one target and defence is a sum over all three shooters, the absolute damage taken +runs about three times the damage dealt at every hex on the board, before position enters at all. The +maxima here are {:.1} taken against {:.1} dealt, a ratio of {:.2} on {} enemies.

    +

    That ratio is the headcount, not a judgement about the ground. Colouring a hex by +deal / (deal + take) would therefore have encoded how outnumbered we are - the same +statement at every hex, and no information about where to stand. The tooltips keep the absolute pair, +because being outnumbered really does mean taking three times the fire and the numbers should keep +saying so. It is only the colour that has to be scale-free.

    +"##, + scale.take_max, + scale.deal_max, + scale.take_max / scale.deal_max.max(0.001), + ENEMIES.len(), + ) +} + +/// What the colour means, now that it is a rank. +fn key_section() -> String { + format!( + r##"

    The colour is a rank

    +

    Two numbers per hex, and they are kept apart. Lightness carries the total: how high +this hex ranks across both channels together. Hue carries the lean: blue where the hex +ranks far higher for dealing than for taking, red where it ranks far higher for taking than for +dealing, and violet where the two ranks are level.

    +

    Both axes are ranks rather than absolute damage, for the reason in +the section above: the two channels have magnitudes that differ by the enemy +count. Each channel is ranked in its own pool, so the offset between them cancels. A +hex in the ninetieth percentile for dealing and the twentieth for taking is a good place to stand, and +that holds at 1v1, 3v1 and 8v8 alike. The lean is the difference of the two ranks, not a share +of their sum: a share of a sum carries the force ratio back in.

    +

    The pool is one board’s three regimes together, never one panel on its own. +The three exponents exist to be compared, and ranking each panel separately would make a board where +every hex is equally bad look exactly like one with real variation. Equal values share a rank, so a +board that saturates draws flat instead of inventing an order.

    +

    Colour is the rank; the tooltip is the absolute damage. Hover any scored hex to see +both, and to check that the ranking is not lying.

    +
    {}
    "##, + legend() ) } @@ -687,23 +966,43 @@ fn how_to_read() -> String { String::from( r##"

    Reading a map

    +

    the fill

    +

    The whole hex is the heat, and a hex we can stop in carries a brighter outline than the bare grid +around it.

    +

    A hex we can stop in where nothing happens sits at the bottom of both rank pools and +draws darkest, which is why the outline is there: a quiet hex and a hex we cannot stop in are different +things, and telling the two apart is most of what a map like this is for.

    +

    Expect the defence list’s first picks to sit low on the lightness ramp. “Least +damage taken” and “nothing happens here” are close to the same hex, and the map says so +rather than hiding it.

    +

    terrain, drawn on top

    +

    light woods · + heavy woods

    +

    water, one wave per level of depth · + impassable

    +

    A numeral in a small box is the hex’s level. Terrain used to be the hex fill; it is an overlay +now so the fill can carry the whole heat scale.

    +

    Every mark on this page is an SVG shape. No emoji, no icon font, and the page fetches +nothing.

    marks

    a top 8 stand by offence, most damage dealt

    a top 8 stand by defence, least damage taken

    a hex on both lists

    -

    an enemy start · where we start

    A mark sits on a hex, but the lists rank stands. One hex can hold several facings and only one of them need be in the top 8.

    -

    terrain

    -

    clear · light woods · - heavy woods

    -

    water · impassable

    -

    A numeral in a hex is its level. Terrain shows through as the ground under the scored hexes, which -are drawn smaller and sit inside it.

    -

    A hex with no scored hexagon inside it is one we cannot stop in. A scored hexagon at -the bottom of the lightness ramp is one we can stop in where nothing happens - those are different -things, and telling them apart is most of what a map like this is for.

    -
    "##, +

    who is where

    +

    where we start · + E1 · E2 · + E3

    +

    Each enemy’s hull is the same tint as its start hex. The hulls overlap heavily, so each one is +drawn a little further inside the last; hover one for its hex count.

    +

    The hulls are drawn faint on purpose. They are context - what was evaluated - and not +data.

    + +

    The hulls and the five walks are on separate rows rather than one panel. Together on a +single map they were unreadable: three overlapping outlines and five overlapping routes over a +coloured field is too many line weights at once. The heatmap under both rows is the same one.

    +"##, ) } @@ -711,7 +1010,7 @@ things, and telling them apart is most of what a map like this is for.

    /// /// Printed whichever way it falls. A page tuned until it agreed with the /// prediction would be worth nothing. -fn findings(scenes: &[Scene], rankings: &[Vec]) -> String { +fn findings(scenes: &[Scene], rankings: &[Vec], ranks: &[Ranks], scale: Scale) -> String { let mut out = String::from( r##"

    What came out

    The prediction on record is that a lower exponent moves the answer towards cover. Cover here is a @@ -771,15 +1070,31 @@ see into it - and counting only the trees would miss the hex the defence list pi for verdict in &verdicts { out.push_str(verdict); } - out.push_str( + let spreads: Vec = scenes + .iter() + .zip(ranks.iter()) + .map(|(scene, pool)| { + format!( + "

  • {}: {} values per rank pool.
  • ", + escape(&scene.name), + pool.len() + ) + }) + .collect(); + let _ = write!( + out, r##"

    Two things worth saying plainly rather than hiding. The forest board saturates: nearly every hex the unit can reach is in or beside woods, so the cover count is at its ceiling in every column and the map is a handful of hexes rather than a field. That is a property of the board and of a six point budget in heavy woods, not a fault in the scoring, and it is exactly the case where this kind of -picture stops being informative. And the offence list barely moves with the exponent, while the -defence list does - which fits, since cover limits the worst case and the worst case is what a low -exponent is looking at.

    +picture stops being informative. It stays in because leaving it out would be tuning the page. And the +offence list barely moves with the exponent, while the defence list does - which fits, since cover +limits the worst case and the worst case is what a low exponent is looking at.

    +

    The rank pools are small, and a small pool makes a coarse ramp:

    +
      {}
    +

    Absolute maxima across all nine maps: {:.1} dealt and {:.1} taken. +Both appear in every tooltip. Neither is used for a colour.

    Generated by crates/sds-core/examples/heatmap.rs from @@ -787,10 +1102,16 @@ exponent is looking at.

    MegaMek. No MegaMek and no match are involved in drawing this: it is the estimator run over a fixture. Us at (4, 13); enemies at (5, 6), (9, 6) and (12, 7). The unit and its guns are a fixture choice, picked so that all five volley -outputs carry information, and are not a model change.

    +outputs carry information, and are not a model change. The hulls are computed here with a monotone +chain in sds_core::heatmap::convex_hull, not with MegaMek’s +ConvexBoardArea: it is a drawing aid, and Princess’s geometry is not a dependency +this repository takes for one.

    Hover any scored hex for its numbers. Nothing on this page is tuned to make the picture look good.

    "##, + spreads.concat(), + scale.deal_max, + scale.take_max, ); out } @@ -802,7 +1123,7 @@ good.

    /// "system" gets a complete palette either way. /// /// The heat ramp is three tokens rather than a colour, which is what lets one -/// encoding serve both themes: the hue is the ratio in both, and lightness runs +/// encoding serve both themes: the hue is the lean in both, and lightness runs /// pale-to-dark on paper and dark-to-bright on a black page. More ink and more /// light are the same statement. const CSS: &str = r##" @@ -816,18 +1137,18 @@ const CSS: &str = r##" --rule-soft: #ebe5da; --map-bg: #fbf9f4; - --t-clear: #e9e4d7; - --t-light: #d7e4c6; - --t-heavy: #bcd5a2; - --t-water: #c8dcef; - --t-block: #ecc9c3; + --ground: #ece7db; --hex-edge: #cfc7b6; + --cell-edge: #6d6350; + --glyph: #4a4132; + --glyph-halo: #fffdf8; - --lvl: #9a8b6d; --mark: #16202c; --mark-edge: #fffdf8; --us: #1b2430; - --them: #8c2f2f; + --foe-0: #b03a2e; + --foe-1: #8a6d1f; + --foe-2: #1f7a4d; /* The heat ramp. Pale is nothing, saturated and dark is a lot. */ --ramp-base: 94%; @@ -857,18 +1178,18 @@ const CSS: &str = r##" --rule-soft: #151d28; --map-bg: #080b11; - --t-clear: #171c25; - --t-light: #16251a; - --t-heavy: #1b3320; - --t-water: #102232; - --t-block: #2c1719; + --ground: #131924; --hex-edge: #0a0e14; + --cell-edge: #8fa2ba; + --glyph: #e6dcc4; + --glyph-halo: #05070b; - --lvl: #b9a273; --mark: #ffffff; --mark-edge: #05070b; --us: #ffffff; - --them: #ff8a8a; + --foe-0: #ff8f7a; + --foe-1: #ecd25c; + --foe-2: #5fd39b; --ramp-base: 8%; --ramp-span: 56%; @@ -893,18 +1214,18 @@ const CSS: &str = r##" --rule-soft: #151d28; --map-bg: #080b11; - --t-clear: #171c25; - --t-light: #16251a; - --t-heavy: #1b3320; - --t-water: #102232; - --t-block: #2c1719; + --ground: #131924; --hex-edge: #0a0e14; + --cell-edge: #8fa2ba; + --glyph: #e6dcc4; + --glyph-halo: #05070b; - --lvl: #b9a273; --mark: #ffffff; --mark-edge: #05070b; --us: #ffffff; - --them: #ff8a8a; + --foe-0: #ff8f7a; + --foe-1: #ecd25c; + --foe-2: #5fd39b; --ramp-base: 8%; --ramp-span: 56%; @@ -924,7 +1245,6 @@ body { background: var(--paper); color: var(--ink); font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; - } main { max-width: 1180px; margin: 0 auto; padding: 56px 22px 72px; } @@ -953,14 +1273,20 @@ h3 { font: 600 15px/1.4 ui-sans-serif, system-ui, sans-serif; letter-spacing: 0.02em; margin: 26px 0 10px; color: var(--ink); } +h4 { font: 600 13px/1.4 ui-sans-serif, system-ui, sans-serif; margin: 0 0 6px; color: var(--ink); } section { margin-bottom: 46px; } p { max-width: 68ch; margin: 0 0 14px; } strong { font-weight: 650; } +a { color: inherit; } code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.88em; background: var(--rule-soft); padding: 0.1em 0.34em; border-radius: 3px; } .note { font-size: 13.5px; color: var(--ink-faint); max-width: 66ch; } +.callout { + background: var(--panel); border: 1px solid var(--rule); border-left: 3px solid var(--foe-0); + border-radius: 4px; padding: 12px 16px; box-shadow: var(--shadow); +} .stat { font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ink-faint); max-width: none; @@ -983,24 +1309,25 @@ svg.key { width: 100%; max-width: 380px; height: auto; display: block; } .key text.axis { fill: var(--ink-faint); font-size: 8.5px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 22px 34px; +.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 22px 34px; background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 18px 20px; box-shadow: var(--shadow); } .legend h3 { margin-top: 0; } .legend p { font-size: 14px; margin: 0 0 8px; color: var(--ink-soft); } -.sw { display: inline-block; width: 13px; height: 13px; vertical-align: -2px; } +.sw { display: inline-block; width: 14px; height: 14px; vertical-align: -3px; } .sw.up { background: var(--mark); clip-path: polygon(50% 0, 100% 100%, 0 100%); } .sw.down { background: var(--mark); clip-path: polygon(0 0, 100% 0, 50% 100%); } .sw.both { background: var(--mark); clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); } .sw.us { border: 2px dashed var(--us); } -.sw.them { border: 2px solid var(--them); } -.sw.t-clear { background: var(--t-clear); border: 1px solid var(--hex-edge); } -.sw.t-light { background: var(--t-light); } -.sw.t-heavy { background: var(--t-heavy); } -.sw.t-water { background: var(--t-water); } -.sw.t-block { background: var(--t-block); } - -.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 22px; +.sw.f0 { border: 2px solid var(--foe-0); background: var(--foe-0); opacity: 0.35; } +.sw.f1 { border: 2px solid var(--foe-1); background: var(--foe-1); opacity: 0.35; } +.sw.f2 { border: 2px solid var(--foe-2); background: var(--foe-2); opacity: 0.35; } +.sw.glyphbox { width: 18px; height: 18px; vertical-align: -4px; } +.sw.glyphbox svg { width: 100%; height: 100%; display: block; } + +/* min() so the grid never demands more width than the page has: the body must + not scroll sideways, and only .scroll may. */ +.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); gap: 22px; margin-bottom: 10px; } figure { margin: 0; min-width: 0; } figcaption { font-size: 14px; margin-bottom: 8px; display: flex; flex-wrap: wrap; @@ -1011,37 +1338,60 @@ figcaption.r-min .dot, figcaption.r-mid .dot, figcaption.r-mean .dot { align-sel svg.map { width: 100%; height: auto; display: block; background: var(--map-bg); border: 1px solid var(--rule); border-radius: 4px; } -.map .ground { stroke: var(--hex-edge); stroke-width: 1; } -.map .t-clear { fill: var(--t-clear); } -.map .t-light { fill: var(--t-light); } -.map .t-heavy { fill: var(--t-heavy); } -.map .t-water { fill: var(--t-water); } -.map .t-block { fill: var(--t-block); } +.map .ground { fill: var(--ground); stroke: var(--hex-edge); stroke-width: 1; } .map .cell, .key .cell { fill: hsl(calc(350deg - var(--b) * 140deg) var(--ramp-sat) calc(var(--ramp-base) + var(--t) * var(--ramp-span))); } -.map .cell { stroke: var(--hex-edge); stroke-width: 0.6; } +/* A different edge from the ground's, so a hex we can stop in where nothing + happens is still visibly a hex we can stop in. */ +.map .cell { stroke: var(--cell-edge); stroke-width: 0.9; stroke-opacity: 0.55; } .map .mark { fill: var(--mark); stroke: var(--mark-edge); stroke-width: 0.8; } -.map .lvl { fill: var(--lvl); font-size: 8px; text-anchor: middle; + +.glyph { paint-order: stroke; fill: var(--glyph); stroke: var(--glyph-halo); stroke-width: 1.6; + stroke-linejoin: round; stroke-linecap: round; } +.glyph-halo, .glyph-line { fill: none; stroke-linecap: round; stroke-linejoin: round; } +.glyph-halo { stroke: var(--glyph-halo); stroke-width: 3.4; } +.glyph-line { stroke: var(--glyph); stroke-width: 1.5; } +.map .lvlbox { fill: var(--glyph-halo); stroke: var(--glyph); stroke-width: 0.7; opacity: 0.92; } +.map .lvl { fill: var(--glyph); font-size: 8px; text-anchor: middle; font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +/* Context, not data: faint enough to sit under the hex colours. */ +.map .hull { stroke-width: 1.3; stroke-dasharray: 5 4; fill-opacity: 0.10; stroke-opacity: 0.62; } +.map .start { stroke-width: 2.4; } +/* Their start hexes are solid in their own tint, ours is an outline: the + hulls belong to them, and each one matches its owner's hex. */ +.map .hull.f0, .map .start.f0 { fill: var(--foe-0); stroke: var(--foe-0); } +.map .hull.f1, .map .start.f1 { fill: var(--foe-1); stroke: var(--foe-1); } +.map .hull.f2, .map .start.f2 { fill: var(--foe-2); stroke: var(--foe-2); } +.map .start.us { fill: none; stroke: var(--us); stroke-dasharray: 4 3; } .map .who { font-size: 9.5px; font-weight: 700; text-anchor: middle; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + paint-order: stroke; stroke: var(--map-bg); stroke-width: 2.2; } .map .who.us { fill: var(--us); } -.map .who.them { fill: var(--them); } -.map .start { fill: none; stroke-width: 2.4; } -.map .start.us { stroke: var(--us); stroke-dasharray: 4 3; } -.map .start.them { stroke: var(--them); } +.map .who.f0, .map .who.f1, .map .who.f2 { fill: var(--map-bg); stroke: none; } + .map .route { fill: none; stroke-linejoin: round; stroke-linecap: round; } -.map .pick { fill: none; stroke-width: 2.2; } .map .facing { stroke-width: 2.2; stroke-linecap: round; } -.map .r-min { stroke: var(--r-min); } -.map .r-mid { stroke: var(--r-mid); } -.map .r-mean { stroke: var(--r-mean); } - -ul.routes { list-style: none; padding: 0; margin: 8px 0 4px; font-size: 13.5px; } -ul.routes li { margin-bottom: 5px; color: var(--ink-soft); } -ul.routes code { background: none; padding: 0; color: var(--ink); } +.map .badge { stroke: var(--mark-edge); stroke-width: 1; } +.map .rank { font-size: 8.5px; font-weight: 700; text-anchor: middle; fill: var(--mark-edge); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; pointer-events: none; } +.map .route.r-min, .map .facing.r-min { stroke: var(--r-min); } +.map .route.r-mid, .map .facing.r-mid { stroke: var(--r-mid); } +.map .route.r-mean, .map .facing.r-mean { stroke: var(--r-mean); } +.map .badge.r-min { fill: var(--r-min); } +.map .badge.r-mid { fill: var(--r-mid); } +.map .badge.r-mean { fill: var(--r-mean); } + +ol.routes { padding-left: 1.6em; margin: 8px 0 4px; font-size: 13px; } +ol.routes li { margin-bottom: 3px; color: var(--ink-soft); } +ol.routes code { background: none; padding: 0; color: var(--ink); } + +.agree { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; + padding: 14px 18px; margin: 14px 0; box-shadow: var(--shadow); } +ul.agreelist { margin: 0 0 8px; padding-left: 20px; font-size: 13.5px; color: var(--ink-soft); } +.agree .note { margin: 0; } .scroll { overflow-x: auto; margin-bottom: 16px; } table { border-collapse: collapse; font-size: 13.5px; min-width: 640px; } diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs index bb9bc83..c631ab9 100644 --- a/crates/sds-core/src/heatmap.rs +++ b/crates/sds-core/src/heatmap.rs @@ -120,23 +120,87 @@ impl Scale { pub fn covers(&self, cell: &HexCell) -> bool { cell.deal <= self.deal_max && cell.take <= self.take_max } +} - /// A cell as the two numbers a render colours by. - pub fn shade(&self, cell: &HexCell) -> Shade { - let unit = |value: f32, full: f32| { - if full <= 0.0 { - 0.0 - } else { - (value / full).clamp(0.0, 1.0) +/// Where a hex stands among all the hexes drawn beside it, one pool per +/// channel. +/// +/// The two channels have magnitudes that are not comparable, and the reason is +/// the headcount rather than the ground: offence takes the **best** enemy +/// because we fire at one thing, defence takes the **sum** because all of them +/// shoot us, so against three enemies "taken" runs about three times "dealt" +/// everywhere on the board. Colouring from `deal / (deal + take)` therefore +/// encodes how outnumbered we are, which is the same at every hex and tells a +/// reader nothing about position. +/// +/// Ranking each channel in its own pool removes that offset. A hex in the 90th +/// percentile for dealing and the 20th for taking is a good place to stand, and +/// that holds at 1v1, 3v1 and 8v8 alike. +/// +/// **Pooled across the regimes of one board, never per panel.** The three +/// exponents are drawn to be compared, and ranking each panel on its own would +/// make a board where every hex is equally bad look exactly like one with real +/// variation. The absolute numbers stay in the tooltips, which is how a reader +/// checks the ranking is not lying. +#[derive(Debug, Clone, PartialEq)] +pub struct Ranks { + deal: Vec, + take: Vec, +} + +impl Ranks { + /// One pool per channel over every cell of every map given. + pub fn pooled<'a>(maps: impl IntoIterator) -> Self { + let mut deal = Vec::new(); + let mut take = Vec::new(); + for map in maps { + for cell in map.cells() { + deal.push(cell.deal); + take.push(cell.take); } - }; - let deal = unit(cell.deal, self.deal_max); - let take = unit(cell.take, self.take_max); - let sum = deal + take; - Shade { - total: (sum / 2.0).clamp(0.0, 1.0), - balance: if sum <= 0.0 { 0.5 } else { deal / sum }, } + deal.sort_by(|a, b| a.total_cmp(b)); + take.sort_by(|a, b| a.total_cmp(b)); + Self { deal, take } + } + + /// How many values are in each pool. Equal by construction: every cell + /// contributes once to both. + pub fn len(&self) -> usize { + self.deal.len() + } + + pub fn is_empty(&self) -> bool { + self.deal.is_empty() + } + + /// The midrank of a value in a sorted pool, as a fraction of 0 to 1. + /// + /// Midrank so that ties share a colour: the forest board saturates, and a + /// tie-break that gave equal values different shades would draw a + /// difference that is not there. + fn rank_in(pool: &[f32], value: f32) -> f32 { + if pool.is_empty() { + return 0.5; + } + let below = pool.partition_point(|v| *v < value); + let through = pool.partition_point(|v| *v <= value); + (below + through) as f32 / (2.0 * pool.len() as f32) + } + + /// Where this cell's damage dealt sits in the pooled deal values. + pub fn deal_rank(&self, cell: &HexCell) -> f32 { + Self::rank_in(&self.deal, cell.deal) + } + + /// Where this cell's damage taken sits in the pooled take values. + pub fn take_rank(&self, cell: &HexCell) -> f32 { + Self::rank_in(&self.take, cell.take) + } + + /// A cell as the two numbers a render colours by. + pub fn shade(&self, cell: &HexCell) -> Shade { + Shade::from_ranks(self.deal_rank(cell), self.take_rank(cell)) } } @@ -146,15 +210,71 @@ impl Scale { /// `plan/candidates.md` asks for: an additive blend of two colours puts "high in /// both" and "middling in both" on the lightness axis alone, and that is the /// axis the eye compares worst. +/// +/// Both axes come from [`Ranks`], so both are scale-free. `balance` is a +/// **difference** of ranks rather than a share of a sum: a share of a sum +/// carries the force ratio, and a difference of ranks does not. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Shade { /// How much is happening here, both directions together. 0 to 1. pub total: f32, - /// The share of it that is ours to deal. 0 is all incoming, 1 is all - /// outgoing, and 0.5 is an even trade or a silent hex. + /// Which direction it leans. 0 is bottom for dealing and top for taking, + /// 1 is the reverse, and 0.5 is a hex that ranks the same in both. pub balance: f32, } +impl Shade { + /// The two rank fractions as the two axes drawn. + pub fn from_ranks(deal_rank: f32, take_rank: f32) -> Self { + Self { + total: ((deal_rank + take_rank) / 2.0).clamp(0.0, 1.0), + balance: (0.5 + (deal_rank - take_rank) / 2.0).clamp(0.0, 1.0), + } + } +} + +/// The convex hull of a set of points, in a consistent winding. +/// +/// Monotone chain, and ours rather than MegaMek's `ConvexBoardArea`: this is a +/// drawing aid for showing which hexes an enemy's `M` covers, and taking a +/// dependency on Princess's geometry to draw a shape would be the wrong trade. +/// +/// Fewer than three points come back unchanged, and collinear points are +/// dropped: a hull that kept them would draw the same outline with more +/// vertices. +pub fn convex_hull(points: &[(f32, f32)]) -> Vec<(f32, f32)> { + let mut sorted: Vec<(f32, f32)> = points.to_vec(); + sorted.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + sorted.dedup(); + if sorted.len() < 3 { + return sorted; + } + let cross = |o: (f32, f32), a: (f32, f32), b: (f32, f32)| { + (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0) + }; + let mut hull: Vec<(f32, f32)> = Vec::with_capacity(sorted.len() * 2); + for pass in 0..2 { + // Two vertices of this pass's own chain, and never fewer than two + // overall: the first pass starts on an empty hull. + let lower = (hull.len() + 1).max(2); + let run: Box> = if pass == 0 { + Box::new(sorted.iter()) + } else { + Box::new(sorted.iter().rev()) + }; + for point in run { + while hull.len() >= lower + && cross(hull[hull.len() - 2], hull[hull.len() - 1], *point) <= 0.0 + { + hull.pop(); + } + hull.push(*point); + } + } + hull.pop(); + hull +} + #[cfg(test)] mod tests { use super::*; @@ -256,44 +376,169 @@ mod tests { for map in [&one, &two] { for cell in map.cells() { assert!(scale.covers(cell)); - let shade = scale.shade(cell); - assert!((0.0..=1.0).contains(&shade.total)); - assert!((0.0..=1.0).contains(&shade.balance)); } } } - /// Lightness is the total and hue is the ratio, so "high in both" and - /// "middling in both" differ on lightness while "all out" and "all in" - /// differ on hue at the same lightness. + /// The whole point of ranking per channel: a channel whose numbers are + /// three times the other's is not three times brighter. + /// + /// This is the 3v1 fixture in miniature. `take` runs an order of magnitude + /// above `deal` because defence sums over three shooters and offence takes + /// the best of one, and the colour must not read that as a statement about + /// the ground. + #[test] + fn ranks_remove_the_offset_between_the_two_channels() { + let map = HexMap::of(&ranking(vec![ + score(1, 1, 0, 1.0, 30.0), + score(2, 1, 0, 2.0, 60.0), + score(3, 1, 0, 3.0, 90.0), + ])); + let ranks = Ranks::pooled([&map]); + // Each hex is at the same percentile in both channels, so every one of + // them is balanced, whatever the absolute gap. + for cell in map.cells() { + assert_eq!(ranks.shade(cell).balance, 0.5); + } + // And lightness still separates them, because the ordering survives. + let low = ranks + .shade(map.get(Coord::new(1, 1)).expect("reachable")) + .total; + let high = ranks + .shade(map.get(Coord::new(3, 1)).expect("reachable")) + .total; + assert!(high > low); + } + + /// Hue is a difference of ranks, so a hex that deals well and takes little + /// leans one way and its mirror leans the other by the same amount. #[test] - fn shade_separates_the_total_from_the_balance() { - let scale = Scale { - deal_max: 10.0, - take_max: 10.0, - }; - let at = |deal, take| { - scale.shade(&HexCell { - hex: Coord::new(0, 0), - deal, - take, - stands: 1, - }) - }; - let heavy = at(10.0, 10.0); - let light = at(3.0, 3.0); - assert!(heavy.total > light.total); - assert_eq!(heavy.balance, light.balance); - - let ours = at(8.0, 2.0); - let theirs = at(2.0, 8.0); - assert_eq!(ours.total, theirs.total); - assert!(ours.balance > 0.5 && theirs.balance < 0.5); - - // A silent hex sits at the bottom of the lightness axis and in the - // middle of the hue axis rather than at an end of it. - let silent = at(0.0, 0.0); - assert_eq!(silent.total, 0.0); - assert_eq!(silent.balance, 0.5); + fn hue_leans_with_the_rank_difference() { + let map = HexMap::of(&ranking(vec![ + score(1, 1, 0, 9.0, 1.0), + score(2, 1, 0, 5.0, 5.0), + score(3, 1, 0, 1.0, 9.0), + ])); + let ranks = Ranks::pooled([&map]); + let at = |x| ranks.shade(map.get(Coord::new(x, 1)).expect("reachable")); + assert!(at(1).balance > 0.5); + assert_eq!(at(2).balance, 0.5); + assert!(at(3).balance < 0.5); + assert!((at(1).balance - 0.5 - (0.5 - at(3).balance)).abs() < 1e-6); + // All three are middling in total: none of them is quiet, and none is + // at both maxima. + assert_eq!(at(2).total, 0.5); + } + + /// One pool over the three regimes of a board, not one per panel. + /// + /// A panel where everything is equally bad must not come out looking like + /// a panel with real variation, which is what per-panel ranking would do. + #[test] + fn the_pool_spans_every_map_given_not_each_one_alone() { + let flat = HexMap::of(&ranking(vec![ + score(1, 1, 0, 1.0, 1.0), + score(2, 1, 0, 1.0, 1.0), + ])); + let varied = HexMap::of(&ranking(vec![ + score(1, 2, 0, 1.0, 1.0), + score(2, 2, 0, 40.0, 40.0), + ])); + let pooled = Ranks::pooled([&flat, &varied]); + let alone = Ranks::pooled([&flat]); + let flat_cell = flat.get(Coord::new(1, 1)).expect("reachable"); + let bright = varied.get(Coord::new(2, 2)).expect("reachable"); + assert!(pooled.shade(flat_cell).total < pooled.shade(bright).total); + // On its own the flat map's hexes sit mid-ramp; pooled, they sit at the + // bottom where they belong. + assert!(pooled.shade(flat_cell).total < alone.shade(flat_cell).total); + assert_eq!(pooled.len(), 4); + } + + /// Equal values share a rank. The forest board saturates, and drawing a + /// difference between tied hexes would be inventing one. + #[test] + fn ties_share_a_rank() { + let map = HexMap::of(&ranking(vec![ + score(1, 1, 0, 7.0, 3.0), + score(2, 1, 0, 7.0, 3.0), + score(3, 1, 0, 7.0, 3.0), + ])); + let ranks = Ranks::pooled([&map]); + let shades: Vec = map.cells().iter().map(|c| ranks.shade(c)).collect(); + assert!(shades.windows(2).all(|pair| pair[0] == pair[1])); + assert_eq!(shades[0].total, 0.5); + } + + /// Both axes stay on the ramp for every cell they were pooled from. + #[test] + fn every_shade_lands_inside_the_ramp() { + let map = HexMap::of(&ranking(vec![ + score(1, 1, 0, 0.0, 0.0), + score(2, 1, 0, 31.7, 92.1), + score(3, 1, 0, 4.0, 60.0), + ])); + let ranks = Ranks::pooled([&map]); + for cell in map.cells() { + let shade = ranks.shade(cell); + assert!((0.0..=1.0).contains(&shade.total)); + assert!((0.0..=1.0).contains(&shade.balance)); + } + } + + /// A square comes back as its four corners, and the point inside is gone. + #[test] + fn a_hull_keeps_the_corners_and_drops_the_inside() { + let hull = convex_hull(&[ + (0.0, 0.0), + (4.0, 0.0), + (4.0, 4.0), + (0.0, 4.0), + (2.0, 2.0), + (1.0, 3.0), + ]); + assert_eq!(hull.len(), 4); + for corner in [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)] { + assert!(hull.contains(&corner), "{corner:?} missing from {hull:?}"); + } + } + + /// Collinear points do not become vertices, and a degenerate set comes + /// back as itself rather than as an empty polygon. + #[test] + fn a_hull_handles_lines_and_single_points() { + let line = convex_hull(&[(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (3.0, 3.0)]); + assert_eq!(line, vec![(0.0, 0.0), (3.0, 3.0)]); + assert_eq!(convex_hull(&[(2.0, 5.0)]), vec![(2.0, 5.0)]); + assert_eq!(convex_hull(&[]), Vec::new()); + let one_hex = convex_hull(&[(1.0, 1.0), (1.0, 1.0), (1.0, 1.0)]); + assert_eq!(one_hex, vec![(1.0, 1.0)]); + } + + /// The hull does not depend on the order the points arrived in, which is + /// the same promise the cell ordering makes. + #[test] + fn a_hull_does_not_depend_on_the_input_order() { + let points = vec![ + (3.0, 1.0), + (0.0, 0.0), + (2.0, 5.0), + (5.0, 2.0), + (1.0, 4.0), + (2.5, 2.5), + ]; + let forward = convex_hull(&points); + let backward = convex_hull(&points.iter().rev().copied().collect::>()); + assert_eq!(forward, backward); + // Every input point is inside or on the hull it came from. + let edges: Vec<((f32, f32), (f32, f32))> = (0..forward.len()) + .map(|at| (forward[at], forward[(at + 1) % forward.len()])) + .collect(); + for point in &points { + for (a, b) in &edges { + let side = (b.0 - a.0) * (point.1 - a.1) - (b.1 - a.1) * (point.0 - a.0); + assert!(side >= -1e-4, "{point:?} outside edge {a:?}->{b:?}"); + } + } } }