diff --git a/.github/workflows/black-lint.yml b/.github/workflows/black-lint.yml index 036e2e9..8a07147 100644 --- a/.github/workflows/black-lint.yml +++ b/.github/workflows/black-lint.yml @@ -1,11 +1,12 @@ -name: black-linter +name: Black Lint on: push: - branches: [main] + branches: [main, dev] paths: - '**/*.py' pull_request: + branches: [main] paths: - '**/*.py' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c54e4..3cf89d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: + branches: [main] jobs: test: diff --git a/README.md b/README.md index d2a0d71..1cde028 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 Lint](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..b089c05 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -32,22 +32,24 @@ const els = { teamsChecklist: document.getElementById("teams-checklist"), 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"), + 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"), 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 @@ -55,15 +57,52 @@ 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() +// 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 @@ -125,6 +164,8 @@ async function loadMetadata() { populateTeamsChecklist(); attachEvents(); await loadCurrentSlice(); + playerPoolRecords = currentRecords; + playerPoolCategory = currentSliceCategory; appliedFilters = currentFilterState(); els.logoPreload.hidden = false; @@ -159,6 +200,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 +311,327 @@ 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 +// 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(); + 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); + }); + + 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 +693,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 +706,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); @@ -443,7 +833,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"; @@ -489,8 +879,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; @@ -499,7 +889,7 @@ function computeKeptLabels(chartDiv, records, xKey, yKey, thresholdField, isDimm return records.map(() => true); } - const CHAR_WIDTH = 6.5; // approx advance width, IBM Plex Mono @ 10px + const CHAR_WIDTH = 6.5; // Approx advance width, IBM Plex Mono @ 10px. const LABEL_HEIGHT = 12; const LABEL_GAP = 10; // vertical offset from marker center to "bottom center" text // Shrink each box by this fraction on every side before the collision test, @@ -549,16 +939,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 +1103,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", @@ -694,7 +1121,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, @@ -773,41 +1200,87 @@ 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 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 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. @@ -823,9 +1296,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 @@ -839,47 +1312,95 @@ 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. + // 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 = ""; + }); + + 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. 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. +function clearScoutCardDragPositions() { + scoutCards.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 = ""; + }); } -// 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, @@ -892,11 +1413,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) { @@ -905,17 +1427,137 @@ 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; } +// 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; +} + +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"; +} + +// 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) : ""; +} + +// 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 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"); + bringScoutCardToFront(cardEl); + cardEl.setPointerCapture(e.pointerId); + e.preventDefault(); +} + +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 + } + 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 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; +} + function closeFiltersDrawer() { els.filtersDrawer.classList.remove("open"); els.filtersToggle.setAttribute("aria-expanded", "false"); @@ -932,11 +1574,15 @@ async function applyFilters() { els.position.value !== appliedFilters.position; if (sliceChanged) { - pinnedIndex = null; - resetScoutCard(); + closeAllScoutCards(); 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 +1590,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 +1651,69 @@ 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(); + }); + + // 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", () => { + 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 +1725,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 +1747,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(); }); @@ -1089,54 +1815,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 53799f3..0658fe9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ LinesShines Β· ι‹’ε…‰ - + @@ -73,13 +73,50 @@

LinesShines Β· ι‹’ε…‰

+ +
+
+ + +
+
+ + +
+
+
β€” multi-selecting 32 options via ctrl/cmd-click isn't discoverable and doesn't work at all on touch. Positioned/sized to sit @@ -428,6 +442,197 @@ body { border-radius: 2px; } +/* --- Players autocomplete + chip list. --- + Sits next to Teams in the control bar; desktop/tablet only (hidden in the + @media (max-width: 860px) block below β€” the pool this searches depends on + live threshold state that doesn't translate cleanly to a touch control, + see app.js's qualifyingPlayerPool()). Collapsed behind a summary button + + click-to-open .players-panel, the same button+panel mechanism as Teams + (.teams-select-btn is reused directly on the Players button, not + duplicated) β€” the chip list only has to fit inside the panel, not the + 150px collapsed button, so it can grow to several chips deep without + ballooning the row past its neighbors. */ +/* No flex-grow (unlike .control-slider) β€” this used to grow to fill + whatever space was left in the row, which was wide enough to push Y axis + onto a second row even though there was nominally "one row" worth of + controls. Teams/Category/Position all settle at exactly 150px via their + select/button's min-width, with no explicit width needed, because their + content never exceeds it β€” but a bare 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; } + +/* Replaces the plain