From 12b3a75997f41abd8376e12da5e5fff210dc0100 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 17:38:48 -0400 Subject: [PATCH] feat(forces): order the results, and let the figures carry the row A filtered search was alphabetical, so comparing eleven heavies meant reading every row; one control beside the count sorts by battle value, tonnage, damage, armour, range, movement or heat, each in the direction a player means by asking. The figures on a stat line are now a step brighter than the words they are measured in, the strip names the variant so two Anvils are two bars, and the results and the roster are lists rather than runs of divs. --- plan/forces.md | 9 ++ web/src/screens/hangar.ts | 222 +++++++++++++++++++++++++++++--------- web/src/styles.css | 37 +++++++ 3 files changed, 215 insertions(+), 53 deletions(-) diff --git a/plan/forces.md b/plan/forces.md index 22dc9a7..64aa082 100644 --- a/plan/forces.md +++ b/plan/forces.md @@ -235,6 +235,15 @@ where a player brings something. rather than what can be drawn: every design has a battle value, and the 99 with no `mekset.txt` entry keep the default silhouette MegaMek draws them with. Battle armour is the one class with art for every design. +- [x] **Order the results.** A filtered search came back alphabetically, so + comparing eleven heavies meant reading every row. One control beside the + count, and the direction is part of each choice rather than a second + button: battle value, tonnage, damage, armour, range and movement all + run biggest first, and the one a player wants the small end of is named + "Coolest". A design the index has no figure for sorts last either way, + because putting it at the top of "most armour" would be a lie about the + only thing the sort claims to know. + - [x] **A design says what it is on the row it is on.** The results and the roster showed a name, a role, a year, a tonnage and a battle value, and a player choosing between two heavies could not see that one of them diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index 2f1e5ec..a35affd 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -55,6 +55,8 @@ interface State { query: string; /** How many results are on screen; grows a page at a time. */ shown: number; + /** Which of `ORDERS` the results are in. */ + sort: string; nextId: number; /** * The camo every sprite on the screen is painted in, or null for the bare @@ -73,6 +75,56 @@ const NUMBER = new Intl.NumberFormat(); /** How many results are drawn before "Show more" is offered. */ const PAGE = 12; +/** + * The orders a list of designs can be put in, and which way each one runs. + * + * One control rather than two, because every one of these has an end a player + * means: nobody sorts by battle value to see the cheapest thing in the + * library first, and nobody sorts by heat to be shown the design that shuts + * itself down. The direction is part of the choice, so it is named here + * instead of being a second button that is wrong half the time. + * + * `name` is the ordering the search already produces — best match first, and + * alphabetical within a rank — so choosing it is choosing not to re-sort. + */ +const ORDERS: { + key: string; + label: string; + of?: (unit: Unit) => number | null; +}[] = [ + { key: "name", label: "Best match" }, + { key: "bv", label: "Battle value", of: (unit) => unit.bv }, + { key: "mass", label: "Tonnage", of: (unit) => unit.mass }, + { key: "damage", label: "Max damage", of: (unit) => unit.firepower }, + { key: "armour", label: "Armour", of: (unit) => unit.total_armor }, + { key: "range", label: "Max range", of: (unit) => unit.max_range }, + { key: "walk", label: "Walking MP", of: (unit) => unit.walk_mp }, + { key: "jump", label: "Jumping MP", of: (unit) => unit.jump_mp }, + { key: "coolest", label: "Coolest", of: (unit) => excessHeat(unit) }, +]; + +/** + * The list in one of those orders, biggest first — except the coolest, which + * is the one figure a player wants the small end of. + * + * A design the index has no figure for sorts last either way. It is not a + * zero, and putting it at the top of "most armour" would be a lie about the + * only thing the sort claims to know. + */ +function ordered(units: Unit[], key: string): Unit[] { + const order = ORDERS.find((one) => one.key === key); + if (!order?.of) return units; + const { of } = order; + const up = key === "coolest"; + return [...units].sort((a, b) => { + const left = of(a); + const right = of(b); + if (left === null) return right === null ? 0 : 1; + if (right === null) return -1; + return up ? left - right : right - left; + }); +} + /** How much accent each segment keeps, stepping down along the strip. */ const RAMP = [100, 86, 72, 58, 46, 36, 28]; @@ -211,12 +263,20 @@ function movement(unit: Unit): string { * `5/8/0`, `21 hex` — and a player who has not met them yet needs somewhere * to find out without leaving the screen. */ -function stat(text: string, means: string, tone = ""): HTMLElement { - return el("span", { - className: `hangar-stat${tone ? ` ${tone}` : ""}`, - textContent: text, - title: means, - }); +function stat( + figure: string, + label: string, + means: string, + tone = "", +): HTMLElement { + return el( + "span", + { className: `hangar-stat${tone ? ` ${tone}` : ""}`, title: means }, + [ + el("span", { className: "hangar-stat-n", textContent: figure }), + el("span", { textContent: ` ${label}` }), + ], + ); } /** @@ -272,7 +332,8 @@ function heatStat(unit: Unit): HTMLElement[] { return [ stat( - `${excess > 0 ? "+" : ""}${excess} max heat`, + `${excess > 0 ? "+" : ""}${excess}`, + "max heat", `${made} ${cost}. Moving is not counted.`, tone, ), @@ -299,12 +360,13 @@ function heatStat(unit: Unit): HTMLElement[] { */ function statLine(unit: Unit): HTMLElement[] { const carried = [ - stat(`${movement(unit)} MP`, "Movement points: walking, running, jumping"), + stat(movement(unit), "MP", "Movement points: walking, running, jumping"), ...(unit.total_armor === null ? [] : [ stat( - `${unit.total_armor} armour`, + `${unit.total_armor}`, + "armour", "Armour points over the whole machine", ), ]), @@ -314,7 +376,8 @@ function statLine(unit: Unit): HTMLElement[] { ? [] : [ stat( - `${NUMBER.format(unit.firepower)} max damage`, + NUMBER.format(unit.firepower), + "max damage", "Average damage if every weapon fires once. Punches, kicks and hatchets are not counted", ), ]), @@ -322,7 +385,8 @@ function statLine(unit: Unit): HTMLElement[] { ? [] : [ stat( - `${unit.max_range} max range`, + `${unit.max_range}`, + "max range", "The longest range any weapon carried reaches, in hexes", ), ]), @@ -364,6 +428,7 @@ export function hangarScreen(): Node[] { target: { ...NO_TARGET }, query: "", shown: PAGE, + sort: "name", nextId: 1, camo: null, paint: 0, @@ -376,6 +441,27 @@ export function hangarScreen(): Node[] { const roster = el("div", { className: "hangar-roster" }); const found = el("span", { className: "hint hangar-found" }); + /** + * What order the results come back in. + * + * Beside the count rather than above the list, because it answers the same + * question the count does — what am I looking at — and a filtered search + * that came back alphabetically was eleven heavies a player had to read + * every row of to compare. + */ + const sorter = el( + "select", + { className: "hangar-sort", "aria-label": "Order the results" }, + ORDERS.map((order) => + el("option", { value: order.key, textContent: order.label }), + ), + ) as HTMLSelectElement; + sorter.addEventListener("change", () => { + state.sort = sorter.value; + state.shown = PAGE; + drawResults(); + }); + const searchBox = el("input", { type: "search", className: "hangar-search", @@ -590,9 +676,20 @@ export function hangarScreen(): Node[] { className: "hangar-seg-name", textContent: pick.unit.chassis, }), + // The model first, because it is the half of the name the strip + // was missing: two Anvils of different variants drew as two bars + // reading "ANVIL / Skirmisher · 60t", and a segment clips from + // the right, so what a player needs to tell them apart has to be + // on the end that survives. el("span", { className: "hangar-seg-facts", - textContent: `${pick.unit.role ?? ""} · ${pick.unit.mass ?? 0}t`, + textContent: [ + pick.unit.model, + pick.unit.role, + `${pick.unit.mass ?? 0}t`, + ] + .filter(Boolean) + .join(" · "), }), el("span", { className: "hangar-seg-bv" }, [ el("strong", { textContent: NUMBER.format(bv) }), @@ -677,37 +774,41 @@ export function hangarScreen(): Node[] { } roster.replaceChildren( - ...state.picks.map((pick) => { - const drop = el("button", { - type: "button", - className: "secondary small-btn", - textContent: "Remove", - "aria-label": `Remove ${pick.unit.name}`, - }); - drop.addEventListener("click", () => { - remove(pick.id); - }); - return el("div", { className: "hangar-row" }, [ - sprite(pick.unit, 44, state), - el("div", { className: "hangar-row-name" }, [ - el("div", { className: "hangar-row-title" }, [ + el( + "ul", + { className: "hangar-row-list" }, + state.picks.map((pick) => { + const drop = el("button", { + type: "button", + className: "secondary small-btn", + textContent: "Remove", + "aria-label": `Remove ${pick.unit.name}`, + }); + drop.addEventListener("click", () => { + remove(pick.id); + }); + return el("li", { className: "hangar-row" }, [ + sprite(pick.unit, 44, state), + el("div", { className: "hangar-row-name" }, [ + el("div", { className: "hangar-row-title" }, [ + el("div", { + className: "hangar-chassis", + textContent: pick.unit.chassis, + }), + el("div", { + className: "hint hangar-model", + textContent: pick.unit.model ?? "", + }), + ]), el("div", { - className: "hangar-chassis", - textContent: pick.unit.chassis, - }), - el("div", { - className: "hint hangar-model", - textContent: pick.unit.model ?? "", + className: "hint hangar-row-facts", + textContent: `${pick.unit.mass ?? 0}t · ${movement(pick.unit)} · G4 P5 · ${NUMBER.format(pick.unit.bv ?? 0)} BV`, }), ]), - el("div", { - className: "hint hangar-row-facts", - textContent: `${pick.unit.mass ?? 0}t · ${movement(pick.unit)} · G4 P5 · ${NUMBER.format(pick.unit.bv ?? 0)} BV`, - }), - ]), - drop, - ]); - }), + drop, + ]); + }), + ), ); } @@ -794,7 +895,7 @@ export function hangarScreen(): Node[] { withinTarget(unit, state.target, now), ); - const hits = matches.slice(0, state.shown); + const hits = ordered(matches, state.sort).slice(0, state.shown); found.textContent = query || locked ? `${NUMBER.format(matches.length)} matching` @@ -812,16 +913,14 @@ export function hangarScreen(): Node[] { return; } - results.replaceChildren( - ...(query || locked - ? [] - : [ - el("p", { - className: "hint hangar-suggested", - textContent: "A few from each weight class — type to search.", - }), - ]), - ...hits.map((unit) => { + // A list, because that is what it is: a screen reader is told how many + // designs came back and where each one starts, which a run of divs cannot + // say. The "Show more" button is a sibling of the list rather than an item + // in it — it is not one of the results. + const list = el( + "ul", + { className: "hangar-hit-list" }, + hits.map((unit) => { const over = now.remaining !== null && (unit.bv ?? 0) > now.remaining; // A plus rather than the word. The row is five columns wide and the // stat lines wanted every pixel of the name column; the label was the @@ -837,7 +936,7 @@ export function hangarScreen(): Node[] { button.addEventListener("click", () => { add(unit); }); - return el("div", { className: "hangar-hit" }, [ + return el("li", { className: "hangar-hit" }, [ sprite(unit, SPRITE_W, state), el("div", { className: "hangar-hit-name" }, [ el("div", { @@ -871,6 +970,18 @@ export function hangarScreen(): Node[] { }), ); + results.replaceChildren( + ...(query || locked + ? [] + : [ + el("p", { + className: "hint hangar-suggested", + textContent: "A few from each weight class — type to search.", + }), + ]), + list, + ); + // The rest of them, a page at a time. Without this the count said 38 and // the list showed 12, with nothing to say where the other 26 went. if (matches.length > hits.length) { @@ -978,7 +1089,12 @@ export function hangarScreen(): Node[] { el("div", { className: "hangar-meter" }, [totals, strip, warning]), el("div", { className: "hangar-panes" }, [ el("section", { className: "card hangar-library" }, [ - el("div", { className: "hangar-search-row" }, [searchBox, help, found]), + el("div", { className: "hangar-search-row" }, [ + searchBox, + help, + sorter, + found, + ]), pills, builder, imposed, diff --git a/web/src/styles.css b/web/src/styles.css index c99ada9..f72b711 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -6478,6 +6478,18 @@ footer .debug { gap: 0.6rem; } +/* Beside the count, and sized to its longest option so the count does not + move when the order changes. */ +.hangar-sort { + padding: 0.3rem 0.4rem; + font-family: var(--font-text); + font-size: 0.8rem; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + .hangar-search { flex-grow: 1; padding: 0.45rem 0.6rem; @@ -6495,6 +6507,18 @@ footer .debug { gap: 0.35rem; } +/* The results and the roster are lists in the markup. The bullets and the + indent go; the column they were already drawn as stays. */ +.hangar-hit-list, +.hangar-row-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + .hangar-hit, .hangar-row { display: grid; @@ -6594,6 +6618,19 @@ footer .debug { white-space: nowrap; } +/* The figure in the page's ink and the word it is measured in a step back, so + a column of rows scans as numbers rather than as five short sentences. A + coloured chip keeps one colour throughout: the colour is the point there, + and splitting it would say the number and the word disagree. */ +.hangar-stat-n { + color: var(--ink); +} + +.hangar-stat-warm .hangar-stat-n, +.hangar-stat-hot .hangar-stat-n { + color: inherit; +} + /* Space alone did not separate them: every figure is a number and a word, so "5/8/3 MP 230 armour" read as one phrase. The dot is the same one the facts line above uses, and it belongs to the figure in front of it rather than -- 2.51.2