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