/** * A firepower curve, drawn. * * helm publishes what a design expects to *land* at each hex out to * twenty-four - MegaMek's own per-weapon averages weighted by how often each * weapon hits a gunnery 4 crew's target on open ground - and the shape of that * is the thing worth looking at. A single number cannot say that an Awesome is * worse in somebody's face than at six hexes, or that a Catapult outreaches an * Atlas; the curve says both at a glance. * * A module of its own rather than part of the Hangar screen, because more than * one screen wants it: the force builder, a match's after-action report and a * player's saved list are all looking at the same twenty-four numbers. It * builds an `` and touches nothing else, so a caller decides where it * goes and how big it is. * * Everything above `chart()` is arithmetic over an array and is tested * directly. The drawing below it is geometry with no decisions left in it. */ /** How far a curve reaches, in hexes. helm publishes exactly this many. */ export const REACH = 24; /** * How close to the best still counts as the best, as a share of the best. * * A design rarely has one answer. A Marauder is within half a point of its own * peak at four hexes running, and calling one of them "the best range" implies * a precision the model does not have. * * A share rather than a fixed number of points, because a point means * different things at the two ends of the library: it is a rounding error on * what an Atlas lands and most of what a Locust does, so a flat point read one * design as precise and the other as vague. A twentieth of the peak is a * difference no player would choose a range on, whatever the design. */ const NEAR = 0.05; /** A run of hexes, first and last inclusive. */ export type Run = [number, number]; /** * Every hex within a twentieth of the best, as runs of adjacent hexes. * * Runs rather than a single number because the answer is often a stretch, and * occasionally two stretches with a gap between them: a design carrying both * a launcher and short-range guns can do the same damage at four hexes and * again at eleven, and nothing in between. */ export function bestHexes(curve: readonly number[], within = NEAR): Run[] { const most = Math.max(0, ...curve); if (most <= 0) return []; const floor = most * (1 - within); const runs: Run[] = []; curve.forEach((damage, at) => { if (damage < floor) return; const hex = at + 1; const last = runs.at(-1); if (last && hex === last[1] + 1) last[1] = hex; else runs.push([hex, hex]); }); return runs; } /** Those runs, written the way a player would read them out: `5-7, 11`. */ export function sayHexes(runs: readonly Run[]): string { return runs .map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`)) .join(", "); } /** * A damage figure, rounded the way a player would say it. * * Whole points. The model is an expectation over dice and a tenth of a point * of it is noise, and a tenth carried on every row of a search result is a * column of noise: nobody chooses between two designs on 13.8 against 14.2. * * `<1` rather than `0` for a design that lands something: a rounding that * turns a machine with a machine gun into one with no guns at all is the one * place the whole point is a lie. */ export function sayDamage(damage: number): string { if (damage > 0 && damage < 0.5) return "<1"; return String(Math.round(damage)); } /** What several designs land together, hex by hex. */ export function totalCurve(curves: readonly (readonly number[])[]): number[] { return Array.from({ length: REACH }, (_, at) => curves.reduce((sum, curve) => sum + (curve[at] ?? 0), 0), ); } /** * How the peak is called out. * * `full` writes both halves of the answer down - the damage above the mark, * the hex on the axis - which is the only way a reader can tell 5 from 6 on a * chart twenty-four hexes wide. `mark` drops the damage and keeps the hex: a * sparkline sits under the damage figure it came from and would only be * repeating it, while "where" is the half nothing else on the row says. * `none` is a curve shown purely as a shape, and the numbers are in the label * a screen reader reads whichever of the three it is. */ export type Peak = "full" | "mark" | "none"; export interface CurveOptions { /** Expected damage at each hex, in points, hex 1 first. */ curve: readonly number[]; /** * What the vertical is scaled to. * * The curve's own peak when it is read alone, which makes it a sparkline: * the shape is the point and the height means nothing. A shared figure when * several are compared, which is what makes an Atlas taller than a Locust. * A curve above the scale is clamped and gets a dashed ceiling, so one * rocket boat does not flatten every other chart on the screen. */ max?: number; width?: number; height?: number; peak?: Peak; /** Ticks along the bottom. Off on a chart too small to read them. */ hexes?: boolean; /** Curves drawn faintly underneath, each with its own best marked. */ faint?: readonly (readonly number[])[]; /** What this is a curve of, for the label a screen reader reads. */ of?: string; } const SVG = "http://www.w3.org/2000/svg"; function node( tag: K, attributes: Record, text?: string, ): SVGElementTagNameMap[K] { const made = document.createElementNS(SVG, tag); for (const [name, value] of Object.entries(attributes)) { made.setAttribute(name, String(value)); } if (text !== undefined) made.textContent = text; return made; } /** The scale a set of curves shares. */ export function scaleFor(curves: readonly (readonly number[])[]): number { return Math.max(1, ...curves.map((curve) => Math.max(0, ...curve))); } /** * Where the scale sits when a whole search result is drawn to one. * * Nineteen designs in twenty fit under it, and the twentieth runs off the top * and gets the dashed ceiling. The true maximum is the wrong figure for a set * of any size: `mass<=55` holds a design landing over a hundred, and drawing * three thousand rows to that turned every one of them into a flat line a * pixel above the axis. The point of the row is its shape. */ export const SPREAD = 0.95; /** * The scale a set of curves shares, ignoring the tail that would flatten it. * * A share of the way up the sorted peaks rather than the top of them, so one * rocket boat in a search costs itself a ceiling rather than costing every * other design its shape. Designs that land nothing are not counted: a library * filter that catches four hundred unarmed haulers should not halve the scale * the armed ones are drawn to. */ export function scaleAcross( curves: readonly (readonly number[])[], share = SPREAD, ): number { const peaks = curves .map((curve) => Math.max(0, ...curve)) .filter((peak) => peak > 0) .sort((a, b) => a - b); if (!peaks.length) return 1; const at = Math.max(0, Math.ceil(peaks.length * share) - 1); return Math.max(1, peaks[at] ?? 1); } /** * The first and last hex the design lands anything at, as indices. * * What is outside it is not a low figure but a flat nought: a Bombard cannot * fire under three hexes and an autocannon boat has nothing past its own * range, and a curve drawn in one weight across the whole board reads as * though it tapers off there. `null` when the design lands nothing anywhere. */ export function liveSpan(curve: readonly number[]): [number, number] | null { const first = curve.findIndex((damage) => damage > 0); if (first < 0) return null; let last = curve.length - 1; while (last > first && !((curve[last] ?? 0) > 0)) last--; return [first, last]; } /** What the chart says, for somebody who cannot see it. */ export function describe(curve: readonly number[], of?: string): string { const runs = bestHexes(curve); const most = Math.max(0, ...curve); const what = of ? `${of}: ` : ""; const [only] = runs; if (!only) return `${what}lands nothing at any range.`; const where = runs.length === 1 && only[0] === only[1] ? "hex" : "hexes"; return `${what}best ${sayDamage(most)} damage at ${where} ${sayHexes(runs)}.`; } /** * The curve, as an `` the caller places. * * Colour comes from the stylesheet rather than from here: `.curve` carries the * accent the line is drawn in and `.curve-peak` the one the best hexes are * marked in, so a chart in a panel and a chart on the match report are the * same two colours without either screen saying so. */ export function chart({ curve, max, width = 250, height = 96, peak = "full", hexes = true, faint = [], of, }: CurveOptions): SVGSVGElement { const ceiling = Math.max(max ?? Math.max(0, ...curve), 1); const padL = 4; const padR = 8; const padT = peak === "full" ? 14 : 4; // The writing along the bottom clears the axis by about five points at // 0.68rem. Thirteen put the hex a run peaks at within a point and a half of // the line above it, which read as the number touching the chart. const padB = hexes || peak !== "none" ? 17 : 3; const x = (at: number) => padL + (at / (REACH - 1)) * (width - padL - padR); const y = (damage: number) => padT + (1 - Math.min(damage, ceiling) / ceiling) * (height - padT - padB); const svg = node("svg", { viewBox: `0 0 ${width} ${height}`, class: "curve", role: "img", "aria-label": describe(curve, of), }); const runs = peak === "none" ? [] : bestHexes(curve); if (hexes) { for (const hex of [6, 12, 18]) { svg.append( node("line", { class: "curve-grid", x1: x(hex - 1), x2: x(hex - 1), y1: padT, y2: y(0), }), ); } } svg.append( node("line", { class: "curve-axis", x1: padL, x2: width - padR, y1: y(0), y2: y(0), }), ); if (curve.some((damage) => damage > ceiling)) { svg.append( node("line", { class: "curve-ceiling", x1: padL, x2: width - padR, y1: padT, y2: padT, }), ); } // What each machine brings, under the total they add up to. Faint, and // marked at its own best rather than at the total's: a lance covers a board // when its members peak in different places, and that is only visible if // each one says where it peaks. for (const line of faint) { svg.append( node("polyline", { class: "curve-faint", points: line.map((damage, at) => `${x(at)},${y(damage)}`).join(" "), }), ); for (const [from, to] of bestHexes(line)) { // The ends of the run, the same as the total's own mark above it. for (const hex of from === to ? [from] : [from, to]) { svg.append( node("circle", { class: "curve-faint-dot", cx: x(hex - 1), cy: y(line[hex - 1] ?? 0), r: 2, }), ); } } } const points = curve.map((damage, at) => `${x(at)},${y(damage)}`).join(" "); svg.append( node("polygon", { class: "curve-fill", points: `${x(0)},${y(0)} ${points} ${x(REACH - 1)},${y(0)}`, }), ); // The best hexes: a band as tall as the curve where the best is a stretch, a // line down to the axis where it is one hex. Both land on the axis, which is // what ties the mark to the number written under it. // // Over the fill and under the line. Under the fill it kept its weight and // lost its colour - a fifth of an orange with the curve's own translucent // navy laid back over it is a grey box - and the band and the two dots // capping it have to read as one mark in one colour. for (const [from, to] of runs) { if (from === to) { svg.append( node("line", { class: "curve-peak-line", x1: x(from - 1), x2: x(from - 1), y1: y(curve[from - 1] ?? 0), y2: y(0), }), ); } else { const top = y(Math.max(...curve.slice(from - 1, to))); svg.append( node("rect", { class: "curve-peak-band", x: x(from - 1), y: top, width: x(to - 1) - x(from - 1), height: y(0) - top, }), ); svg.append( node("line", { class: "curve-peak-line", x1: x(from - 1), x2: x(to - 1), y1: y(0), y2: y(0), }), ); } } // Drawn in two weights: solid where the design lands something, faded over // the noughts at either end. A curve that stops has stopped, and the faded // run is what says where - a Bombard's blind first two hexes and the dead // board past anybody's longest gun are both flat nought rather than a // taper. The segments overlap by a hex so the join is not a gap. const live = liveSpan(curve); const last = curve.length - 1; const segment = (from: number, to: number) => curve .slice(from, to + 1) .map((damage, at) => `${x(from + at)},${y(damage)}`) .join(" "); if (!live) { svg.append( node("polyline", { class: "curve-line curve-line-dead", points }), ); } else { // The hex either side of the live stretch belongs to the solid line, not // to the faded one: the drop to nought is the design running out of range // and is the most interesting hex on that end of the chart. Faded it read // as a stray diagonal hanging off the line. What is faded is flat. const from = Math.max(0, live[0] - 1); const to = Math.min(last, live[1] + 1); if (from > 0) { svg.append( node("polyline", { class: "curve-line curve-line-dead", points: segment(0, from), }), ); } svg.append( node("polyline", { class: "curve-line", points: segment(from, to) }), ); if (to < last) { svg.append( node("polyline", { class: "curve-line curve-line-dead", points: segment(to, last), }), ); } } // The stretch between the two dots, in their colour rather than the line's. // The band, the dots and the run of line they cap are one mark, and a blue // line running through the middle of it broke it into three. for (const [from, to] of runs) { if (from === to) continue; svg.append( node("polyline", { class: "curve-line curve-peak-run", points: curve .slice(from - 1, to) .map((damage, at) => `${x(from - 1 + at)},${y(damage)}`) .join(" "), }), ); } const written = new Set(); for (const [from, to] of runs) { // The ends of the run and nothing between them: the band already says // that everything in it is the best, and a dot per hex made a five-hex // answer look like five separate ones. for (const hex of from === to ? [from] : [from, to]) { svg.append( node("circle", { class: "curve-peak-dot", cx: x(hex - 1), cy: y(curve[hex - 1] ?? 0), r: 3.5, }), ); } if (peak === "none") continue; // The hex it happens at, on the axis and in the mark's own colour, in // place of whichever fixed tick it lands on. const middle = (x(from - 1) + x(to - 1)) / 2; svg.append( node( "text", { class: "curve-peak-hex", x: Math.max(padL + 6, Math.min(middle, width - padR - 6)), y: height - 4, "text-anchor": "middle", }, from === to ? `${from}` : `${from}–${to}`, ), ); if (hexes) for (let hex = from - 2; hex <= to + 2; hex++) written.add(hex); } const [first] = runs; if (peak === "full" && first) { const most = Math.max(...curve); const [from, to] = first; const middle = (x(from - 1) + x(to - 1)) / 2; svg.append( node( "text", { class: "curve-peak-value", x: Math.max(padL + 8, Math.min(middle, width - padR - 10)), y: Math.max(y(Math.min(most, ceiling)) - 6, 10), "text-anchor": "middle", }, sayDamage(most), ), ); } if (hexes) { for (const hex of [1, 6, 12, 18, REACH]) { if (written.has(hex)) continue; svg.append( node( "text", { class: "curve-hex", x: x(hex - 1), y: height - 4, "text-anchor": hex === 1 ? "start" : hex === REACH ? "end" : "middle", }, String(hex), ), ); } } return svg; }