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/5] 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/5] 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/5] 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/5] 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. -->
- +
+ + +
-