From 76716d873ba4deaec3a7aa650a1f9de5bd4ea575 Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:06:06 -0500 Subject: [PATCH 1/9] Added players spotlight. 1. Adjusted LinesShines link. 2. Players spotlight and teams spotlight are union with each other. --- README.md | 6 +- frontend/app.js | 514 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 45 +++- frontend/style.css | 180 ++++++++++++++++ 4 files changed, 722 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index d2a0d71..a6bc4d9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ # 🏈LinesShines Β· ι‹’ε…‰ +[![main CI](https://github.com/StarsExpress/LinesShines/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/StarsExpress/LinesShines/actions) +[![main Black Linter](https://github.com/StarsExpress/LinesShines/actions/workflows/black-lint.yml/badge.svg?branch=main)](https://github.com/StarsExpress/LinesShines/actions) +[![Latest Release](https://img.shields.io/github/v/release/StarsExpress/LinesShines)](https://github.com/StarsExpress/LinesShines/releases) + ![Latest](https://img.shields.io/badge/Latest-Data-violet?labelColor=violet&style=flat) ![Season](https://img.shields.io/badge/Season-2025-crimson) ![Week](https://img.shields.io/badge/Week-18-gold) @@ -14,7 +18,7 @@ --- -### [πŸ”· Blue 80 Blue 80 🏟](https://lines-shines.up.railway.app) +### [πŸ”· Blue 80 Blue 80 🏟](https://www.lines-shines.com) ### [πŸ“— Background Stories ➑️️](https://starsexpress.github.io/Faraway-s-Way/linesshines/) diff --git a/frontend/app.js b/frontend/app.js index 20af846..c87f9b8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -32,6 +32,14 @@ const els = { teamsChecklist: document.getElementById("teams-checklist"), teamsSelectAll: document.getElementById("teams-select-all"), teamsSelectNone: document.getElementById("teams-select-none"), + playersControl: document.getElementById("players-control"), + playersBtn: document.getElementById("players-toggle-btn"), + playersSummary: document.getElementById("players-select-summary"), + playersPanel: document.getElementById("players-panel"), + playersField: document.getElementById("players-field"), + playersChips: document.getElementById("players-chips"), + playersInput: document.getElementById("players-input"), + playersDropdown: document.getElementById("players-dropdown"), chart: document.getElementById("chart"), chartPanel: document.querySelector(".chart-panel"), emptyState: document.getElementById("empty-state"), @@ -55,6 +63,31 @@ const sliceCache = new Map(); // key = `${category}:${season}:${position} let currentRecords = []; // records for the current slice (all threshold values) let currentFiltered = []; // records >= threshold (what the chart shows) +// Which category's schema currentRecords actually matches. Tracked +// separately from els.category.value because a pending (not-yet-Applied) +// category switch changes els.category.value immediately while +// currentRecords still holds the previous category's rows until Apply +// re-runs loadCurrentSlice() β€” code that reads currentRecords (the Players +// pool, see qualifyingPlayerPool()) needs the category that actually +// matches the data in hand, not the one the dropdown currently shows. +let currentSliceCategory = null; + +// Records for whatever category/season/position the controls are *currently +// set to* (pending, not necessarily Applied yet) β€” feeds only the Players +// search pool (qualifyingPlayerPool()), kept live by updatePlayerPool() on +// every category/season/position change so switching Position immediately +// changes which players the search box will suggest, rather than waiting +// for Apply the way the chart itself does. Deliberately separate from +// currentRecords/currentSliceCategory above, which stay Apply-gated. +let playerPoolRecords = []; +let playerPoolCategory = null; + +// Players filter: full player name ("player", not the abbreviated display +// name) β†’ record, in selection order. Live/pending like the Teams +// checklist β€” edited freely via chips, only takes effect on the chart once +// Apply snapshots it into appliedFilters.players (see currentFilterState()). +const selectedPlayers = new Map(); + // Snapshot of {category, season, position, xMetric, yMetric, threshold} the // chart was last actually rendered with. Every one of those controls can be // changed freely without touching the chart β€” render() and showScoutCard() @@ -125,6 +158,8 @@ async function loadMetadata() { populateTeamsChecklist(); attachEvents(); await loadCurrentSlice(); + playerPoolRecords = currentRecords; + playerPoolCategory = currentSliceCategory; appliedFilters = currentFilterState(); els.logoPreload.hidden = false; @@ -159,6 +194,7 @@ function currentFilterState() { // other primitive-valued control β€” two different array references // would never compare equal even with identical contents. teams: selectedTeamCodes().sort().join(","), + players: Array.from(selectedPlayers.keys()).sort().join(","), }; } @@ -269,6 +305,313 @@ function closeTeamsDropdown() { els.teamsBtn.setAttribute("aria-expanded", "false"); } +// --- Players autocomplete ----------------------------------------------- +// +// PFF's "Player" column is "{first} {last}" or "{first} {last} {suffix}", +// where either name can itself be multi-word ("Andrew Van Ginkel", "D.J. +// Wonnum"). Splitting on token count is ambiguous β€” a suffix whitelist is +// the only reliable signal, since a compound last name and a suffix both +// just look like "more tokens after the first". +const NAME_SUFFIXES = new Set(["Jr.", "Jr", "II", "III", "IV", "V", "Sr.", "Sr"]); + +function parsePlayerName(fullName) { + const parts = (fullName || "").split(" ").filter(Boolean); + let suffix = null; + if (parts.length >= 3 && NAME_SUFFIXES.has(parts[parts.length - 1])) { + suffix = parts.pop(); + } + const first = parts[0] || ""; + const last = parts.length > 1 ? parts.slice(1).join(" ") : ""; + return { first, last, suffix }; +} + +// Ratcliff/Obershelp ratio β€” same algorithm as Python's stdlib +// difflib.SequenceMatcher(None, a, b).ratio(), reimplemented here since +// there's no equivalent in the browser and pulling in a fuzzy-match +// dependency (Fuse.js etc.) for a fallback layer that only matters for +// typos is overkill. Only reached for short (name-token-length) strings, so +// the O(n*m) cost here is negligible. +function longestMatchSize(a, b, alo, ahi, blo, bhi) { + let besti = alo, bestj = blo, bestsize = 0; + let j2len = {}; + for (let i = alo; i < ahi; i++) { + const newJ2len = {}; + for (let j = blo; j < bhi; j++) { + if (a[i] === b[j]) { + const k = (j2len[j - 1] || 0) + 1; + newJ2len[j] = k; + if (k > bestsize) { + besti = i - k + 1; + bestj = j - k + 1; + bestsize = k; + } + } + } + j2len = newJ2len; + } + return [besti, bestj, bestsize]; +} + +function matchingCharCount(a, b) { + const queue = [[0, a.length, 0, b.length]]; + let total = 0; + while (queue.length) { + const [alo, ahi, blo, bhi] = queue.pop(); + const [i, j, k] = longestMatchSize(a, b, alo, ahi, blo, bhi); + if (k) { + total += k; + if (alo < i && blo < j) queue.push([alo, i, blo, j]); + if (i + k < ahi && j + k < bhi) queue.push([i + k, ahi, j + k, bhi]); + } + } + return total; +} + +function sequenceRatio(a, b) { + if (!a.length && !b.length) return 1; + return (2 * matchingCharCount(a, b)) / (a.length + b.length); +} + +// Layered match strategy (deliberately not Levenshtein β€” the wrong tool for +// prefix-driven autocomplete): a prefix match beats a substring match beats +// a token-prefix match beats a fuzzy/typo fallback. Returns a [layer, tiebreak] +// tuple (lower sorts first) or null for no match at all. Token split includes +// "-" (not just whitespace) so a query landing after the hyphen in a compound +// last name like "Norman-Lott" still hits Layer 2 as a token-prefix. +function matchScore(query, candidate) { + if (!query || !candidate) return null; + const q = query.toLowerCase(); + const c = candidate.toLowerCase(); + + if (c.startsWith(q)) return [0, c.length]; + + const idx = c.indexOf(q); + if (idx !== -1) return [1, idx]; + + const tokens = c.split(/[\s-]+/); + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].startsWith(q)) return [2, i]; + } + + const ratio = sequenceRatio(q, c); + if (ratio > 0.75) return [3, -ratio]; + + return null; +} + +function compareScores(a, b) { + return a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]; +} + +// Scores one player against every whitespace-split token of the query β€” a +// candidate passes if ANY token matches ANY of first/last/suffix (OR, not +// AND), so "will ander" and "ander jr" both hit "Will Anderson Jr." even +// though neither token alone is the full name. Ranking signals, in priority +// order (see searchPlayers' sort): totalHits (how many query tokens matched +// at all) > nameHits (how many matched first/last specifically β€” a suffix +// hit doesn't count here, which is what makes "jr" alone rank below a token +// that hit an actual name) > bestScore (the best individual matchScore +// across every matched token). +function scorePlayerAgainstQuery(queryTokens, playerRecord) { + const { first, last, suffix } = parsePlayerName(playerRecord.player); + + let totalHits = 0; + let nameHits = 0; + let bestScore = null; + + for (const token of queryTokens) { + const firstScore = matchScore(token, first); + const lastScore = matchScore(token, last); + const suffixScore = suffix ? matchScore(token, suffix) : null; + + const nameScores = [firstScore, lastScore].filter((s) => s !== null); + const allScores = [firstScore, lastScore, suffixScore].filter((s) => s !== null); + + if (allScores.length > 0) { + totalHits++; + if (nameScores.length > 0) nameHits++; + + const tokenBest = allScores.slice().sort(compareScores)[0]; + if (bestScore === null || compareScores(tokenBest, bestScore) < 0) { + bestScore = tokenBest; + } + } + } + + if (totalHits === 0) return null; + return { totalHits, nameHits, bestScore }; +} + +// playerPoolRecords/playerPoolCategory (rather than currentRecords/ +// currentSliceCategory) so this always reflects the pending category β€” +// updatePlayerPool() keeps both live on every category/season/position +// change, independent of whether that change has been Applied yet. +function qualifyingPlayerPool() { + if (!playerPoolCategory) return []; + const cat = metadata[playerPoolCategory]; + const minThreshold = Number(els.thresholdNumber.value); + return playerPoolRecords.filter((r) => r[cat.threshold_field] >= minThreshold); +} + +// Top `topK` matches for `query` among `pool`, excluding players already +// selected (no point suggesting a chip that already exists). Search runs +// against the full "player" field (e.g. "Will Anderson Jr."), never +// "abbr_name" ("W. Anderson Jr.") β€” abbr_name exists purely for chart-label +// rendering and would make a query like "will" fail to match. +function searchPlayers(query, pool, topK = 8) { + const queryTokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (queryTokens.length === 0) return []; + + const scored = pool + .filter((record) => !selectedPlayers.has(record.player)) + .map((record) => ({ record, result: scorePlayerAgainstQuery(queryTokens, record) })) + .filter((x) => x.result !== null); + + scored.sort((a, b) => { + if (a.result.totalHits !== b.result.totalHits) return b.result.totalHits - a.result.totalHits; + if (a.result.nameHits !== b.result.nameHits) return b.result.nameHits - a.result.nameHits; + const cmp = compareScores(a.result.bestScore, b.result.bestScore); + if (cmp !== 0) return cmp; + return a.record.player.localeCompare(b.record.player); + }); + + return scored.slice(0, topK).map((x) => x.record); +} + +function renderPlayerChips() { + els.playersChips.innerHTML = ""; + selectedPlayers.forEach((record, key) => { + const label = record.abbr_name || record.player; + const chip = document.createElement("span"); + chip.className = "player-chip"; + + const text = document.createElement("span"); + text.textContent = label; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "player-chip-remove"; + removeBtn.setAttribute("aria-label", `Remove ${label}`); + removeBtn.textContent = "Γ—"; + removeBtn.addEventListener("click", () => { + selectedPlayers.delete(key); + renderPlayerChips(); + updatePendingState(); + }); + + chip.append(text, removeBtn); + els.playersChips.appendChild(chip); + }); + updatePlayersSummary(); +} + +// Mirrors updateTeamsSummary() β€” the collapsed button's label, shown while +// .players-panel is closed so the chip list itself never has to fit inside +// the 150px button (see the control-players sizing comment above .control-players). +function updatePlayersSummary() { + const count = selectedPlayers.size; + if (count === 0) els.playersSummary.textContent = "No Players"; + else if (count === 1) { + const [[, record]] = selectedPlayers; + els.playersSummary.textContent = record.abbr_name || record.player; + } else els.playersSummary.textContent = `${count} Players`; +} + +function openPlayersPanel() { + els.playersPanel.hidden = false; + els.playersBtn.setAttribute("aria-expanded", "true"); + els.playersInput.focus(); +} + +function closePlayersPanel() { + els.playersPanel.hidden = true; + els.playersBtn.setAttribute("aria-expanded", "false"); + hidePlayersDropdown(); +} + +function hidePlayersDropdown() { + els.playersDropdown.hidden = true; + els.playersDropdown.innerHTML = ""; +} + +function renderPlayersDropdown(matches) { + els.playersDropdown.innerHTML = ""; + if (!matches.length) { + hidePlayersDropdown(); + return; + } + + matches.forEach((record) => { + const opt = document.createElement("button"); + opt.type = "button"; + opt.className = "player-option"; + + // Full name here (unlike the abbreviated chip label) β€” the dropdown is + // a disambiguation UI where "Anderson" alone could mean several + // players, so the full name plus team logo carries more identifying + // context than the compact "W. Anderson Jr." the chip uses once picked. + const name = document.createElement("span"); + name.className = "player-option-name"; + name.textContent = record.player; + + const team = document.createElement("span"); + team.className = "player-option-team"; + + const logo = document.createElement("img"); + logo.className = "player-option-logo"; + logo.src = logoSrc(record.team); + logo.alt = ""; + logo.loading = "lazy"; + logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); + + const code = document.createElement("span"); + code.textContent = record.team; + + team.append(logo, code); + opt.append(name, team); + opt.addEventListener("click", () => { + selectedPlayers.set(record.player, record); + renderPlayerChips(); + els.playersInput.value = ""; + hidePlayersDropdown(); + updatePendingState(); + els.playersInput.focus(); + }); + + els.playersDropdown.appendChild(opt); + }); + + els.playersDropdown.hidden = false; +} + +function runPlayersSearch() { + const query = els.playersInput.value.trim(); + if (!query) { + hidePlayersDropdown(); + return; + } + renderPlayersDropdown(searchPlayers(query, qualifyingPlayerPool())); +} + +// Called whenever the qualifying pool can have shrunk β€” live threshold +// edits, and live category/season/position changes via updatePlayerPool() +// (e.g. switching Position from ED to DI drops any ED-only chip immediately, +// since it's no longer in the new position's pool) β€” so a selected player +// who no longer clears the bar (or no longer exists in the new slice) +// silently loses their chip instead of lingering as a selection that can't +// actually take effect. +function prunePlayerSelections() { + const poolKeys = new Set(qualifyingPlayerPool().map((r) => r.player)); + let changed = false; + selectedPlayers.forEach((_, key) => { + if (!poolKeys.has(key)) { + selectedPlayers.delete(key); + changed = true; + } + }); + if (changed) renderPlayerChips(); +} + function filtersArePending() { if (!appliedFilters) return false; const current = currentFilterState(); @@ -330,12 +673,12 @@ function populateCategoryDependentControls() { els.thresholdFieldLabel.textContent = thresholdFieldLabel(cat); } -async function loadCurrentSlice() { - const category = els.category.value; - const season = Number(els.season.value); - const position = els.position.value; +// Shared by loadCurrentSlice() (Apply-gated, drives the chart) and +// updatePlayerPool() (live, drives only the Players search pool) β€” both +// just need the records for a given category/season/position, memoized in +// sliceCache so switching back to an already-seen combination is free. +async function fetchSlice(category, season, position) { const key = `${category}:${season}:${position}`; - if (!sliceCache.has(key)) { const url = `/api/${category}?season=${season}&position=${encodeURIComponent(position)}`; const res = await fetch(url); @@ -343,10 +686,37 @@ async function loadCurrentSlice() { const data = await res.json(); sliceCache.set(key, data.records || []); } - currentRecords = sliceCache.get(key); + return sliceCache.get(key); +} + +async function loadCurrentSlice() { + const category = els.category.value; + const season = Number(els.season.value); + const position = els.position.value; + currentRecords = await fetchSlice(category, season, position); + currentSliceCategory = category; updateThresholdRange(); } +// Mirrors loadCurrentSlice(), but for whatever category/season/position the +// controls are pending on right now, and never touches currentRecords/ +// currentSliceCategory/updateThresholdRange β€” those stay reserved for the +// last Applied slice the chart is actually showing. Fired on every +// category/season/position change (see attachEvents()) so the Players +// dropdown always searches the position currently selected, e.g. switching +// from ED to DI immediately drops ED-only players like Derick Hall from the +// suggestions and starts surfacing DI players like Dexter Lawrence instead, +// without waiting for Apply. +async function updatePlayerPool() { + const category = els.category.value; + const season = Number(els.season.value); + const position = els.position.value; + playerPoolRecords = await fetchSlice(category, season, position); + playerPoolCategory = category; + prunePlayerSelections(); + runPlayersSearch(); +} + function updateThresholdRange() { const cat = currentCategoryMeta(); const values = currentRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null); @@ -549,16 +919,54 @@ function computeKeptLabels(chartDiv, records, xKey, yKey, thresholdField, isDimm const DIM_OPACITY = { marker: 0.15, logo: 0.22, label: 0.12 }; const LABEL_ALPHA = 0.55; // normal (non-dimmed) player-name opacity +// Teams and Players both only dim, never exclude (see the isDimmed comment +// in render()), so unlike the old Teams-only subtitle this can't just count +// currentFiltered β€” a reader needs to know *why* a non-highlighted-team +// player might still be sitting on the chart. Falls back to the plain +// "N players β‰₯ threshold" line when nothing is actually being highlighted +// (all teams selected, no players added) so the common case stays terse. +function highlightSubtitle(cat, records, isDimmed, selectedTeams, selectedPlayerKeys, minThreshold) { + const fieldLabel = thresholdFieldLabel(cat); + const totalTeams = allTeamCodes().length; + const allTeamsSelected = selectedTeams.size === totalTeams; + + const parts = []; + if (allTeamsSelected) { + // Every team already selected β€” Players is the only real filter, no + // point naming "32 Teams". + } else if (selectedTeams.size === 0) { + parts.push("no teams"); + } else if (selectedTeams.size <= 2) { + parts.push(Array.from(selectedTeams).map(teamName).join(" + ")); + } else { + parts.push(`${selectedTeams.size} teams`); + } + + const playerRecords = records.filter((r) => selectedPlayerKeys.has(r.player)); + if (playerRecords.length) { + const names = playerRecords.map((r) => r.abbr_name || r.player); + parts.push(names.length <= 2 ? names.join(" + ") : `${names.length} players`); + } + + const highlightedCount = records.length - isDimmed.filter(Boolean).length; + const clause = parts.length ? parts.join(" + ") : "nothing"; + return `${records.length} players with at least ${minThreshold} ${fieldLabel}.`; +} + function render() { const cat = appliedCategoryMeta(); const minThreshold = Number(appliedFilters.threshold); const selectedTeams = new Set(appliedFilters.teams ? appliedFilters.teams.split(",") : []); + const selectedPlayerKeys = new Set(appliedFilters.players ? appliedFilters.players.split(",") : []); currentFiltered = currentRecords.filter((r) => r[cat.threshold_field] >= minThreshold); - // Empty selectedTeams (Teams β†’ None) has .has() return false for every - // team, which dims everyone uniformly β€” no special-casing needed. - const isDimmed = currentFiltered.map((r) => !selectedTeams.has(r.team)); + // Players joins Teams via OR β€” a player is highlighted if their team is + // selected OR they were explicitly added, so an explicitly-picked player + // off a dimmed team still stands out. Empty selectedTeams (Teams β†’ None) + // with no players picked has both .has() calls return false for every + // record, which dims everyone uniformly β€” no special-casing needed. + const isDimmed = currentFiltered.map((r) => !(selectedTeams.has(r.team) || selectedPlayerKeys.has(r.player))); if (currentFiltered.length < 2) { els.emptyState.hidden = false; @@ -675,8 +1083,7 @@ function render() { // so count shouldn't shrink just because some teams are unchecked. const positionLabel = (cat.positions && cat.positions[appliedFilters.position]) || appliedFilters.position; const titleText = `${appliedFilters.season} NFL ${positionLabel} ${xKey} & ${yKey}`; - const subtitleText = - `${currentFiltered.length} players with at least ${minThreshold} ${thresholdFieldLabel(cat)}.`; + const subtitleText = highlightSubtitle(cat, currentFiltered, isDimmed, selectedTeams, selectedPlayerKeys, minThreshold); const layout = { paper_bgcolor: "transparent", @@ -937,6 +1344,11 @@ async function applyFilters() { await loadCurrentSlice(); // may also reset/clamp the threshold controls } + // A season/position/category switch (or a threshold edit that slipped in + // without a live prune) can leave stale chips pointing at players outside + // the new qualifying pool β€” drop them before snapshotting into + // appliedFilters so the chart never highlights a player who isn't there. + prunePlayerSelections(); appliedFilters = currentFilterState(); closeFiltersDrawer(); render(); @@ -944,18 +1356,29 @@ async function applyFilters() { } function attachEvents() { - // Category/season/position/axes are all pending-only now: picking a new - // value just updates the control itself (plus, for category, the option - // lists that depend on it) and lights up Apply. Nothing fetches or - // re-renders until applyFilters() runs, so the user can change several of - // these together and commit them in one shot. + // Category/season/position/axes are all pending-only for the chart: picking + // a new value just updates the control itself (plus, for category, the + // option lists that depend on it) and lights up Apply β€” nothing fetches or + // re-renders the chart until applyFilters() runs, so the user can change + // several of these together and commit them in one shot. Category/season/ + // position additionally trigger updatePlayerPool() live (unlike the chart), + // since the Players search pool is cheap to keep in sync with whatever + // position is currently selected rather than making it wait for Apply too. els.category.addEventListener("change", () => { resetThresholdOnNextRange = true; populateCategoryDependentControls(); updatePendingState(); + updatePlayerPool(); }); - [els.season, els.position, els.xMetric, els.yMetric].forEach((el) => + [els.season, els.position].forEach((el) => + el.addEventListener("change", () => { + updatePendingState(); + updatePlayerPool(); + }) + ); + + [els.xMetric, els.yMetric].forEach((el) => el.addEventListener("change", updatePendingState) ); @@ -994,6 +1417,57 @@ function attachEvents() { if (!els.teamsControl.contains(e.target)) closeTeamsDropdown(); }); + // Players: collapsed behind a button + panel, same mechanism as Teams β€” + // the chip list stays hidden until opened so it never has to fit inside + // the collapsed button's width (see the .control-players comment in + // style.css). Pending-only like Teams otherwise: adding/removing a chip + // just updates the selection and lights up Apply; the chart doesn't + // re-highlight until applyFilters() runs. The search itself, though, + // reacts live (see qualifyingPlayerPool()/prunePlayerSelections()) since + // it's cheap client-side filtering against already-cached data, not a + // refetch. + els.playersBtn.addEventListener("click", () => { + if (els.playersPanel.hidden) openPlayersPanel(); + else closePlayersPanel(); + }); + + els.playersInput.addEventListener("input", runPlayersSearch); + + els.playersInput.addEventListener("focus", () => { + if (els.playersInput.value.trim()) runPlayersSearch(); + }); + + els.playersInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + // Autocomplete convention: Escape backs out one level at a time β€” + // first close just the suggestion list, and only close the whole + // panel if the suggestions were already closed. + if (!els.playersDropdown.hidden) hidePlayersDropdown(); + else closePlayersPanel(); + } else if (e.key === "Enter") { + // Autocomplete convention: Enter commits the top suggestion, same as + // a click on it. + e.preventDefault(); + const first = els.playersDropdown.querySelector(".player-option"); + if (first) first.click(); + } + }); + + // Same e.isTrusted guard as the Teams/filters-drawer listeners β€” see the + // comment above the Teams one for why. Uses composedPath() rather than + // playersControl.contains(e.target): picking a suggestion calls + // hidePlayersDropdown(), which clears the suggestion list's innerHTML + // (removing the very button just clicked) before this bubbled listener + // runs β€” contains() on a now-detached node always returns false, which + // was slamming the whole panel shut on every single pick. composedPath() + // is captured at dispatch time, before that mutation, so it still + // includes playersControl. + document.addEventListener("click", (e) => { + if (!e.isTrusted) return; + if (els.playersPanel.hidden) return; + if (!e.composedPath().includes(els.playersControl)) closePlayersPanel(); + }); + // Dragging the slider or typing a number only updates these two controls' // own displayed values β€” the chart holds its current state until the user // clicks Apply (or presses Enter in the number field). Re-rendering on @@ -1005,6 +1479,11 @@ function attachEvents() { // category switch stomp it back to that category's default at Apply // time (see the resetThresholdOnNextRange comment above its declaration). resetThresholdOnNextRange = false; + // The Players pool is threshold-gated live (not just at Apply) β€” see + // qualifyingPlayerPool() β€” so a chip that no longer clears the new + // value should disappear the moment the slider moves, not linger until + // Apply. + prunePlayerSelections(); updatePendingState(); }); @@ -1022,6 +1501,7 @@ function attachEvents() { // visually β€” filtering below still uses the exact typed number. els.threshold.value = min + Math.round((raw - min) / step) * step; resetThresholdOnNextRange = false; + prunePlayerSelections(); updatePendingState(); }); diff --git a/frontend/index.html b/frontend/index.html index 53799f3..fd2d565 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ LinesShines Β· ι‹’ε…‰ - + @@ -80,6 +80,41 @@

