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##"
///
/// 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!(
+ "
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.
"##,
+ 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:?}");
+ }
+ }
}
}