Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
17 kB · 495 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496/** * 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 `<svg>` 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<K extends keyof SVGElementTagNameMap>( tag: K, attributes: Record<string, string | number>, 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 `<svg>` 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<number>(); 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;}