LinesShines Β· ι‹’ε…‰

+ +
+ +
+ + +
+
+
has its own UA intrinsic + width (~160px+) that isn't capped by flex-basis when this box's own + width is auto, which used to make Players noticeably wider than its + neighbors before the chip list moved into its own panel. Fixed at the + same 150px explicitly so the collapsed button can't inflate. */ +.control-players { position: relative; width: 150px; } + +.players-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + width: 300px; + background: var(--turf-800); + border: 1px solid var(--turf-600); + border-radius: 8px; + padding: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.players-panel[hidden] { display: none; } + +.players-field { + box-sizing: border-box; + min-height: 38px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + width: 100%; + background: var(--turf-950); + border: 1px solid var(--turf-600); + border-radius: 6px; + padding: 5px 10px; +} +.players-field:focus-within { + border-color: var(--flag-gold); +} + +.players-chips { + display: contents; /* chips lay out directly in .players-field's flex row, alongside the input */ +} + +.player-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--turf-700); + border: 1px solid var(--turf-600); + border-radius: 999px; + padding: 3px 4px 3px 10px; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--chalk); + white-space: nowrap; +} +.player-chip-remove { + appearance: none; + -webkit-appearance: none; + background: transparent; + border: none; + color: var(--chalk-dim); + font-size: 14px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; + border-radius: 50%; +} +.player-chip-remove:hover, +.player-chip-remove:focus-visible { + color: var(--flag-gold-bright); + background: rgba(241, 236, 221, 0.08); + outline: none; +} + +.players-input { + flex: 1 1 90px; + min-width: 90px; + background: transparent; + border: none; + color: var(--chalk); + font-family: var(--font-body); + font-size: 14px; + padding: 4px 2px; +} +.players-input:focus { outline: none; } +.players-input::placeholder { color: var(--chalk-dim); } + +/* Nested inside .players-panel now (not its own floating box) β€” just the + scrollable suggestion list under the chip field, separated by a divider + whenever it actually has matches to show. */ +.players-dropdown { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--turf-600); + max-height: 280px; + overflow-y: auto; +} +.players-dropdown[hidden] { display: none; } + +.player-option { + appearance: none; + -webkit-appearance: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + background: transparent; + border: none; + border-radius: 5px; + padding: 6px 8px; + font-family: var(--font-body); + font-size: 13px; + color: var(--chalk); + text-align: left; + cursor: pointer; +} +.player-option:hover, +.player-option:focus-visible { + background: rgba(241, 236, 221, 0.08); + outline: none; +} +.player-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.player-option-team { + flex: none; + display: flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.05em; + color: var(--chalk-dim); +} +.player-option-logo { + flex: none; + width: 16px; + height: 16px; + object-fit: contain; +} + /* Governs every filter control now (category/season/position/axes/threshold all sit "pending" in their own inputs until this is clicked), so it's styled as the form's one primary action rather than tucked next to the @@ -544,6 +715,10 @@ body { } #chart { width: 100%; height: 720px; } +@media (min-width: 861px) { + .chart-panel { min-height: 800px; } + #chart { height: 800px; } +} /* The actual hit-testing layer is Plotly's .nsewdrag overlay rect, which sits above every marker and would otherwise keep its own injected crosshair cursor (a leftover affordance for drag-to-zoom, which is off @@ -868,6 +1043,11 @@ body { most of a phone screen before the chart even starts. */ .controls-toggle { display: block; margin-bottom: 14px; } + /* Players is a deliberate desktop/tablet-only feature (see the comment on + .control-players above) β€” no mobile fallback, no read-only messaging, + it's simply absent from the mobile drawer. */ + .control-players { display: none; } + .filters-drawer { position: fixed; bottom: 0; From c48245d690f6c164e9b54f514cc458b86de1f696 Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:07:38 -0500 Subject: [PATCH 2/9] Added CI to dev branch. --- .github/workflows/black-lint.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/black-lint.yml b/.github/workflows/black-lint.yml index 036e2e9..17fe29d 100644 --- a/.github/workflows/black-lint.yml +++ b/.github/workflows/black-lint.yml @@ -2,7 +2,7 @@ name: black-linter on: push: - branches: [main] + branches: [main, dev] paths: - '**/*.py' pull_request: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c54e4..5a23233 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: jobs: From 158f64d33d13c876ce9e067aa0f98f6bbf017051 Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:34:47 -0500 Subject: [PATCH 3/9] Adjusted workflows triggers. --- .github/workflows/black-lint.yml | 1 + .github/workflows/ci.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/black-lint.yml b/.github/workflows/black-lint.yml index 17fe29d..0c71aef 100644 --- a/.github/workflows/black-lint.yml +++ b/.github/workflows/black-lint.yml @@ -6,6 +6,7 @@ on: paths: - '**/*.py' pull_request: + branches: [main] paths: - '**/*.py' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a23233..3cf89d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main, dev] pull_request: + branches: [main] jobs: test: From 9ecd3f97cf0f71c9268eb0074ccec44caeea395d Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:46:55 -0500 Subject: [PATCH 4/9] Allowed multi cards and multi player selection. --- .github/workflows/black-lint.yml | 2 +- frontend/app.js | 309 +++++++++++++++++++------------ frontend/index.html | 53 +++--- frontend/style.css | 114 ++++++++---- 4 files changed, 307 insertions(+), 171 deletions(-) diff --git a/.github/workflows/black-lint.yml b/.github/workflows/black-lint.yml index 0c71aef..8a07147 100644 --- a/.github/workflows/black-lint.yml +++ b/.github/workflows/black-lint.yml @@ -1,4 +1,4 @@ -name: black-linter +name: Black Lint on: push: diff --git a/frontend/app.js b/frontend/app.js index c87f9b8..15d357b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -33,6 +33,7 @@ const els = { teamsSelectAll: document.getElementById("teams-select-all"), teamsSelectNone: document.getElementById("teams-select-none"), playersControl: document.getElementById("players-control"), + playersResetBtn: document.getElementById("players-reset-btn"), playersBtn: document.getElementById("players-toggle-btn"), playersSummary: document.getElementById("players-select-summary"), playersPanel: document.getElementById("players-panel"), @@ -46,16 +47,9 @@ const els = { logoPreload: document.getElementById("logo-preload"), filtersToggle: document.getElementById("toggle-filters"), filtersDrawer: document.getElementById("filters-drawer"), - scoutCard: document.getElementById("scout-card"), - scoutEmpty: document.getElementById("scout-card-empty"), - scoutBody: document.getElementById("scout-card-body"), - scoutLogo: document.getElementById("scout-logo"), - scoutBadge: document.getElementById("scout-badge"), - scoutName: document.getElementById("scout-name"), - scoutMeta: document.getElementById("scout-meta"), - scoutStats: document.getElementById("scout-stats"), - scoutClose: document.getElementById("scout-close"), - scoutDragHandle: document.getElementById("scout-drag-handle"), + scoutCards: document.getElementById("scout-cards"), + scoutEmptyHint: document.getElementById("scout-empty-hint"), + scoutCardTemplate: document.getElementById("scout-card-template"), }; let metadata = null; // /api/metadata payload @@ -90,13 +84,25 @@ const selectedPlayers = new Map(); // Snapshot of {category, season, position, xMetric, yMetric, threshold} the // chart was last actually rendered with. Every one of those controls can be -// changed freely without touching the chart β€” render() and showScoutCard() +// changed freely without touching the chart β€” render() and openScoutCard() // read from this snapshot, never live off the controls directly β€” so the // Apply button is what commits a batch of changes together, and an // unrelated render() trigger (the label/logo toggles) can't accidentally // leak in a half-picked axis or threshold that hasn't been applied yet. let appliedFilters = null; -let pinnedIndex = null; + +// Live scouting cards, one per pinned player, keyed by the same full +// "player" string selectedPlayers/prunePlayerSelections use elsewhere β€” +// player.player uniquely identifies a row within a slice. Each entry is +// { record, el } where el is the cloned .scout-card DOM node currently +// sitting in #scout-cards. Any number can be open/dragged/overlapping at +// once; see openScoutCard()/closeScoutCard() below. +const scoutCards = new Map(); +// Shared incrementing counter so whichever card was most recently opened, +// clicked, or dragged gets bumped above every other open card β€” otherwise +// overlapping cards would stack in open-order forever with no way to bring +// an older one back to the front. +let scoutZCounter = 10; let logoRelayoutGuard = false; // suppresses our own relayout from re-triggering itself // True right after page load and right after a category switch β€” both cases @@ -572,10 +578,14 @@ function renderPlayersDropdown(matches) { opt.addEventListener("click", () => { selectedPlayers.set(record.player, record); renderPlayerChips(); - els.playersInput.value = ""; - hidePlayersDropdown(); updatePendingState(); els.playersInput.focus(); + // Keep the query text and dropdown alive instead of clearing/closing β€” + // searchPlayers() already excludes just-picked players, so re-running + // it surfaces the next-best matches for the same query (e.g. picking + // "Chris Jones" out of a "Jones" search leaves DaQuan/Travis Jones in + // the list) without the user having to retype the query per pick. + runPlayersSearch(); }); els.playersDropdown.appendChild(opt); @@ -813,7 +823,7 @@ function teamName(code) { } // Fallback swatch for the teams-dropdown checklist when a team's logo file -// 404s β€” mirrors showScoutCard()'s scoutLogo.onerror treatment. +// 404s β€” mirrors openScoutCard()'s logoImg.onerror treatment. function teamSwatch(code) { const span = document.createElement("span"); span.className = "team-swatch"; @@ -859,8 +869,8 @@ function computeLogoImages(chartDiv, records, xKey, yKey, isDimmed) { // overlap one already kept, blank the rest. // `isDimmed` (aligned index-for-index with `records`) pushes every // highlighted (non-dimmed) player's label ahead of every dimmed player's, -// regardless of threshold_field β€” a Teams selection should never lose its -// own labels to a bigger name outside the selection. +// regardless of threshold_field, so a Teams/Players selection never loses +// its own labels to a bigger name outside the selection. function computeKeptLabels(chartDiv, records, xKey, yKey, thresholdField, isDimmed) { const fullLayout = chartDiv._fullLayout; const xAxis = fullLayout && fullLayout.xaxis; @@ -1101,7 +1111,7 @@ function render() { xanchor: "center", subtitle: { text: subtitleText, - font: { family: "IBM Plex Mono, monospace", size: isMobile ? 9 : 12, color: "#a9b6a9" }, + font: { family: "IBM Plex Mono, monospace", size: isMobile ? 9 : 12, color: "#f1ecdd" }, }, }, dragmode: false, @@ -1180,41 +1190,86 @@ function render() { els.chart.on("plotly_click", (e) => { const idx = e.points[0].pointIndex; - if (pinnedIndex === idx) { - pinnedIndex = null; - resetScoutCard(); + const record = currentFiltered[idx]; + if (scoutCards.has(record.player)) { + closeScoutCard(record.player); } else { - pinnedIndex = idx; - showScoutCard(currentFiltered[idx]); + openScoutCard(record); } }); } -function showScoutCard(record) { +// Below 860px scouting cards are static blocks stacked under the chart (see +// the @media (max-width: 860px) rules in style.css), not floating overlays +// β€” dragging/cascading only makes sense above that breakpoint, same cutoff +// targetLogoPx() already uses for the desktop/mobile split. +function isDesktopScoutLayout() { + return window.innerWidth >= 860; +} + +function updateScoutEmptyHint() { + els.scoutEmptyHint.hidden = scoutCards.size > 0; +} + +// Every open card gets a higher inline z-index than anything opened, +// clicked, or dragged before it, so the one the user is currently paying +// attention to always renders on top of any it overlaps. +function bringScoutCardToFront(cardEl) { + scoutZCounter += 1; + cardEl.style.zIndex = String(scoutZCounter); +} + +// The first card opened keeps the CSS default top-right anchor (top:24, +// right:24) β€” same spot the single card always used to appear. Every card +// after that gets an explicit inline left/top, nudged down-left a bit +// further per already-open card, so opening several in a row fans them out +// instead of stacking them exactly on top of each other. They're still +// fully draggable afterward, and dragging one on top of another is exactly +// the overlap the user asked to allow. +const SCOUT_CASCADE_STEP = 28; +const SCOUT_CASCADE_WRAP = 8; // wrap the offset so a long run of opens can't drift off-panel +function cascadeScoutCardPosition(cardEl) { + if (scoutCards.size === 0 || !isDesktopScoutLayout()) return; + const panelRect = els.chartPanel.getBoundingClientRect(); + const cardRect = cardEl.getBoundingClientRect(); + const offset = (scoutCards.size % SCOUT_CASCADE_WRAP) * SCOUT_CASCADE_STEP; + const maxLeft = Math.max(panelRect.width - cardRect.width, 0); + const maxTop = Math.max(panelRect.height - cardRect.height, 0); + cardEl.style.left = `${Math.min(Math.max(panelRect.width - cardRect.width - 24 - offset, 0), maxLeft)}px`; + cardEl.style.top = `${Math.min(24 + offset, maxTop)}px`; + cardEl.style.right = "auto"; +} + +function openScoutCard(record) { const cat = appliedCategoryMeta(); - els.scoutCard.classList.add("is-active"); - els.scoutEmpty.hidden = true; - els.scoutBody.hidden = false; + const cardEl = els.scoutCardTemplate.content.firstElementChild.cloneNode(true); + + const closeBtn = cardEl.querySelector(".scout-close"); + const dragHandle = cardEl.querySelector(".scout-drag-handle"); + const logoImg = cardEl.querySelector(".scout-logo"); + const badge = cardEl.querySelector(".scout-badge"); + const nameEl = cardEl.querySelector(".scout-name"); + const metaEl = cardEl.querySelector(".scout-meta"); + const statsEl = cardEl.querySelector(".scout-stats"); const color = teamColor(record.team); - els.scoutLogo.src = logoSrc(record.team); - els.scoutLogo.alt = `${record.team} logo`; - els.scoutLogo.hidden = false; - els.scoutBadge.hidden = true; - els.scoutLogo.onerror = () => { - els.scoutLogo.hidden = true; - els.scoutBadge.hidden = false; - els.scoutBadge.textContent = record.team; - els.scoutBadge.style.background = color; + logoImg.src = logoSrc(record.team); + logoImg.alt = `${record.team} logo`; + logoImg.hidden = false; + badge.hidden = true; + logoImg.onerror = () => { + logoImg.hidden = true; + badge.hidden = false; + badge.textContent = record.team; + badge.style.background = color; }; - els.scoutName.textContent = record.player; - els.scoutMeta.textContent = `${teamName(record.team)} Β· ${record.position}`; + nameEl.textContent = record.player; + metaEl.textContent = `${teamName(record.team)} Β· ${record.position}`; const xKey = appliedFilters.xMetric; const yKey = appliedFilters.yMetric; - els.scoutStats.innerHTML = ""; // Three grid children per row (dt, value dd, rank dd) so the grid's // row-major auto-placement stays aligned β€” a row that only emitted two // children when it has no rank would shift every following row's columns. @@ -1230,9 +1285,9 @@ function showScoutCard(record) { const rankDd = document.createElement("dd"); rankDd.className = "scout-stat-rank"; rankDd.textContent = rankText || "β€”"; - els.scoutStats.appendChild(dt); - els.scoutStats.appendChild(dd); - els.scoutStats.appendChild(rankDd); + statsEl.appendChild(dt); + statsEl.appendChild(dd); + statsEl.appendChild(rankDd); }; // Games and the threshold field (PR Opp / Non Spike PB Snaps) are volume @@ -1246,47 +1301,65 @@ function showScoutCard(record) { const rankText = rank ? `#${rank.rank}/${rank.n} Β· ${ordinal(rank.percentile)} pct` : ""; addRow(key, formatValue(record[key], meta), key === xKey || key === yKey, rankText); }); + + els.scoutCards.appendChild(cardEl); + cascadeScoutCardPosition(cardEl); // reads scoutCards.size, so must run before scoutCards.set() below + cardEl.classList.add("is-active"); + bringScoutCardToFront(cardEl); + + closeBtn.addEventListener("click", () => closeScoutCard(record.player)); + dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); + dragHandle.addEventListener("pointermove", onScoutDragMove); + dragHandle.addEventListener("pointerup", endScoutDrag); + dragHandle.addEventListener("pointercancel", endScoutDrag); + // Raises a card even on a plain click, not just a drag, so tapping an + // overlapped card's stats (not just its drag handle) also brings it front. + cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); + + scoutCards.set(record.player, { record, el: cardEl }); + updateScoutEmptyHint(); } -function resetScoutCard() { - els.scoutCard.classList.remove("is-active"); - els.scoutEmpty.hidden = false; - els.scoutBody.hidden = true; - // Drop any position a drag left behind so the card starts back at its - // default top-right corner next time it's opened, rather than wherever - // the user last dragged it. - clearScoutCardDragPosition(); +function closeScoutCard(key) { + const entry = scoutCards.get(key); + if (!entry) return; + entry.el.remove(); + scoutCards.delete(key); + updateScoutEmptyHint(); } -// Below 860px the scouting card is a static block stacked under the chart -// (see the @media (max-width: 860px) rules in style.css), not a floating -// overlay β€” dragging only makes sense above that breakpoint, same cutoff -// targetLogoPx() already uses for the desktop/mobile split. -function isDesktopScoutLayout() { - return window.innerWidth >= 860; +function closeAllScoutCards() { + scoutCards.forEach((entry) => entry.el.remove()); + scoutCards.clear(); + updateScoutEmptyHint(); } -// Inline left/top (set by dragging) sit at higher specificity than the -// mobile media query's `top: auto; right: auto;` reset, so they'd otherwise -// survive a resize down to mobile and break the stacked layout. Clearing -// them lets the stylesheet's position rules take back over. -function clearScoutCardDragPosition() { - els.scoutCard.style.left = ""; - els.scoutCard.style.top = ""; - els.scoutCard.style.right = ""; +// Inline left/top (set by dragging or cascadeScoutCardPosition) sit at +// higher specificity than the mobile media query's `top: auto; right: auto;` +// reset, so they'd otherwise survive a resize down to mobile and break the +// stacked layout. Clearing them lets the stylesheet's position rules take +// back over for every currently-open card. +function clearScoutCardDragPositions() { + scoutCards.forEach((entry) => { + entry.el.style.left = ""; + entry.el.style.top = ""; + entry.el.style.right = ""; + }); } -// Drag state for the one pointer currently moving the card, or null. Only -// one drag can be in progress at a time β€” the pointerId lets move/end -// handlers ignore any other pointer that fires while a drag is active -// (e.g. a second touch point). +// Drag state for the one pointer currently moving a card, or null. Only one +// drag can be in progress at a time β€” the pointerId lets move/end handlers +// ignore any other pointer that fires while a drag is active (e.g. a second +// touch point) β€” but which card it's moving is per-drag, so several cards +// can each be dragged in turn without interfering with each other. let scoutDragState = null; -function beginScoutDrag(e) { +function beginScoutDrag(e, cardEl) { if (!isDesktopScoutLayout()) return; const panelRect = els.chartPanel.getBoundingClientRect(); - const cardRect = els.scoutCard.getBoundingClientRect(); + const cardRect = cardEl.getBoundingClientRect(); scoutDragState = { + cardEl, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, @@ -1299,11 +1372,12 @@ function beginScoutDrag(e) { }; // Switch from the default top/right anchor to an explicit left/top so // the card can move freely; keeps it exactly where it already was. - els.scoutCard.style.left = `${scoutDragState.startLeft}px`; - els.scoutCard.style.top = `${scoutDragState.startTop}px`; - els.scoutCard.style.right = "auto"; - els.scoutCard.classList.add("is-dragging"); - els.scoutDragHandle.setPointerCapture(e.pointerId); + cardEl.style.left = `${scoutDragState.startLeft}px`; + cardEl.style.top = `${scoutDragState.startTop}px`; + cardEl.style.right = "auto"; + cardEl.classList.add("is-dragging"); + bringScoutCardToFront(cardEl); + e.currentTarget.setPointerCapture(e.pointerId); } function onScoutDragMove(e) { @@ -1312,14 +1386,14 @@ function onScoutDragMove(e) { const dy = e.clientY - scoutDragState.startY; const left = Math.min(Math.max(scoutDragState.startLeft + dx, 0), scoutDragState.maxLeft); const top = Math.min(Math.max(scoutDragState.startTop + dy, 0), scoutDragState.maxTop); - els.scoutCard.style.left = `${left}px`; - els.scoutCard.style.top = `${top}px`; + scoutDragState.cardEl.style.left = `${left}px`; + scoutDragState.cardEl.style.top = `${top}px`; } function endScoutDrag(e) { if (!scoutDragState || e.pointerId !== scoutDragState.pointerId) return; - els.scoutDragHandle.releasePointerCapture(e.pointerId); - els.scoutCard.classList.remove("is-dragging"); + e.currentTarget.releasePointerCapture(e.pointerId); + scoutDragState.cardEl.classList.remove("is-dragging"); scoutDragState = null; } @@ -1339,8 +1413,7 @@ async function applyFilters() { els.position.value !== appliedFilters.position; if (sliceChanged) { - pinnedIndex = null; - resetScoutCard(); + closeAllScoutCards(); await loadCurrentSlice(); // may also reset/clamp the threshold controls } @@ -1431,6 +1504,18 @@ function attachEvents() { else closePlayersPanel(); }); + // Mirrors Teams' None button, but scoped to Players' own chip set rather + // than a shared checklist β€” clears every selected player in one click + // regardless of whether the panel is open, then re-runs the live search + // so any just-cleared player can immediately reappear as a suggestion. + els.playersResetBtn.addEventListener("click", () => { + if (!selectedPlayers.size) return; + selectedPlayers.clear(); + renderPlayerChips(); + updatePendingState(); + runPlayersSearch(); + }); + els.playersInput.addEventListener("input", runPlayersSearch); els.playersInput.addEventListener("focus", () => { @@ -1569,54 +1654,42 @@ function attachEvents() { els.labelsToggle.addEventListener("change", render); els.logosToggle.addEventListener("change", render); - els.scoutClose.addEventListener("click", () => { - pinnedIndex = null; - resetScoutCard(); - }); - - // Pointer capture on the handle itself means move/up keep firing on it - // even once the pointer strays outside the card during a fast drag β€” no - // document-level listeners needed. - els.scoutDragHandle.addEventListener("pointerdown", beginScoutDrag); - els.scoutDragHandle.addEventListener("pointermove", onScoutDragMove); - els.scoutDragHandle.addEventListener("pointerup", endScoutDrag); - els.scoutDragHandle.addEventListener("pointercancel", endScoutDrag); + // Per-card close/drag listeners are wired up inside openScoutCard() itself + // β€” each cloned card owns its own close button and drag handle β€” so + // there's no single static card to attach listeners to here anymore. // A drag position is only valid in the desktop overlay layout β€” resizing - // past the breakpoint mid-session (or a device rotation) needs the same - // cleanup resetScoutCard() does on close, see clearScoutCardDragPosition(). + // past the breakpoint mid-session (or a device rotation) needs every + // currently-open card's inline position stripped so the mobile stacked + // layout can take back over, see clearScoutCardDragPositions(). window.addEventListener("resize", () => { - if (!isDesktopScoutLayout()) clearScoutCardDragPosition(); + if (!isDesktopScoutLayout()) clearScoutCardDragPositions(); }); - // Closes the pinned scouting card when clicking anywhere outside both - // the chart and the card itself. - // - // `e.isTrusted` guards both this and the filters-drawer listener below - // against Plotly.downloadImage()'s internal implementation: it builds a - // throwaway , appends it to , and calls .click() on it to - // trigger browser for saving dialog. That programmatic click bubbles to - // document as a real "click" event with a target outside every one of - // our containers, which β€” without this guard β€” closed the scouting card - // and, worse, closed the mobile filters drawer immediately after Save - // Plot, even though Save Plot is supposed to leave the drawer open for - // repeated exports. Synthetic (script-dispatched) events always report - // isTrusted: false, so filtering on it distinguishes Plotly's anchor - // click from an actual user tap outside the drawer/card. - document.addEventListener("click", (e) => { - if (!e.isTrusted) return; - if (pinnedIndex == null) return; - if (!els.chart.contains(e.target) && !els.scoutCard.contains(e.target)) { - pinnedIndex = null; - resetScoutCard(); - } - }); + // Deliberately no "click outside closes the card" handler here, unlike the + // Teams/Players dropdowns and the filters drawer below. Those are + // transient single-purpose overlays where dismissing on an outside click + // is the expected pattern; scouting cards are meant to stay pinned β€” any + // number open at once, see openScoutCard() β€” until the user explicitly + // hits a card's own Γ—. Auto-closing all of them on an incidental click + // elsewhere on the chart would undercut the whole point of letting + // several stay open side by side. els.filtersToggle.addEventListener("click", () => { const isOpen = els.filtersDrawer.classList.toggle("open"); els.filtersToggle.setAttribute("aria-expanded", String(isOpen)); }); + // Same e.isTrusted guard as the Teams/Players dropdown listeners above β€” + // it protects against Plotly.downloadImage()'s internal implementation: + // it builds a throwaway , appends it to , and calls .click() on + // it to trigger the browser's save dialog. That programmatic click + // bubbles to document as a real "click" event with a target outside the + // drawer, which β€” without this guard β€” closed the mobile filters drawer + // immediately after Save Plot, even though Save Plot is supposed to leave + // the drawer open for repeated exports. Synthetic (script-dispatched) + // events always report isTrusted: false, so filtering on it distinguishes + // Plotly's anchor click from an actual user tap outside the drawer. document.addEventListener("click", (e) => { if (!e.isTrusted) return; if (!els.filtersDrawer.classList.contains("open")) return; diff --git a/frontend/index.html b/frontend/index.html index fd2d565..3da1929 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -93,7 +93,10 @@

LinesShines Β· ι‹’ε…‰

control-bar sizing gotcha in CLAUDE.md) every time a player is added. -->
- +
+ + +
-
- -

- Source: PFF Premium Stats. Data is never published. - Median lines are calculated according to current filter. -

diff --git a/frontend/style.css b/frontend/style.css index b993d9a..a897588 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -60,7 +60,6 @@ body { .hero { padding: 28px 24px 20px; - border-bottom: 1px solid var(--turf-600); } .hero-inner { @@ -127,6 +126,49 @@ body { margin: 0; } +/* Sits in the gap between the hero and the filter panel β€” its own + presence (generous padding + no bordering
s either side) reads as + the divider, rather than a rule bracketing empty space. Each segment is + its own nowrap flex item, with the following separator baked into the + segment's own text (see index.html) rather than a standalone item, so a + wrap always lands *before* a "Β· phrase" pair β€” never leaves a bare dot + stranded at the end of the previous line. Plain flex-wrap handles every + breakpoint in the spec (one line / two / three) with no explicit + per-breakpoint markup: each segment's rendered width decides whether it + still fits the current line. The one thing flex-wrap *can't* self-heal + is a single segment wider than the viewport itself β€” see the phone-only + font/padding override below for why that needs its own rule. */ +.meta-band { + display: flex; + flex-wrap: wrap; + justify-content: center; + row-gap: 4px; + margin: 0; + padding: 30px 24px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.03em; + color: var(--chalk-dim); + text-align: center; +} +.meta-segment { white-space: nowrap; } + +/* The longest single segment ("Β· Median lines are calculated according to + current filter", ~416px at the 11px base size) is wider than most phone + viewports on its own. Once flex-wrap gives it its own line (see above), + centering an overflowing item spills it equally past both edges of the + container β€” and since body has overflow-x: hidden (top of this file), + the spilled-left half, dot included, would silently disappear instead + of scrolling. Shrinking font-size + padding here keeps every segment, + including that one, narrower than the viewport at any real phone width + so nothing ever gets clipped. */ +@media (max-width: 480px) { + .meta-band { + font-size: 8px; + padding: 30px 10px; + } +} + .layout { max-width: 1180px; margin: 0 auto; @@ -172,6 +214,10 @@ body { } /* --- control bar --- */ +/* No border-top: that edge used to sit right under the hero's own + border-bottom, bracketing the hero/filters gap with a second rule. The + .meta-band now living in that gap is the only divider β€” see its + comment above. */ .control-bar { display: flex; flex-wrap: wrap; @@ -180,6 +226,7 @@ body { padding: 18px 20px; background: var(--turf-800); border: 1px solid var(--turf-600); + border-top: none; border-radius: 10px; margin-bottom: 22px; } @@ -1104,14 +1151,6 @@ body { min-width: 64px; } -.footnote { - margin-top: 26px; - font-size: 12.5px; - line-height: 1.6; - color: var(--chalk-dim); -} -.footnote a { color: var(--flag-gold-bright); } - @media (max-width: 860px) { .board { grid-template-columns: 1fr; } From 565ef1f7c382f9d3ac1bca81b5336c37d7c7266a Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:45:37 -0500 Subject: [PATCH 9/9] Added player cards merger framework. --- frontend/app.js | 759 ++++++++++++++++++++++++++++-------- frontend/index.html | 107 ++++- frontend/style.css | 390 +++++++++++++++++- main.py | 37 +- tests/conftest.py | 40 ++ tests/test_api_endpoints.py | 22 ++ 6 files changed, 1161 insertions(+), 194 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index b010cfe..29b8d13 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,10 +1,11 @@ /* LinesShines Β· ι‹’ε…‰. * Talks to the FastAPI backend for metadata + per-slice records: * GET /api/metadata - * GET /api/pass_rush?season=&position= - * GET /api/pass_block?season=&position= - * Filtering (min-snap threshold), axis choice, and label toggle all run - * client-side against a small in-memory cache of already-fetched slices. + * GET /api/pass_rush?season= (all positions in the category, one response) + * GET /api/pass_block?season= + * Filtering (min-snap threshold, position), axis choice, and label toggle + * all run client-side against a small in-memory cache of already-fetched + * slices β€” see fetchSlice()/positionPool() below. */ // Team logos live next to LinesShines/logos/ β€” the FastAPI service exposes @@ -50,6 +51,15 @@ const els = { scoutCards: document.getElementById("scout-cards"), scoutEmptyHint: document.getElementById("scout-empty-hint"), scoutCardTemplate: document.getElementById("scout-card-template"), + mergeCardTemplate: document.getElementById("merge-card-template"), + teammateCardTemplate: document.getElementById("teammate-card-template"), + mergeToolbar: document.getElementById("merge-toolbar"), + mergeQuotaLabel: document.getElementById("merge-quota-label"), + mergeMessage: document.getElementById("merge-message"), + mergeBtn: document.getElementById("merge-btn"), + mergeConfirmOverlay: document.getElementById("merge-confirm-overlay"), + mergeConfirmCancel: document.getElementById("merge-confirm-cancel"), + mergeConfirmClear: document.getElementById("merge-confirm-clear"), }; let metadata = null; // /api/metadata payload @@ -459,11 +469,16 @@ function scorePlayerAgainstQuery(queryTokens, playerRecord) { // currentSliceCategory) so this always reflects the pending category β€” // updatePlayerPool() keeps both live on every category/season/position // change, independent of whether that change has been Applied yet. +// playerPoolRecords now holds every position in the category (see +// fetchSlice), so this also scopes to the pending position β€” otherwise a +// query typed while Position=ED would start suggesting DI players too. function qualifyingPlayerPool() { if (!playerPoolCategory) return []; const cat = metadata[playerPoolCategory]; const minThreshold = Number(els.thresholdNumber.value); - return playerPoolRecords.filter((r) => r[cat.threshold_field] >= minThreshold); + return playerPoolRecords.filter( + (r) => r.position === els.position.value && r[cat.threshold_field] >= minThreshold + ); } // Top `topK` matches for `query` among `pool`, excluding players already @@ -695,12 +710,19 @@ function populateCategoryDependentControls() { // Shared by loadCurrentSlice() (Apply-gated, drives the chart) and // updatePlayerPool() (live, drives only the Players search pool) β€” both -// just need the records for a given category/season/position, memoized in -// sliceCache so switching back to an already-seen combination is free. -async function fetchSlice(category, season, position) { - const key = `${category}:${season}:${position}`; +// just need every position's records for a given category/season, memoized +// in sliceCache so switching back to an already-seen combination is free. +// No position param: the API returns every position in the category (see +// main.py), and the client partitions by position from here on β€” required +// so Teammate Cards can pull cross-position rosters (T/G/C, ED/DI) out of +// the same in-memory slice instead of a second fetch. Percentile pools must +// still be computed per exact position (see positionPool() below) β€” never +// over this combined array β€” per the "first philosophy" comment in +// BLUEPRINT.md Β§3. +async function fetchSlice(category, season) { + const key = `${category}:${season}`; if (!sliceCache.has(key)) { - const url = `/api/${category}?season=${season}&position=${encodeURIComponent(position)}`; + const url = `/api/${category}?season=${season}`; const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url} β†’ ${res.status}`); const data = await res.json(); @@ -712,14 +734,13 @@ async function fetchSlice(category, season, position) { async function loadCurrentSlice() { const category = els.category.value; const season = Number(els.season.value); - const position = els.position.value; - currentRecords = await fetchSlice(category, season, position); + currentRecords = await fetchSlice(category, season); currentSliceCategory = category; updateThresholdRange(); } -// Mirrors loadCurrentSlice(), but for whatever category/season/position the -// controls are pending on right now, and never touches currentRecords/ +// Mirrors loadCurrentSlice(), but for whatever category/season the controls +// are pending on right now, and never touches currentRecords/ // currentSliceCategory/updateThresholdRange β€” those stay reserved for the // last Applied slice the chart is actually showing. Fired on every // category/season/position change (see attachEvents()) so the Players @@ -730,16 +751,31 @@ async function loadCurrentSlice() { async function updatePlayerPool() { const category = els.category.value; const season = Number(els.season.value); - const position = els.position.value; - playerPoolRecords = await fetchSlice(category, season, position); + playerPoolRecords = await fetchSlice(category, season); playerPoolCategory = category; prunePlayerSelections(); runPlayersSearch(); } +// Every position pool now lives in currentRecords (see fetchSlice above), so +// percentiles/ranks for Merge and Teammate cards must filter down to one +// exact position β€” never the combined multi-position array β€” before ranking. +// Mirrors currentFiltered's own filter in render(), just parameterized over +// position instead of being locked to appliedFilters.position. +function positionPool(position) { + const cat = appliedCategoryMeta(); + const minThreshold = Number(appliedFilters.threshold); + return currentRecords.filter((r) => r.position === position && r[cat.threshold_field] >= minThreshold); +} + function updateThresholdRange() { const cat = currentCategoryMeta(); - const values = currentRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null); + // currentRecords now holds every position in the category (see + // fetchSlice) β€” scope to the pending position before computing the + // slider's max, otherwise switching to a smaller-pool position (e.g. DI) + // would inherit a max sized for a bigger one (e.g. ED). + const positionRecords = currentRecords.filter((r) => r.position === els.position.value); + const values = positionRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null); const maxVal = values.length ? Math.max(...values) : 100; const max = Math.ceil(maxVal / 10) * 10; @@ -1056,7 +1092,13 @@ function render() { const selectedTeams = new Set(appliedFilters.teams ? appliedFilters.teams.split(",") : []); const selectedPlayerKeys = new Set(appliedFilters.players ? appliedFilters.players.split(",") : []); - currentFiltered = currentRecords.filter((r) => r[cat.threshold_field] >= minThreshold); + // currentRecords holds every position in the category (see fetchSlice) β€” + // scope to the applied position here, same as positionPool() does for + // Merge/Teammate cards, so the chart's own percentile pool never mixes + // positions. + currentFiltered = currentRecords.filter( + (r) => r.position === appliedFilters.position && r[cat.threshold_field] >= minThreshold + ); // Players joins Teams via OR β€” a player is highlighted if their team is // selected OR they were explicitly added, so an explicitly-picked player // off a dimmed team still stands out. Empty selectedTeams (Teams β†’ None) @@ -1293,8 +1335,59 @@ function isDesktopScoutLayout() { return window.innerWidth >= 860; } +// --- Card identity (Player / Merge / Teammate) ------------------------------ +// +// Every open card gets a stable {id, origin} pair per BLUEPRINT.md Β§5, so a +// future recommender (v2.0.0) can read merge provenance without a card's +// identity depending on its current member set. origin is 'seed' for a +// Player Card the user opened directly off the chart, 'merge' for one built +// via the Merge button, 'teammate' for a Teammate Association Card. +let cardSeq = 0; +function nextCardId() { + cardSeq += 1; + return `card-${cardSeq}`; +} + +// Player Cards currently checked for merging, keyed the same way scoutCards +// is (record.player). Cleared on merge, on manual uncheck, or whenever the +// underlying Player Card closes (see closeScoutCard()). +const mergeSelection = new Set(); + +// Open Merge Cards, keyed by their own generated id (never by player β€” a +// Merge Card has several members). Entry: { id, origin:'merge', +// memberKeys:[player,...], el, folded }. +const mergeCards = new Map(); + +// Open Teammate Association Cards, keyed by the anchor's player string β€” one +// per anchor regardless of whether the toggle that opened it lives on a +// Player Card or a Merge Card member row. This is what makes BLUEPRINT.md +// Β§2.3's recursion guard trivial: a Teammate Card never renders a teammate +// toggle of its own, so there's no second layer of anchors to key around. +// Entry: { id, origin:'teammate', anchorKey, el, folded, seeMore }. +const teammateCards = new Map(); + +const MERGE_CARD_MAX_MEMBERS = 5; +const MERGE_QUOTA = 8; +// Position-group pulled into a Teammate Card's roster and the per-category +// cap on how many can be shown β€” both keyed by category, not position, +// since a Teammate Card spans every position within its anchor's category +// (BLUEPRINT.md Β§2.2). +const TEAMMATE_POSITIONS = { pass_block: ["T", "G", "C"], pass_rush: ["ED", "DI"] }; +const TEAMMATE_CAP = { pass_block: 5, pass_rush: 7 }; +const TEAMMATE_VISIBLE_DEFAULT = 5; + +function mergedPlayerKeys() { + const keys = new Set(); + mergeCards.forEach((c) => c.memberKeys.forEach((k) => keys.add(k))); + return keys; +} + +function openCardsCount() { + return scoutCards.size + mergeCards.size + teammateCards.size; +} + function updateScoutEmptyHint() { - els.scoutEmptyHint.hidden = scoutCards.size > 0; + els.scoutEmptyHint.hidden = openCardsCount() > 0; } // Every open card gets a higher inline z-index than anything opened, @@ -1308,17 +1401,18 @@ function bringScoutCardToFront(cardEl) { // The first card opened keeps the CSS default top-right anchor (top:24, // right:24) β€” same spot the single card always used to appear. Every card // after that gets an explicit inline left/top, nudged down-left a bit -// further per already-open card, so opening several in a row fans them out +// further per already-open card (Player, Merge, or Teammate β€” they all share +// this one cascade sequence), so opening several in a row fans them out // instead of stacking them exactly on top of each other. They're still // fully draggable afterward, and dragging one on top of another is exactly // the overlap the user asked to allow. const SCOUT_CASCADE_STEP = 28; const SCOUT_CASCADE_WRAP = 8; // wrap the offset so a long run of opens can't drift off-panel function cascadeScoutCardPosition(cardEl) { - if (scoutCards.size === 0 || !isDesktopScoutLayout()) return; + if (openCardsCount() === 0 || !isDesktopScoutLayout()) return; const panelRect = els.chartPanel.getBoundingClientRect(); const cardRect = cardEl.getBoundingClientRect(); - const offset = (scoutCards.size % SCOUT_CASCADE_WRAP) * SCOUT_CASCADE_STEP; + const offset = (openCardsCount() % SCOUT_CASCADE_WRAP) * SCOUT_CASCADE_STEP; const maxLeft = Math.max(panelRect.width - cardRect.width, 0); const maxTop = Math.max(panelRect.height - cardRect.height, 0); cardEl.style.left = `${Math.min(Math.max(panelRect.width - cardRect.width - 24 - offset, 0), maxLeft)}px`; @@ -1326,18 +1420,34 @@ function cascadeScoutCardPosition(cardEl) { cardEl.style.right = "auto"; } +// Shared by every card type's fold button. Two discrete states only +// (BLUEPRINT.md Β§1.3) β€” no inline height, no min-size clamp, this replaces +// the old free edge-resize entirely. `onUnfold` lets a Teammate Card reset +// its "See more" state back to collapsed every time it re-expands (Β§2.5). +function toggleCardFold(cardEl, state, onUnfold) { + state.folded = !state.folded; + cardEl.classList.toggle("is-folded", state.folded); + const foldBtn = cardEl.querySelector(".scout-fold"); + const icon = foldBtn && foldBtn.querySelector("i"); + if (icon) icon.className = state.folded ? "fa-solid fa-chevron-down" : "fa-solid fa-chevron-up"; + if (foldBtn) foldBtn.setAttribute("aria-label", state.folded ? "Unfold card" : "Fold card"); + if (!state.folded && onUnfold) onUnfold(); +} + function openScoutCard(record) { const cat = appliedCategoryMeta(); const cardEl = els.scoutCardTemplate.content.firstElementChild.cloneNode(true); const closeBtn = cardEl.querySelector(".scout-close"); + const foldBtn = cardEl.querySelector(".scout-fold"); const dragHandle = cardEl.querySelector(".scout-drag-handle"); - const panelEl = cardEl.querySelector(".scout-card-panel"); const logoImg = cardEl.querySelector(".scout-logo"); const badge = cardEl.querySelector(".scout-badge"); const nameEl = cardEl.querySelector(".scout-name"); const metaEl = cardEl.querySelector(".scout-meta"); const statsEl = cardEl.querySelector(".scout-stats"); + const mergeCheckbox = cardEl.querySelector(".scout-merge-select"); + const teammateBtn = cardEl.querySelector(".card-teammate-toggle"); const color = teamColor(record.team); logoImg.src = logoSrc(record.team); @@ -1389,77 +1499,82 @@ function openScoutCard(record) { }); els.scoutCards.appendChild(cardEl); - cascadeScoutCardPosition(cardEl); // reads scoutCards.size, so must run before scoutCards.set() below + cascadeScoutCardPosition(cardEl); // reads openCardsCount(), so must run before scoutCards.set() below cardEl.classList.add("is-active"); bringScoutCardToFront(cardEl); + const entry = { record, el: cardEl, id: nextCardId(), origin: "seed", folded: false }; + closeBtn.addEventListener("click", () => closeScoutCard(record.player)); + foldBtn.addEventListener("click", () => toggleCardFold(cardEl, entry)); dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); dragHandle.addEventListener("pointermove", onScoutDragMove); dragHandle.addEventListener("pointerup", endScoutDrag); dragHandle.addEventListener("pointercancel", endScoutDrag); // Raises a card even on a plain click, not just a drag, so tapping an - // overlapped card's stats (not just its drag handle) also brings it front. - // Also doubles as the resize trigger: no visible grip (see the CSS comment - // by .scout-card.is-resizing) β€” like a Mac window, a pointerdown within a - // few px of any edge starts a resize instead of just a bring-to-front. - cardEl.addEventListener("pointerdown", (e) => { - bringScoutCardToFront(cardEl); - if (!isDesktopScoutLayout()) return; - const edges = getResizeEdges(cardEl, e.clientX, e.clientY); - if (edges) beginScoutResize(e, cardEl, panelEl, edges); - }); - // Hover-only cursor feedback while not actively resizing; once a resize - // starts, pointer capture (set in beginScoutResize) keeps routing move - // events to this same listener, so performScoutResize takes over instead. - cardEl.addEventListener("pointermove", (e) => { - if (scoutResizeState && scoutResizeState.cardEl === cardEl) { - performScoutResize(e); - } else if (!scoutResizeState) { - updateScoutResizeCursor(cardEl, e); - } - }); - cardEl.addEventListener("pointerup", endScoutResize); - cardEl.addEventListener("pointercancel", endScoutResize); - cardEl.addEventListener("pointerleave", () => { - if (!scoutResizeState) cardEl.style.cursor = ""; - }); + // overlapped card's stats brings it to front too. + cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); + mergeCheckbox.addEventListener("change", () => toggleMergeSelect(record.player, mergeCheckbox)); + teammateBtn.addEventListener("click", () => toggleTeammateCard(record)); - scoutCards.set(record.player, { record, el: cardEl }); + scoutCards.set(record.player, entry); updateScoutEmptyHint(); + updateMergeToolbar(); } +// Closing a Player Card also drops it out of any pending merge selection and +// closes its own Teammate Card if one is open β€” a player can't simultaneously +// be an open Player Card and part of a Merge Card (performMerge() closes the +// source cards), so there's no other card left that could still anchor that +// Teammate Card once this one is gone. function closeScoutCard(key) { const entry = scoutCards.get(key); if (!entry) return; entry.el.remove(); scoutCards.delete(key); + mergeSelection.delete(key); + closeTeammateCard(key); updateScoutEmptyHint(); + updateMergeToolbar(); } +// Only ever touches Player Cards β€” see clearMergeAndTeammateCards() for the +// Merge/Teammate equivalent, kept separate so applyFilters() can gate that +// one behind the confirm modal while Player Cards keep closing unconditionally +// on every slice change, exactly like they always have. function closeAllScoutCards() { scoutCards.forEach((entry) => entry.el.remove()); scoutCards.clear(); + mergeSelection.clear(); updateScoutEmptyHint(); + updateMergeToolbar(); +} + +// Dissolves every Merge Card and closes every Teammate Card β€” the pool +// invalidation from BLUEPRINT.md Β§4 (season/category/position/threshold +// changes). Called from applyFilters() once any confirm prompt it needed has +// already resolved. +function clearMergeAndTeammateCards() { + mergeCards.forEach((entry) => entry.el.remove()); + mergeCards.clear(); + teammateCards.forEach((entry) => entry.el.remove()); + teammateCards.clear(); + updateScoutEmptyHint(); + updateMergeToolbar(); } // Inline left/top (set by dragging or cascadeScoutCardPosition) sit at // higher specificity than the mobile media query's `top: auto; right: auto;` // reset, so they'd otherwise survive a resize down to mobile and break the // stacked layout. Clearing them lets the stylesheet's position rules take -// back over for every currently-open card. Inline width/height from -// beginScoutResize would equally survive (and equally make no sense once -// mobile takes over sizing via its own min-height rule), so those get -// cleared here too. +// back over for every currently-open card of every type β€” Fold/Unfold never +// sets inline styles (see toggleCardFold(), a CSS class only), so there's +// nothing else here to clear now that resize is gone. function clearScoutCardDragPositions() { - scoutCards.forEach((entry) => { + [...scoutCards.values(), ...mergeCards.values(), ...teammateCards.values()].forEach((entry) => { entry.el.style.left = ""; entry.el.style.top = ""; entry.el.style.right = ""; - entry.el.style.width = ""; - const panelEl = entry.el.querySelector(".scout-card-panel"); - panelEl.style.height = ""; - panelEl.style.maxHeight = ""; }); } @@ -1513,124 +1628,420 @@ function endScoutDrag(e) { scoutDragState = null; } -// Bounds for the edge-resize below β€” width mirrors style.css's .scout-card -// min/max-width (keep these in sync with that rule; CSS alone can't gate -// height since .scout-card-panel's height is otherwise auto, so its bounds -// only live here). -const SCOUT_CARD_MIN_WIDTH = 260; -const SCOUT_CARD_MAX_WIDTH = 640; -const SCOUT_CARD_MIN_HEIGHT = 160; -const SCOUT_CARD_MAX_HEIGHT = 900; - -// How close the pointer needs to be to a card's outer edge, in px, to count -// as a resize grab rather than a plain click/drag β€” no visible grip, so this -// margin *is* the hit target, like a Mac window's edge. Comfortably clears -// .scout-close (offset 10px from the same corner) so the close button never -// gets swallowed by the resize zone. -const SCOUT_RESIZE_MARGIN = 8; - -// Which of a card's four edges (if any) a point sits within SCOUT_RESIZE_MARGIN -// of, e.g. {left:false, right:true, top:false, bottom:true} for a -// bottom-right corner grab β€” or null if the point isn't near any edge. -function getResizeEdges(cardEl, clientX, clientY) { - const rect = cardEl.getBoundingClientRect(); - const edges = { - left: clientX - rect.left <= SCOUT_RESIZE_MARGIN, - right: rect.right - clientX <= SCOUT_RESIZE_MARGIN, - top: clientY - rect.top <= SCOUT_RESIZE_MARGIN, - bottom: rect.bottom - clientY <= SCOUT_RESIZE_MARGIN, - }; - return edges.left || edges.right || edges.top || edges.bottom ? edges : null; +// --- Player Merge Cards (BLUEPRINT.md Β§1) ----------------------------------- + +// Persistent quota readout + Merge button, visible whenever any card is open +// on desktop/tablet (BLUEPRINT.md Β§1.4: "display remaining quota somewhere +// persistent" β€” not just while mid-selection). +function updateMergeToolbar() { + const visible = isDesktopScoutLayout() && openCardsCount() > 0; + els.mergeToolbar.hidden = !visible; + if (!visible) return; + + const usedSlots = mergedPlayerKeys().size; + els.mergeQuotaLabel.textContent = `Merge quota: ${MERGE_QUOTA - usedSlots}/${MERGE_QUOTA} remaining`; + + const selectedCount = mergeSelection.size; + els.mergeBtn.textContent = selectedCount ? `Merge (${selectedCount})` : "Merge"; + const quotaExhausted = usedSlots >= MERGE_QUOTA; + els.mergeBtn.disabled = selectedCount < 2 || quotaExhausted; + els.mergeBtn.title = quotaExhausted + ? "Merge limit reached β€” dissolve a card to free slots" + : selectedCount < 2 + ? "Select 2 or more Player Cards to merge" + : ""; } -function cursorForEdges(edges) { - if ((edges.left && edges.top) || (edges.right && edges.bottom)) return "nwse-resize"; - if ((edges.right && edges.top) || (edges.left && edges.bottom)) return "nesw-resize"; - if (edges.left || edges.right) return "ew-resize"; - return "ns-resize"; +function setMergeMessage(text) { + els.mergeMessage.textContent = text || ""; } -// Live cursor feedback as the pointer wanders near a card's edges β€” the only -// hint a resize zone exists, since there's no visible grip. -function updateScoutResizeCursor(cardEl, e) { - const edges = isDesktopScoutLayout() ? getResizeEdges(cardEl, e.clientX, e.clientY) : null; - cardEl.style.cursor = edges ? cursorForEdges(edges) : ""; +// Wired to every Player Card's merge-select checkbox. Enforces the 5-per- +// card cap at selection time β€” BLUEPRINT.md's testing checklist calls for +// the 6th selection to be refused with a stated reason, not silently +// truncated once Merge is pressed. +function toggleMergeSelect(player, checkbox) { + if (!isDesktopScoutLayout()) return; + if (checkbox.checked) { + if (mergeSelection.size >= MERGE_CARD_MAX_MEMBERS) { + checkbox.checked = false; + setMergeMessage(`Merge Cards hold at most ${MERGE_CARD_MAX_MEMBERS} players β€” unselect one first.`); + return; + } + mergeSelection.add(player); + } else { + mergeSelection.delete(player); + } + setMergeMessage(""); + updateMergeToolbar(); } -// Resize state for the one pointer currently dragging a card's edge, or -// null β€” same single-pointer-at-a-time shape as scoutDragState above. -let scoutResizeState = null; +function performMerge() { + if (!isDesktopScoutLayout() || mergeSelection.size < 2) return; + const usedSlots = mergedPlayerKeys().size; + if (usedSlots + mergeSelection.size > MERGE_QUOTA) { + setMergeMessage("Merge limit reached β€” dissolve a card to free slots."); + return; + } -function beginScoutResize(e, cardEl, panelEl, edges) { - const panelRect = els.chartPanel.getBoundingClientRect(); - const cardRect = cardEl.getBoundingClientRect(); - scoutResizeState = { - cardEl, - panelEl, - edges, - pointerId: e.pointerId, - startX: e.clientX, - startY: e.clientY, - // Same left/top conversion beginScoutDrag does β€” dragging the left or - // top edge needs an explicit anchor to push around, not just a width/ - // height to grow, since the opposite edge has to stay put. - startLeft: cardRect.left - panelRect.left, - startTop: cardRect.top - panelRect.top, - startWidth: cardRect.width, - startHeight: panelEl.getBoundingClientRect().height, - }; - cardEl.style.left = `${scoutResizeState.startLeft}px`; - cardEl.style.top = `${scoutResizeState.startTop}px`; - cardEl.style.right = "auto"; - // Drop the open/close max-height cap in favor of an explicit height the - // user now controls directly; is-resizing (in style.css) kills the - // max-height transition so that swap doesn't animate. - panelEl.style.maxHeight = "none"; - cardEl.classList.add("is-resizing"); + const memberKeys = Array.from(mergeSelection); + const memberRecords = memberKeys.map((key) => scoutCards.get(key)).filter(Boolean).map((entry) => entry.record); + memberKeys.forEach((key) => closeScoutCard(key)); + mergeSelection.clear(); + setMergeMessage(""); + openMergeCard(memberRecords); +} + +// A single holding this row's teammate-toggle β€” shared by every row in +// a Merge Card's table (BLUEPRINT.md Β§2.1: one teammate toggle per merged +// member, no team-level dedupe even when two members share a team). +function makeTeammateCell(record) { + const td = document.createElement("td"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "merge-row-teammate-toggle"; + btn.innerHTML = ''; + btn.setAttribute("aria-label", `Teammates for ${record.player}`); + btn.addEventListener("click", () => toggleTeammateCard(record)); + td.appendChild(btn); + return td; +} + +// One row per player, one column per metric, percentile-only cells +// (BLUEPRINT.md Β§1.2) β€” never the raw value, never #rank/N. +function openMergeCard(memberRecords) { + const cat = appliedCategoryMeta(); + const cardEl = els.mergeCardTemplate.content.firstElementChild.cloneNode(true); + + const closeBtn = cardEl.querySelector(".scout-close"); + const foldBtn = cardEl.querySelector(".scout-fold"); + const dragHandle = cardEl.querySelector(".scout-drag-handle"); + const titleEl = cardEl.querySelector(".merge-card-title"); + const subtitleEl = cardEl.querySelector(".merge-card-subtitle"); + const table = cardEl.querySelector(".merge-table"); + + const id = nextCardId(); + const memberKeys = memberRecords.map((r) => r.player); + + titleEl.textContent = `Merge Card Β· ${memberRecords.length} players`; + subtitleEl.textContent = memberRecords.map((r) => r.abbr_name || r.player).join(" + "); + + const metricKeys = Object.keys(cat.metrics); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + ["Player", "Pool", "Games", cat.threshold_field, ...metricKeys, "Teammates"].forEach((label) => { + const th = document.createElement("th"); + th.textContent = label; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + + const tbody = document.createElement("tbody"); + memberRecords.forEach((record) => { + const pool = positionPool(record.position); + const tr = document.createElement("tr"); + + const nameTd = document.createElement("td"); + nameTd.className = "merge-table-name"; + nameTd.textContent = record.abbr_name || record.player; + tr.appendChild(nameTd); + + const poolTd = document.createElement("td"); + poolTd.className = "merge-table-pool"; + poolTd.textContent = `${cat.positions[record.position] || record.position}, n=${pool.length}`; + tr.appendChild(poolTd); + + const gamesTd = document.createElement("td"); + gamesTd.textContent = record.games ?? "β€”"; + tr.appendChild(gamesTd); + + const snapsTd = document.createElement("td"); + snapsTd.textContent = record[cat.threshold_field] ?? "β€”"; + tr.appendChild(snapsTd); + + metricKeys.forEach((key) => { + const meta = cat.metrics[key]; + const rank = rankAndPercentile(pool, key, meta.higher_is_better, record[key]); + const td = document.createElement("td"); + td.textContent = rank ? ordinal(rank.percentile) : "β€”"; + tr.appendChild(td); + }); + + tr.appendChild(makeTeammateCell(record)); + tbody.appendChild(tr); + }); + + table.appendChild(thead); + table.appendChild(tbody); + + els.scoutCards.appendChild(cardEl); + cascadeScoutCardPosition(cardEl); // must run before mergeCards.set() below + cardEl.classList.add("is-active"); bringScoutCardToFront(cardEl); - cardEl.setPointerCapture(e.pointerId); - e.preventDefault(); + + const entry = { id, origin: "merge", memberKeys, el: cardEl, folded: false }; + + closeBtn.addEventListener("click", () => closeMergeCard(id)); + foldBtn.addEventListener("click", () => toggleCardFold(cardEl, entry)); + dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); + dragHandle.addEventListener("pointermove", onScoutDragMove); + dragHandle.addEventListener("pointerup", endScoutDrag); + dragHandle.addEventListener("pointercancel", endScoutDrag); + cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); + + mergeCards.set(id, entry); + updateScoutEmptyHint(); + updateMergeToolbar(); } -function performScoutResize(e) { - const s = scoutResizeState; - const panelRect = els.chartPanel.getBoundingClientRect(); - const dx = e.clientX - s.startX; - const dy = e.clientY - s.startY; - - let width = s.startWidth; - let left = s.startLeft; - if (s.edges.right) { - width = Math.min(Math.max(s.startWidth + dx, SCOUT_CARD_MIN_WIDTH), SCOUT_CARD_MAX_WIDTH); - } else if (s.edges.left) { - width = Math.min(Math.max(s.startWidth - dx, SCOUT_CARD_MIN_WIDTH), SCOUT_CARD_MAX_WIDTH); - left = s.startLeft + (s.startWidth - width); // keep the right edge fixed while the left one moves - } - left = Math.min(Math.max(left, 0), Math.max(panelRect.width - width, 0)); - - let height = s.startHeight; - let top = s.startTop; - if (s.edges.bottom) { - height = Math.min(Math.max(s.startHeight + dy, SCOUT_CARD_MIN_HEIGHT), SCOUT_CARD_MAX_HEIGHT); - } else if (s.edges.top) { - height = Math.min(Math.max(s.startHeight - dy, SCOUT_CARD_MIN_HEIGHT), SCOUT_CARD_MAX_HEIGHT); - top = s.startTop + (s.startHeight - height); // keep the bottom edge fixed while the top one moves +// Dissolve: releases the merged players' quota slots and removes the card. +// No undo, no partial-unmerge β€” out of scope for v1.2.0 (BLUEPRINT.md Β§0). +function closeMergeCard(id) { + const entry = mergeCards.get(id); + if (!entry) return; + entry.el.remove(); + mergeCards.delete(id); + entry.memberKeys.forEach((key) => closeTeammateCard(key)); + updateScoutEmptyHint(); + updateMergeToolbar(); +} + +// --- Teammate Association Cards (BLUEPRINT.md Β§2) --------------------------- + +// Same-team, same-season, same-category roster around `anchorRecord`, +// including same-position teammates (BLUEPRINT.md Β§2.2's position table), +// excluding the anchor himself. Splits into pre-/post-threshold sets so the +// caller can state how many were omitted for missing the snap bar, then +// sorts by snap count (the category's threshold_field, the only volume stat +// available per player) descending and caps at the category's limit β€” +// dropping the lowest first, no positional reservation. +function computeTeammateRoster(anchorRecord) { + const cat = appliedCategoryMeta(); + const positions = TEAMMATE_POSITIONS[appliedFilters.category]; + const threshold = Number(appliedFilters.threshold); + + const teamPool = currentRecords.filter( + (r) => positions.includes(r.position) && r.team === anchorRecord.team && r.player !== anchorRecord.player + ); + const qualifying = teamPool.filter((r) => r[cat.threshold_field] >= threshold); + const omittedCount = teamPool.length - qualifying.length; + + const sorted = qualifying.slice().sort((a, b) => b[cat.threshold_field] - a[cat.threshold_field]); + const cap = TEAMMATE_CAP[appliedFilters.category]; + + return { roster: sorted.slice(0, cap), omittedCount }; +} + +// Min/Median/Max/RSWA per metric over `rosterRecords` β€” always the full +// capped roster, regardless of "See more" state (BLUEPRINT.md Β§2.5: that +// control is display-only). RSWA weights each teammate by his snap overlap +// with the anchor, capped at the anchor's own snaps β€” see the formula in +// BLUEPRINT.md Β§2.5 for why the cap matters (PFF publishes no +// who-played-with-whom data, so this is the cheapest bound on it). +function computeThreeMRSWA(anchorRecord, rosterRecords, cat) { + const anchorSnaps = anchorRecord[cat.threshold_field] || 0; + const perPlayer = rosterRecords.map((t) => { + const pool = positionPool(t.position); + const percentiles = {}; + Object.entries(cat.metrics).forEach(([key, meta]) => { + const rank = rankAndPercentile(pool, key, meta.higher_is_better, t[key]); + percentiles[key] = rank ? rank.percentile : null; + }); + return { percentiles, effSnaps: Math.min(anchorSnaps, t[cat.threshold_field] || 0) }; + }); + + const totalEff = perPlayer.reduce((sum, p) => sum + p.effSnaps, 0); + + const results = {}; + Object.keys(cat.metrics).forEach((key) => { + const values = perPlayer.map((p) => p.percentiles[key]).filter((v) => v != null); + let rswa = null; + if (totalEff > 0) { + rswa = perPlayer.reduce((sum, p) => sum + (p.percentiles[key] ?? 0) * (p.effSnaps / totalEff), 0); + rswa = Math.round(rswa * 10) / 10; + } + results[key] = { + min: values.length ? Math.min(...values) : null, + median: median(values), + max: values.length ? Math.max(...values) : null, + rswa, + }; + }); + return results; +} + +function toggleTeammateCard(anchorRecord) { + if (!isDesktopScoutLayout()) return; + if (teammateCards.has(anchorRecord.player)) { + closeTeammateCard(anchorRecord.player); + } else { + openTeammateCard(anchorRecord); } - top = Math.min(Math.max(top, 0), Math.max(panelRect.height - height, 0)); +} - s.cardEl.style.width = `${width}px`; - s.cardEl.style.left = `${left}px`; - s.cardEl.style.top = `${top}px`; - s.panelEl.style.height = `${height}px`; +function closeTeammateCard(anchorKey) { + const entry = teammateCards.get(anchorKey); + if (!entry) return; + entry.el.remove(); + teammateCards.delete(anchorKey); + updateScoutEmptyHint(); + updateMergeToolbar(); } -function endScoutResize(e) { - if (!scoutResizeState || scoutResizeState.cardEl !== e.currentTarget || e.pointerId !== scoutResizeState.pointerId) - return; - e.currentTarget.releasePointerCapture(e.pointerId); - scoutResizeState.cardEl.classList.remove("is-resizing"); - scoutResizeState.cardEl.style.cursor = ""; - scoutResizeState = null; +// Renders the visible slice of the roster (first 5 by snap count, or all of +// it once "See more" is toggled) into listEl β€” percentiles only, each +// against the teammate's own position pool, with the pool denominator shown +// per BLUEPRINT.md Β§2.4. +function renderTeammateRoster(entry, roster, listEl) { + const cat = appliedCategoryMeta(); + listEl.innerHTML = ""; + const visibleCount = entry.seeMore ? roster.length : Math.min(TEAMMATE_VISIBLE_DEFAULT, roster.length); + + roster.slice(0, visibleCount).forEach((t) => { + const pool = positionPool(t.position); + const row = document.createElement("div"); + row.className = "teammate-row"; + + const name = document.createElement("span"); + name.className = "teammate-row-name"; + name.textContent = `${t.abbr_name || t.player} (${cat.positions[t.position] || t.position})`; + row.appendChild(name); + + const cellsWrap = document.createElement("div"); + cellsWrap.className = "teammate-row-cells"; + Object.entries(cat.metrics).forEach(([key, meta]) => { + const rank = rankAndPercentile(pool, key, meta.higher_is_better, t[key]); + const cell = document.createElement("span"); + cell.className = "teammate-cell"; + cell.textContent = rank ? ordinal(rank.percentile) : "β€”"; + cell.title = rank + ? `${key}: #${rank.rank}/${rank.n} ${cat.positions[t.position] || t.position}` + : key; + cellsWrap.appendChild(cell); + }); + row.appendChild(cellsWrap); + + listEl.appendChild(row); + }); +} + +function openTeammateCard(anchorRecord) { + const cat = appliedCategoryMeta(); + const cardEl = els.teammateCardTemplate.content.firstElementChild.cloneNode(true); + + const closeBtn = cardEl.querySelector(".scout-close"); + const foldBtn = cardEl.querySelector(".scout-fold"); + const dragHandle = cardEl.querySelector(".scout-drag-handle"); + const titleEl = cardEl.querySelector(".teammate-card-title"); + const subtitleEl = cardEl.querySelector(".teammate-card-subtitle"); + const omittedEl = cardEl.querySelector(".teammate-omitted-note"); + const listEl = cardEl.querySelector(".teammate-roster"); + const seeMoreBtn = cardEl.querySelector(".teammate-see-more"); + const summaryTable = cardEl.querySelector(".teammate-summary-table"); + + const { roster, omittedCount } = computeTeammateRoster(anchorRecord); + const id = nextCardId(); + const entry = { id, origin: "teammate", anchorKey: anchorRecord.player, el: cardEl, folded: false, seeMore: false }; + + titleEl.textContent = anchorRecord.player; + subtitleEl.textContent = `${roster.length} teammate${roster.length === 1 ? "" : "s"}`; + + if (omittedCount > 0) { + omittedEl.hidden = false; + omittedEl.textContent = `${omittedCount} linemate${omittedCount === 1 ? "" : "s"} below the snap threshold not shown.`; + } else { + omittedEl.hidden = true; + } + + const renderRoster = () => renderTeammateRoster(entry, roster, listEl); + renderRoster(); + + if (roster.length > TEAMMATE_VISIBLE_DEFAULT) { + seeMoreBtn.hidden = false; + seeMoreBtn.textContent = "See more"; + seeMoreBtn.addEventListener("click", () => { + entry.seeMore = !entry.seeMore; + seeMoreBtn.textContent = entry.seeMore ? "See less" : "See more"; + renderRoster(); + }); + } else { + seeMoreBtn.hidden = true; + } + + // 3M + RSWA summary, computed over every qualifying (capped) teammate + // regardless of "See more" state. + const summary = computeThreeMRSWA(anchorRecord, roster, cat); + const theadRow = document.createElement("tr"); + ["Metric", "Min", "Median", "Max", "RSWA"].forEach((label) => { + const th = document.createElement("th"); + th.textContent = label; + theadRow.appendChild(th); + }); + const thead = document.createElement("thead"); + thead.appendChild(theadRow); + + const tbody = document.createElement("tbody"); + Object.keys(cat.metrics).forEach((key) => { + const row = summary[key]; + const tr = document.createElement("tr"); + const labelTd = document.createElement("td"); + labelTd.className = "teammate-summary-metric"; + labelTd.textContent = key; + tr.appendChild(labelTd); + [row.min, row.median, row.max, row.rswa].forEach((v) => { + const td = document.createElement("td"); + td.textContent = v == null ? "β€”" : ordinal(Math.round(v)); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + summaryTable.appendChild(thead); + summaryTable.appendChild(tbody); + + els.scoutCards.appendChild(cardEl); + cascadeScoutCardPosition(cardEl); // must run before teammateCards.set() below + cardEl.classList.add("is-active"); + bringScoutCardToFront(cardEl); + + closeBtn.addEventListener("click", () => closeTeammateCard(anchorRecord.player)); + // Fold resets "See more" back to collapsed on unfold (BLUEPRINT.md Β§2.5). + foldBtn.addEventListener("click", () => + toggleCardFold(cardEl, entry, () => { + entry.seeMore = false; + seeMoreBtn.textContent = "See more"; + renderRoster(); + }) + ); + dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); + dragHandle.addEventListener("pointermove", onScoutDragMove); + dragHandle.addEventListener("pointerup", endScoutDrag); + dragHandle.addEventListener("pointercancel", endScoutDrag); + cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); + + teammateCards.set(anchorRecord.player, entry); + updateScoutEmptyHint(); + updateMergeToolbar(); +} + +// --- Pool-change confirm modal (BLUEPRINT.md Β§4) ----------------------------- + +// Resolves true (proceed) or false (cancel). Only ever shown when at least +// one Merge Card exists β€” Teammate Cards alone are cheap to rebuild (one +// toggle click) and don't warrant interrupting Apply, see applyFilters(). +function showMergeInvalidationConfirm() { + return new Promise((resolve) => { + els.mergeConfirmOverlay.hidden = false; + const cleanup = (result) => { + els.mergeConfirmOverlay.hidden = true; + els.mergeConfirmCancel.removeEventListener("click", onCancel); + els.mergeConfirmClear.removeEventListener("click", onClear); + resolve(result); + }; + const onCancel = () => cleanup(false); + const onClear = () => cleanup(true); + els.mergeConfirmCancel.addEventListener("click", onCancel); + els.mergeConfirmClear.addEventListener("click", onClear); + }); } function closeFiltersDrawer() { @@ -1647,6 +2058,22 @@ async function applyFilters() { els.category.value !== appliedFilters.category || els.season.value !== appliedFilters.season || els.position.value !== appliedFilters.position; + // Threshold isn't part of sliceChanged (no refetch needed β€” currentRecords + // already holds every threshold value), but it does change which rows + // clear the bar, so it still invalidates Merge/Teammate percentile pools + // exactly like a slice change does (BLUEPRINT.md Β§4). Axes/Teams/Players + // deliberately don't participate here β€” none of them affect + // currentFiltered/positionPool, see the "do NOT invalidate" list in Β§4. + const thresholdChanged = els.thresholdNumber.value !== appliedFilters.threshold; + const poolChanged = sliceChanged || thresholdChanged; + + if (poolChanged && mergeCards.size > 0) { + const proceed = await showMergeInvalidationConfirm(); + if (!proceed) return; // Cancel aborts entirely β€” pending controls stay lit, nothing is fetched or cleared. + } + if (poolChanged) { + clearMergeAndTeammateCards(); + } if (sliceChanged) { closeAllScoutCards(); @@ -1899,8 +2326,14 @@ function attachEvents() { // layout can take back over, see clearScoutCardDragPositions(). window.addEventListener("resize", () => { if (!isDesktopScoutLayout()) clearScoutCardDragPositions(); + // Merge/Teammate cards and the toolbar are desktop/tablet-only + // (BLUEPRINT.md Β§0) β€” a resize across the 860px breakpoint needs to + // re-evaluate the toolbar's own visibility either way. + updateMergeToolbar(); }); + els.mergeBtn.addEventListener("click", performMerge); + // Deliberately no "click outside closes the card" handler here, unlike the // Teams/Players dropdowns and the filters drawer below. Those are // transient single-purpose overlays where dismissing on an outside click diff --git a/frontend/index.html b/frontend/index.html index 5a26ae4..7c28500 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -217,10 +217,23 @@

LinesShines Β· ι‹’ε…‰

+ + + + pinned player), #merge-card-template, or #teammate-card-template + (see openScoutCard()/openMergeCard()/openTeammateCard() in app.js) + and appended here as siblings β€” each keeps its own position/z-index + so several can be open and dragged independently without stepping + on each other. -->
+
+ + +
+ + + + + + + + + diff --git a/frontend/style.css b/frontend/style.css index a897588..90e4674 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -957,10 +957,8 @@ body { right: 24px; /* Widened from 260px to fit the rank/percentile column added alongside the value column without wrapping metric labels like "TPS Pressure - Rate". min/max-width bound the edge-resize drag in app.js (see - SCOUT_CARD_MIN_WIDTH/SCOUT_CARD_MAX_WIDTH there, kept in sync with - these) β€” below min the header/rank column start wrapping, above max it - can swallow the whole chart panel. */ + Rate". Fixed, not resizable β€” see .is-folded below for the two-state + Fold/Unfold that replaced the old free edge-resize (BLUEPRINT.md Β§1.3). */ width: 380px; min-width: 260px; max-width: 640px; @@ -971,6 +969,12 @@ body { z-index: 5; } +/* Merge/Teammate cards reuse every .scout-card rule above (position, drag, + z-index, mobile stacking) via this base class, plus their own modifier + below for anything that needs to differ. */ +.scout-card--merge { width: 460px; } +.scout-card--teammate { width: 420px; } + /* Positioned relative to .scout-card itself (not .scout-card-panel) so the same top/right offsets land in the right corner whether .scout-card is the absolute desktop overlay or the relative mobile stacked block β€” @@ -1035,20 +1039,51 @@ body { max-height: 480px; opacity: 1; pointer-events: auto; - /* Once the user edge-resizes the card (see beginScoutResize() in app.js), - app.js sets an explicit inline height and clears max-height β€” - overflow-y:auto here means a card shrunk below its content's natural - height scrolls instead of clipping silently. overflow-x stays hidden - from the rule above. */ + /* A card's body (stats table / merge table / teammate roster+summary) can + run taller than the 480px cap above β€” overflow-y:auto means it scrolls + instead of clipping silently. overflow-x stays hidden from the rule + above. */ overflow-y: auto; } -/* No visible grip β€” like a Mac window, the whole card edge is a resize - target (see getResizeEdges()/beginScoutResize() in app.js, both gated by - isDesktopScoutLayout() the same as dragging). This only kills the - max-height transition so an active resize doesn't fight the CSS-driven - open/close animation. */ -.scout-card.is-resizing .scout-card-panel { transition: none; } +/* Fold/Unfold (BLUEPRINT.md Β§1.3) β€” two discrete states only, no continuous + resize. Folding hides each card type's metric body and leaves just the + header (.scout-card-top) β€” and, for a Player Card, its always-visible + .scout-card-actions row (merge-select + teammate toggle), which should + stay usable without unfolding first. No inline height is ever set here, + so there's no min-size clamp and nothing can mid-row clip. */ +.scout-card.is-folded .scout-stats, +.scout-card.is-folded .merge-table-wrap, +.scout-card.is-folded .teammate-omitted-note, +.scout-card.is-folded .teammate-roster, +.scout-card.is-folded .teammate-see-more, +.scout-card.is-folded .teammate-summary-wrap { + display: none; +} +.scout-fold { + display: none; + position: absolute; + top: 10px; + right: 40px; + z-index: 6; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + background: rgba(15, 33, 25, 0.55); + border: 1px solid var(--turf-600); + border-radius: 50%; + color: var(--chalk-dim); + font-size: 11px; + padding: 0; + cursor: pointer; +} +.scout-fold:hover, +.scout-fold:focus-visible { + color: var(--flag-gold-bright); + border-color: var(--flag-gold); +} +.scout-card.is-active .scout-fold { display: flex; } /* A cloned card is only ever in the DOM while its player is pinned, so unlike the old single static card, the body never needs its own @@ -1151,6 +1186,312 @@ body { min-width: 64px; } +/* --- Player Card actions: merge-select + teammate toggle (BLUEPRINT.md Β§1/Β§2) --- + Sits between the header and the stats table, always visible β€” including + while the card is folded, see .is-folded above β€” so a card doesn't need + to be unfolded to be selected for a merge or to pull up its teammates. */ +.scout-card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 12px; + margin-bottom: 2px; + border-bottom: 1px dashed var(--turf-600); +} +.card-merge-select { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--chalk-dim); + cursor: pointer; +} +.card-merge-select input { + accent-color: var(--flag-gold); + width: 14px; + height: 14px; +} +.card-teammate-toggle, +.merge-row-teammate-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--turf-700); + color: var(--chalk-dim); + border: 1px solid var(--turf-600); + border-radius: 5px; + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; +} +.card-teammate-toggle:hover, +.card-teammate-toggle:focus-visible, +.merge-row-teammate-toggle:hover, +.merge-row-teammate-toggle:focus-visible { + color: var(--flag-gold-bright); + border-color: var(--flag-gold); + outline: none; +} +.merge-row-teammate-toggle { padding: 3px 6px; font-size: 11px; } + +/* --- Merge toolbar --- persistent quota readout + Merge button + (BLUEPRINT.md Β§1.4), styled after .apply-btn/.png-btn's conventions. + Sits above the scout-cards overlay, inside .board. */ +.merge-toolbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 10px 16px; + background: var(--turf-800); + border: 1px solid var(--turf-600); + border-radius: 8px; + margin-bottom: -4px; +} +.merge-quota { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--chalk-dim); +} +.merge-message { + flex: 1 1 auto; + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--rush-clay); +} +.merge-btn { + flex: none; + margin-left: auto; + background: var(--flag-gold); + color: var(--turf-950); + border: 1px solid var(--flag-gold); + border-radius: 6px; + padding: 7px 16px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} +.merge-btn:hover, +.merge-btn:focus-visible { + background: var(--flag-gold-bright); + border-color: var(--flag-gold-bright); +} +.merge-btn:disabled { + background: var(--turf-700); + color: var(--chalk-dim); + border-color: var(--turf-600); + cursor: not-allowed; +} + +/* --- Merge Card table --- one row per player, one column per metric, + percentile-only cells (BLUEPRINT.md Β§1.2). */ +.merge-table-wrap { overflow-x: auto; } +.merge-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 11.5px; +} +.merge-table th, +.merge-table td { + padding: 6px 8px; + text-align: right; + white-space: nowrap; + border-bottom: 1px solid var(--turf-600); +} +.merge-table th { + color: var(--chalk-dim); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 10px; +} +.merge-table td { color: var(--chalk); } +.merge-table .merge-table-name, +.merge-table th:first-child { + text-align: left; + font-family: var(--font-body); + font-weight: 600; +} +.merge-table .merge-table-pool { + text-align: left; + color: var(--chalk-dim); + font-size: 10.5px; +} + +/* --- Teammate Association Card --- roster rows + 3M/RSWA summary + (BLUEPRINT.md Β§2). */ +.teammate-omitted-note { + margin: 0 0 10px; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--chalk-dim); + font-style: italic; +} +.teammate-roster { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 10px; +} +.teammate-row { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 8px; + border-bottom: 1px dashed var(--turf-600); +} +.teammate-row-name { + font-family: var(--font-body); + font-size: 12.5px; + font-weight: 600; + color: var(--chalk); +} +.teammate-row-cells { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.teammate-cell { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--chalk-dim); + background: rgba(241, 236, 221, 0.06); + border-radius: 4px; + padding: 2px 6px; + cursor: help; +} +.teammate-see-more { + appearance: none; + -webkit-appearance: none; + display: block; + background: transparent; + border: none; + color: var(--instant-replay-blue); + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0 0 12px; + cursor: pointer; +} +.teammate-see-more:hover, +.teammate-see-more:focus-visible { + color: var(--instant-replay-blue-bright); + outline: none; +} +.teammate-summary-wrap { overflow-x: auto; } +.teammate-summary-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 11px; +} +.teammate-summary-table th, +.teammate-summary-table td { + padding: 5px 7px; + text-align: right; + white-space: nowrap; + border-bottom: 1px solid var(--turf-600); +} +.teammate-summary-table th { + color: var(--chalk-dim); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 9.5px; +} +.teammate-summary-table td { color: var(--chalk); } +.teammate-summary-metric, +.teammate-summary-table th:first-child { + text-align: left; + font-family: var(--font-body); +} + +/* --- Pool-change confirm modal (BLUEPRINT.md Β§4) --- */ +.merge-confirm-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 33, 25, 0.75); + padding: 24px; +} +.merge-confirm-overlay[hidden] { display: none; } +.merge-confirm-box { + width: 100%; + max-width: 380px; + background: linear-gradient(165deg, var(--turf-700), var(--turf-800)); + border: 1px solid var(--turf-600); + border-radius: 10px; + padding: 20px; + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.5); +} +.merge-confirm-title { + margin: 0 0 8px; + font-family: var(--font-display); + font-size: 18px; + color: var(--chalk); +} +.merge-confirm-body { + margin: 0 0 18px; + font-size: 13px; + line-height: 1.5; + color: var(--chalk-dim); +} +.merge-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.merge-confirm-actions button { + border-radius: 6px; + padding: 8px 16px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; +} +.merge-confirm-cancel { + background: transparent; + color: var(--chalk-dim); + border: 1px solid var(--turf-600); +} +.merge-confirm-cancel:hover, +.merge-confirm-cancel:focus-visible { + color: var(--chalk); + border-color: var(--flag-gold); + outline: none; +} +.merge-confirm-clear { + background: var(--rush-clay); + color: var(--chalk); + border: 1px solid var(--rush-clay); +} +.merge-confirm-clear:hover, +.merge-confirm-clear:focus-visible { + filter: brightness(1.1); + outline: none; +} + @media (max-width: 860px) { .board { grid-template-columns: 1fr; } @@ -1196,9 +1537,6 @@ body { cursor: auto; touch-action: auto; } - /* Resizing is desktop-only too (same isDesktopScoutLayout() guard on - beginScoutResize() in app.js) β€” the stacked mobile layout sizes each - card to its content instead. */ .scout-card-panel { max-height: none; overflow: visible; @@ -1212,6 +1550,11 @@ body { padding: 20px 18px 18px; min-height: 320px; } + /* Fold/Unfold isn't restricted to desktop (unlike Merge/Teammate) β€” but a + folded card's body is empty (see .is-folded in the desktop rules above), + so the 320px min-height meant for an always-expanded mobile card would + otherwise leave a tall dead gap under the header. */ + .scout-card.is-folded .scout-card-panel { min-height: 0; } .scout-card-body { padding: 0; } /* Standalone placeholder (not nested in a card) shown when nothing is @@ -1243,6 +1586,17 @@ body { it's simply absent from the mobile drawer. */ .control-players { display: none; } + /* Merge Cards & Teammate Cards are desktop/tablet-only too (BLUEPRINT.md + Β§0) β€” app.js's isDesktopScoutLayout() guards already stop new ones from + opening below 860px; this hides the affordances that would start that + flow (the persistent toolbar, and each Player Card's merge-select/ + teammate-toggle row) so the mobile card body is exactly what v1.1.0 + shipped. */ + .merge-toolbar, + .scout-card-actions { + display: none; + } + .filters-drawer { position: fixed; bottom: 0; diff --git a/main.py b/main.py index af54e76..c82cdf9 100644 --- a/main.py +++ b/main.py @@ -2,8 +2,10 @@ Routes: GET /api/metadata β†’ seasons, positions, metric defs, teams - GET /api/pass_rush?season=&position= β†’ per-slice records - GET /api/pass_block?season=&position= β†’ per-slice records + GET /api/pass_rush?season=[&position=] β†’ records; position omitted β†’ every + position in the category (client + partitions/filters from there) + GET /api/pass_block?season=[&position=] β†’ same shape as pass_rush GET /health β†’ readiness probe for Railway Anything else falls through to StaticFiles serving `frontend/`. @@ -199,19 +201,29 @@ def metadata() -> dict: @app.get("/api/pass_rush") def pass_rush( season: int = Query(..., ge=1980, le=2100), - position: str = Query(..., min_length=1, max_length=4), + position: str | None = Query(None, min_length=1, max_length=4), ) -> dict: - if position not in PASS_RUSH_POSITIONS: + if position is not None and position not in PASS_RUSH_POSITIONS: raise HTTPException( 400, f"invalid position: {position!r} β€” choose from {list(PASS_RUSH_POSITIONS)}", ) + filters = [PassRushStat.season == season] + # Omitted position means "every supported position", not literally every + # row for the season β€” the table can carry positions PASS_RUSH_POSITIONS + # doesn't expose (e.g. LB), left over from a broader raw ingest. + filters.append( + PassRushStat.position == position + if position is not None + else PassRushStat.position.in_(PASS_RUSH_POSITIONS) + ) + with SessionLocal() as sess: rows = ( sess.execute( select(PassRushStat) - .where(PassRushStat.season == season, PassRushStat.position == position) + .where(*filters) .order_by(PassRushStat.pr_opp.desc()) ) .scalars() @@ -229,21 +241,26 @@ def pass_rush( @app.get("/api/pass_block") def pass_block( season: int = Query(..., ge=1980, le=2100), - position: str = Query(..., min_length=1, max_length=4), + position: str | None = Query(None, min_length=1, max_length=4), ) -> dict: - if position not in PASS_BLOCK_POSITIONS: + if position is not None and position not in PASS_BLOCK_POSITIONS: raise HTTPException( 400, f"invalid position: {position!r} β€” choose from {list(PASS_BLOCK_POSITIONS)}", ) + filters = [PassBlockStat.season == season] + filters.append( + PassBlockStat.position == position + if position is not None + else PassBlockStat.position.in_(PASS_BLOCK_POSITIONS) + ) + with SessionLocal() as sess: rows = ( sess.execute( select(PassBlockStat) - .where( - PassBlockStat.season == season, PassBlockStat.position == position - ) + .where(*filters) .order_by(PassBlockStat.non_spike_pb_snaps.desc()) ) .scalars() diff --git a/tests/conftest.py b/tests/conftest.py index 3ba480a..b590be5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,28 @@ def seeded_db(): ) ) + # Second position within the same season/category as the ED row above, + # so a no-position request has something real to aggregate (see + # test_pass_rush_no_position_returns_all_positions). + sess.add( + PassRushStat( + season=2025, + position="DI", + team_code="BUF", + player="D. Test", + abbr_name="D. Test", + games=17, + pr_opp=280, + tps_pr_opp=110, + win_rate=12.0, + tps_win_rate=14.0, + pressure_rate=18.0, + tps_pressure_rate=22.0, + havoc_rate=7.0, + tps_havoc_rate=9.0, + ) + ) + sess.add( PassBlockStat( season=2025, @@ -80,6 +102,24 @@ def seeded_db(): ) ) + # Second position, mirrors the pass-rush DI row above. + sess.add( + PassBlockStat( + season=2025, + position="G", + team_code="MIA", + player="G. Uard", + abbr_name="G. Uard", + games=17, + non_spike_pb_snaps=450, + tps_non_spike_pb_snaps=180, + allowed_pressure_pct=4.0, + tps_allowed_pressure_pct=5.0, + allowed_havoc_pct=1.5, + tps_allowed_havoc_pct=2.5, + ) + ) + sess.commit() yield diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index 11719ad..98f7c14 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -31,6 +31,17 @@ def test_pass_rush_rejects_unknown_position(client, seeded_db): assert resp.status_code == 400 +def test_pass_rush_no_position_returns_all_positions(client, seeded_db): + resp = client.get("/api/pass_rush", params={"season": 2025}) + assert resp.status_code == 200 + body = resp.json() + + assert body["position"] is None + positions = {r["position"] for r in body["records"]} + assert positions == {"ED", "DI"} + assert len(body["records"]) == 2 + + def test_pass_rush_requires_query_params(client): resp = client.get("/api/pass_rush") assert resp.status_code == 422 @@ -51,6 +62,17 @@ def test_pass_block_rejects_unknown_position(client, seeded_db): assert resp.status_code == 400 +def test_pass_block_no_position_returns_all_positions(client, seeded_db): + resp = client.get("/api/pass_block", params={"season": 2025}) + assert resp.status_code == 200 + body = resp.json() + + assert body["position"] is None + positions = {r["position"] for r in body["records"]} + assert positions == {"T", "G"} + assert len(body["records"]) == 2 + + def test_pass_rush_unknown_season_returns_empty(client, seeded_db): resp = client.get("/api/pass_rush", params={"season": 1999, "position": "ED"}) assert resp.status_code == 200