diff --git a/README.md b/README.md index 1cde028..0701a19 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ #### ② Frontend ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black) +![Plotly](https://img.shields.io/badge/Plotly-0d1117?style=for-the-badge&logo=plotly&logoColor=3F4F75) ![CSS](https://img.shields.io/badge/CSS-1572B6?style=for-the-badge&logo=css3&logoColor=white) ![HTML](https://img.shields.io/badge/HTML-E34F26?style=for-the-badge&logo=html5&logoColor=white) -- JavaScript + CSS + HTML — visualize plotly graphs and interactive components. +- JavaScript + Plotly + CSS + HTML — visualize plotly graphs and interactive components. #### ③ CI/CD ![Pytest](https://img.shields.io/badge/Pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white) @@ -48,3 +49,13 @@ - Pytest — unit & API tests. - GitHub — CI/CD pipeline triggers. - Railway — exploration platform deployment. + +#### [④ Special Image Exporter: html2canvas](https://html2canvas.hertzen.com) + +Is the card-export engine since LinesShines v1.3.0~~ + +I truly recommend [html2canvas](https://github.com/niklasvh/html2canvas) for: + +- DOM-to-image conversion without needing a server-side rendering step. +- ignoreElements support — let UI controls (close buttons, resize handles) stay out of exported images. +- MIT-licensed, which is cleanly preserved in vendored files. diff --git a/config.py b/config.py index 9c7adf8..8e064ca 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,8 @@ NFL_BASE_PATH = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(NFL_BASE_PATH, "data") -ROUNDING_DIGITS = 1 +ROUNDING_DECIMALS = 3 # Better precision alleviates overlaps in plots. +DISPLAY_DECIMALS = 1 # For UI displays only. FRONT_7_NAMES = {"DI": "Defensive Interior", "ED": "Edge", "LB": "Linebacker"} OL_NAMES = {"T": "Offensive Tackles", "G": "Guards", "C": "Centers"} @@ -15,7 +16,7 @@ "Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps." ) -# Default thresholds applied on page load. +# Default "historical seasons'" thresholds applied on page load. DEFAULT_THRESHOLDS = { "pass_rush": 230, # Min PR Opp for pass rush filter. "pass_block": 300, # Min Non Spike PB Snaps for pass block filter. diff --git a/database/db_ingestion.py b/database/db_ingestion.py index 80277ec..4463ac3 100644 --- a/database/db_ingestion.py +++ b/database/db_ingestion.py @@ -10,12 +10,12 @@ per-row upsert and dialect-agnostic (works on both SQLite and Postgres). Usage: -1. Must always go from repo root. +1. Must always go from project root. 2. After running two preprocessing scripts. 3. python -m database.db_ingestion For Railway, set DATABASE_URL to Postgres URL Railway assigns. -Point LINESHINES_REPO_ROOT at LinesShines repo root so scripts can +Point LINESHINES_REPO_ROOT at LinesShines project root so scripts can locate xlsx files under $LINESHINES_REPO_ROOT/data/. """ @@ -37,14 +37,14 @@ OL_POSITIONS = ("T", "G", "C") -def _find_repo_root() -> Path: - """Walk up from this file until we find a marker that identifies the repo root.""" +def _find_project_root() -> Path: + """Walk up from this file until we find a marker that identifies project root.""" here = Path(__file__).resolve().parent for candidate in [here, *here.parents]: if (candidate / ".gitignore").exists(): return candidate # Root is found. - raise RuntimeError("could not locate repo root from " + str(here)) + raise RuntimeError("could not locate project root from " + str(here)) def _repo_root() -> Path: @@ -64,7 +64,7 @@ def _repo_root() -> Path: if env_root: return Path(env_root) / "data" - return _find_repo_root() / "data" + return _find_project_root() / "data" def _safe_int(val): @@ -238,7 +238,7 @@ def main() -> None: pass_block_rows = ingest_pass_block(sess, data_dir, args.seasons) print( - f"\nIngested {pass_rush_rows} pass-rush rows and {pass_block_rows} pass-block rows." + f"\nBulk-inserted {pass_rush_rows} pass-rush rows and {pass_block_rows} pass-block rows." ) diff --git a/frontend/app.js b/frontend/app.js deleted file mode 100644 index ea22ea7..0000000 --- a/frontend/app.js +++ /dev/null @@ -1,3562 +0,0 @@ -/* LinesShines · 鋒光. - * Talks to the FastAPI backend for metadata + per-slice records: - * GET /api/metadata - * GET /api/pass_rush?season= (all positions in the category, one response) - * GET /api/pass_block?season= - * Filtering (min-snap threshold, position), axis choice, and label toggle - * all run client-side against a small in-memory cache of already-fetched - * slices — see fetchSlice()/positionPool() below. - */ - -// Team logos live next to LinesShines/logos/ — the FastAPI service exposes -// them under /logos/ (see main.py static mount) once the frontend is -// deployed inside the LinesShines repo. -const LOGO_PATH = (team) => `logos/${team}.png`; - -const els = { - category: document.getElementById("category-select"), - season: document.getElementById("season-select"), - position: document.getElementById("position-select"), - xMetric: document.getElementById("x-metric-select"), - yMetric: document.getElementById("y-metric-select"), - threshold: document.getElementById("threshold-slider"), - thresholdNumber: document.getElementById("threshold-number"), - applyBtn: document.getElementById("apply-filters"), - savePngBtn: document.getElementById("save-png-btn"), - thresholdFieldLabel: document.getElementById("threshold-field-label"), - labelsToggle: document.getElementById("labels-toggle"), - logosToggle: document.getElementById("logos-toggle"), - teamsControl: document.querySelector(".control-teams"), - teamsBtn: document.getElementById("teams-toggle-btn"), - teamsSummary: document.getElementById("teams-select-summary"), - teamsDropdown: document.getElementById("teams-dropdown"), - 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"), - scoutCards: document.getElementById("scout-cards"), - scoutEmptyHint: document.getElementById("scout-empty-hint"), - scoutCardTemplate: document.getElementById("scout-card-template"), - mergeCardTemplate: document.getElementById("merge-card-template"), - linemateCardTemplate: document.getElementById("linemate-card-template"), - mergeConfirmOverlay: document.getElementById("merge-confirm-overlay"), - mergeConfirmCancel: document.getElementById("merge-confirm-cancel"), - mergeConfirmClear: document.getElementById("merge-confirm-clear"), - workspaceNoticeOverlay: document.getElementById("workspace-notice-overlay"), - workspaceNoticeBody: document.getElementById("workspace-notice-body"), - workspaceNoticeOk: document.getElementById("workspace-notice-ok"), - // Pinned Players (BLUEPRINT_PinnedPlayers.md) — see - // renderPlayerCardsSpace() in app.js. - playerCardsSpace: document.getElementById("player-cards-space"), - pcsQuotaLabel: document.getElementById("pcs-quota-label"), - pcsInspectBtn: document.getElementById("pcs-inspect-btn"), - pcsPanel: document.getElementById("pcs-panel"), - pcsSinglesList: document.getElementById("pcs-singles-list"), - pcsMergedList: document.getElementById("pcs-merged-list"), - pcsMergedEmpty: document.getElementById("pcs-merged-empty"), - // Single Cards add-search (v1.2.0 §4) — Pinned Players' only entry point. - pcsAddInput: document.getElementById("pcs-add-input"), - pcsAddDropdown: document.getElementById("pcs-add-dropdown"), - // Merged Cards "Create" button + its popup (v1.2.0 §5). - pcsCreateBtn: document.getElementById("pcs-create-btn"), - mergeCreateOverlay: document.getElementById("merge-create-overlay"), - mergeCreateMembers: document.getElementById("merge-create-members"), - mergeCreateInput: document.getElementById("merge-create-input"), - mergeCreateDropdown: document.getElementById("merge-create-dropdown"), - mergeCreateMessage: document.getElementById("merge-create-message"), - mergeCreateCancel: document.getElementById("merge-create-cancel"), - mergeCreateSubmit: document.getElementById("merge-create-submit"), - // Merge Card membership editor popup. - mergeEditOverlay: document.getElementById("merge-edit-overlay"), - mergeEditMembers: document.getElementById("merge-edit-members"), - mergeEditInput: document.getElementById("merge-edit-input"), - mergeEditDropdown: document.getElementById("merge-edit-dropdown"), - mergeEditMessage: document.getElementById("merge-edit-message"), - mergeEditDone: document.getElementById("merge-edit-done"), -}; - -let metadata = null; // /api/metadata payload -const sliceCache = new Map(); // key = `${category}:${season}:${position}` → records[] -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 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; - -// Pinned Players Workspace (BLUEPRINT_PinnedPlayers.md §1/§3) — the -// persistent Single Cards list, keyed by the same full "player" string as -// everything else (record.player → record). Populated ONLY by the Single -// Cards fuzzy-search box (addPlayerToSingleCards(), v1.2.0 §4) — a plot click -// never touches this, see viewFloatingCard(). Deliberately independent of -// scoutCards below: a player can be listed here with his floating card -// closed, or vice versa — see removeSingleCard()/closeScoutCard(). Together -// with mergeCards' memberKeys (declared further down), this is what -// distinctWorkspacePlayers() counts against the 8-player quota. -const workspaceSingles = new Map(); - -// Live floating Player Cards, one per currently-open card, 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. Purely an "is this -// floating card open" registry now — it does NOT imply Workspace membership, -// see workspaceSingles above. -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 -// where the threshold should snap to that category's configured default -// rather than carrying over a value from a different threshold_field scale -// (PR Opp vs Non Spike PB Snaps aren't comparable). Season/position changes -// within the same category leave this false, so the user's value persists. -// If the user manually edits the threshold slider/number while a category -// switch is still pending (not yet Applied), that's an explicit override — -// it clears this flag so applyFilters() doesn't clobber it with the new -// category's default. -let resetThresholdOnNextRange = true; - -// Decoded Image objects, keyed by team code, filled once at page load so -// filter changes never wait on the browser to re-resolve/decode /logos/*.png -// again — layout.images and the scout card both just point at these. -const logoCache = {}; - -async function preloadLogos() { - if (!metadata || !metadata.teams) return; - const codes = Object.keys(metadata.teams); - await Promise.all( - codes.map( - (code) => - new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - logoCache[code] = img; - resolve(); - }; - img.onerror = () => resolve(); // missing file → falls back to the badge/no source - img.src = LOGO_PATH(code); - }) - ) - ); -} - -// Prefer the preloaded, already-decoded image's resolved URL over the raw -// relative path — same bytes, but guarantees Plotly/the tag hit the -// exact URL the browser already cached. -function logoSrc(team) { - return logoCache[team] ? logoCache[team].src : LOGO_PATH(team); -} - -// Logos are sized as a fixed pixel target rather than a fraction of plot -// width so a dense mobile chart doesn't inherit same visual scale as a 1100px desktop chart. -// Mobile gets a smaller absolute size to cut overlap. -// Player labels default to on, logos take up less room and collision is smaller. -function targetLogoPx() { - return window.innerWidth < 860 ? 16 : 26; -} - -async function loadMetadata() { - const res = await fetch("/api/metadata"); - if (!res.ok) throw new Error(`GET /api/metadata → ${res.status}`); - metadata = await res.json(); - - populateCategoryDependentControls(); - populateTeamsChecklist(); - attachEvents(); - await loadCurrentSlice(); - playerPoolRecords = currentRecords; - playerPoolCategory = currentSliceCategory; - appliedFilters = currentFilterState(); - - els.logoPreload.hidden = false; - await preloadLogos(); - els.logoPreload.hidden = true; - - render(); - renderPlayerCardsSpace(); - updatePendingState(); -} - -function currentCategoryMeta() { - return metadata[els.category.value]; -} - -// The category metadata for whatever's actually plotted right now, as -// opposed to currentCategoryMeta() which tracks the (possibly still -// pending, not-yet-applied) category select. -function appliedCategoryMeta() { - return metadata[appliedFilters.category]; -} - -function currentFilterState() { - return { - category: els.category.value, - season: els.season.value, - position: els.position.value, - xMetric: els.xMetric.value, - yMetric: els.yMetric.value, - threshold: els.thresholdNumber.value, - // Joined into a comparable string (not a bare array) so the `!==` - // check in filtersArePending() works the same way it does for every - // 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(","), - }; -} - -function allTeamCodes() { - return Array.from(els.teamsChecklist.querySelectorAll("input[type=checkbox]")).map((cb) => cb.value); -} - -function selectedTeamCodes() { - return Array.from(els.teamsChecklist.querySelectorAll("input[type=checkbox]:checked")).map((cb) => cb.value); -} - -// Teams are global (not category/season-scoped like positions/metrics are), -// so this only runs once at startup rather than from -// populateCategoryDependentControls() — repopulating on every category -// switch would silently reset an in-progress team selection back to "all". -// NFL conference/division structure — not exposed by /api/metadata (it's -// static league structure, not PFF-derived data), so it lives here purely -// to lay the Teams checklist out like NFL.com: AFC in the left column, NFC -// in the right, each divided into East/North/South/West groups. Codes match -// the LinesShines/PFF spellings in teams_reference.py (BLT, CLV, HST, LA, -// LV, ...), not the NFL's own abbreviations. -const CONFERENCES = { - AFC: { - East: ["BUF", "MIA", "NE", "NYJ"], - North: ["BLT", "CIN", "CLV", "PIT"], - South: ["HST", "IND", "JAX", "TEN"], - West: ["DEN", "KC", "LAC", "LV"], - }, - NFC: { - East: ["DAL", "NYG", "PHI", "WAS"], - North: ["CHI", "DET", "GB", "MIN"], - South: ["ATL", "CAR", "NO", "TB"], - West: ["ARZ", "LA", "SEA", "SF"], - }, -}; - -function teamOptionRow(code) { - const label = document.createElement("label"); - label.className = "team-option"; - - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.value = code; - cb.checked = true; - - const logo = document.createElement("img"); - logo.className = "team-option-logo"; - logo.src = logoSrc(code); - logo.alt = ""; - logo.loading = "lazy"; - logo.onerror = () => logo.replaceWith(teamSwatch(code)); - - const text = document.createElement("span"); - text.textContent = code; - - label.append(cb, logo, text); - return label; -} - -function populateTeamsChecklist() { - els.teamsChecklist.innerHTML = ""; - - Object.entries(CONFERENCES).forEach(([conference, divisions]) => { - const column = document.createElement("div"); - column.className = "teams-column"; - - const columnHeader = document.createElement("div"); - columnHeader.className = "teams-column-header"; - columnHeader.textContent = conference; - column.appendChild(columnHeader); - - Object.entries(divisions).forEach(([division, codes]) => { - const group = document.createElement("div"); - group.className = "teams-division"; - - const divisionHeader = document.createElement("div"); - divisionHeader.className = "teams-division-header"; - divisionHeader.textContent = division; - group.appendChild(divisionHeader); - - // Codes are already listed ascending within each division above. - codes.forEach((code) => group.appendChild(teamOptionRow(code))); - column.appendChild(group); - }); - - els.teamsChecklist.appendChild(column); - }); - - updateTeamsSummary(); -} - -function updateTeamsSummary() { - const selected = selectedTeamCodes(); - const total = allTeamCodes().length; - if (selected.length === total) els.teamsSummary.textContent = "All Teams"; - else if (selected.length === 0) els.teamsSummary.textContent = "No Teams"; - else if (selected.length === 1) els.teamsSummary.textContent = teamName(selected[0]); - else els.teamsSummary.textContent = `${selected.length} Teams`; -} - -function openTeamsDropdown() { - els.teamsDropdown.hidden = false; - els.teamsBtn.setAttribute("aria-expanded", "true"); -} - -function closeTeamsDropdown() { - els.teamsDropdown.hidden = true; - 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. -// playerPoolRecords now holds every position in the category (see -// fetchSlice), so this also scopes to the pending position — otherwise a -// query typed while Position=ED would start suggesting DI players too. -function qualifyingPlayerPool() { - if (!playerPoolCategory) return []; - const cat = metadata[playerPoolCategory]; - const minThreshold = Number(els.thresholdNumber.value); - return playerPoolRecords.filter( - (r) => r.position === els.position.value && r[cat.threshold_field] >= minThreshold - ); -} - -// Top `topK` matches for `query` among `pool`, excluding any player key in -// `excludeKeys`. 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. Shared by the Players filter (searchPlayers below, excluding -// already-selected chips) and the Merge Card Edit popup -// (BLUEPRINT_PinnedPlayers.md §5, same fuzzy-match style, its own -// exclusion set) so both stay on exactly one matching engine. -function searchPlayersExcluding(query, pool, excludeKeys, topK = 8) { - const queryTokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - if (queryTokens.length === 0) return []; - - const scored = pool - .filter((record) => !excludeKeys.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); -} - -// Players filter's own search — no point suggesting a chip that already -// exists, so it excludes whatever's already selected there. -function searchPlayers(query, pool, topK = 8) { - return searchPlayersExcluding(query, pool, new Set(selectedPlayers.keys()), topK); -} - -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(); - return Object.keys(current).some((key) => current[key] !== appliedFilters[key]); -} - -// Lights up the Apply button whenever any batched control (category, -// season, position, either axis, or threshold) holds a value the chart -// hasn't been rendered with yet — the only feedback the user gets now that -// none of these re-render on their own. -function updatePendingState() { - els.applyBtn.classList.toggle("pending", filtersArePending()); -} - -function populateCategoryDependentControls() { - const cat = currentCategoryMeta(); - - // Positions - els.position.innerHTML = ""; - Object.entries(cat.positions).forEach(([code, label]) => { - const opt = document.createElement("option"); - opt.value = code; - opt.textContent = `${label}`; - els.position.appendChild(opt); - }); - - // Seasons (already sorted desc by the API) - els.season.innerHTML = ""; - cat.seasons.forEach((s) => { - const opt = document.createElement("option"); - opt.value = s; - opt.textContent = s; - els.season.appendChild(opt); - }); - - // Metrics - const metricKeys = Object.keys(cat.metrics); - [els.xMetric, els.yMetric].forEach((select) => { - select.innerHTML = ""; - metricKeys.forEach((m) => { - const opt = document.createElement("option"); - opt.value = m; - opt.textContent = m; - select.appendChild(opt); - }); - }); - // Distinct defaults, mirroring the pipeline's canonical query pairs - // (e.g. plain Win Rate vs. TPS Win Rate). Pass rush gets an explicit - // Win Rate / Havoc Rate pairing; pass block falls back to the generic - // non-TPS-vs-TPS heuristic. - if (els.category.value === "pass_rush" && metricKeys.includes("Win Rate") && metricKeys.includes("Havoc Rate")) { - els.xMetric.value = "Win Rate"; - els.yMetric.value = "Havoc Rate"; - } else { - els.xMetric.value = metricKeys.find((m) => !m.startsWith("TPS")) || metricKeys[0]; - els.yMetric.value = metricKeys.find((m) => m.startsWith("TPS")) || metricKeys[1] || metricKeys[0]; - } - - els.thresholdFieldLabel.textContent = thresholdFieldLabel(cat); -} - -// Shared by loadCurrentSlice() (Apply-gated, drives the chart) and -// updatePlayerPool() (live, drives only the Players search pool) — both -// just need every position's records for a given category/season, memoized -// in sliceCache so switching back to an already-seen combination is free. -// No position param: the API returns every position in the category (see -// main.py), and the client partitions by position from here on — required -// so Linemate Cards can pull cross-position rosters (T/G/C, ED/DI) out of -// the same in-memory slice instead of a second fetch. Percentile pools must -// still be computed per exact position (see positionPool() below) — never -// over this combined array — per the "first philosophy" comment in -// BLUEPRINT.md §3. -async function fetchSlice(category, season) { - const key = `${category}:${season}`; - if (!sliceCache.has(key)) { - const url = `/api/${category}?season=${season}`; - const res = await fetch(url); - if (!res.ok) throw new Error(`GET ${url} → ${res.status}`); - const data = await res.json(); - sliceCache.set(key, data.records || []); - } - return sliceCache.get(key); -} - -async function loadCurrentSlice() { - const category = els.category.value; - const season = Number(els.season.value); - currentRecords = await fetchSlice(category, season); - currentSliceCategory = category; - updateThresholdRange(); -} - -// Mirrors loadCurrentSlice(), but for whatever category/season the controls -// are pending on right now, and never touches currentRecords/ -// currentSliceCategory/updateThresholdRange — those stay reserved for the -// last Applied slice the chart is actually showing. Fired on every -// category/season/position change (see attachEvents()) so the Players -// 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); - playerPoolRecords = await fetchSlice(category, season); - playerPoolCategory = category; - prunePlayerSelections(); - runPlayersSearch(); -} - -// Every position pool now lives in currentRecords (see fetchSlice above), so -// percentiles/ranks for Merge and Linemate cards must filter down to one -// exact position — never the combined multi-position array — before ranking. -// Mirrors currentFiltered's own filter in render(), just parameterized over -// position instead of being locked to appliedFilters.position. -function positionPool(position) { - const cat = appliedCategoryMeta(); - const minThreshold = Number(appliedFilters.threshold); - return currentRecords.filter((r) => r.position === position && r[cat.threshold_field] >= minThreshold); -} - -// Shared pool for every Pinned Players fuzzy-search box — the Single -// Cards add-search, the Create-merge popup, and the Edit-merge popup -// (BLUEPRINT_PinnedPlayers.md v1.2.0 §4: never cross-position, always -// "the same pool the plot is showing"). Just positionPool() locked to -// appliedFilters.position rather than an arbitrary position argument. -function pcsSearchPool() { - return positionPool(appliedFilters.position); -} - -function updateThresholdRange() { - const cat = currentCategoryMeta(); - // currentRecords now holds every position in the category (see - // fetchSlice) — scope to the pending position before computing the - // slider's max, otherwise switching to a smaller-pool position (e.g. DI) - // would inherit a max sized for a bigger one (e.g. ED). - const positionRecords = currentRecords.filter((r) => r.position === els.position.value); - const values = positionRecords.map((r) => r[cat.threshold_field]).filter((v) => v != null); - const maxVal = values.length ? Math.max(...values) : 100; - const max = Math.ceil(maxVal / 10) * 10; - - els.threshold.min = 0; - els.threshold.max = max; - els.threshold.step = 5; - - if (resetThresholdOnNextRange) { - const defaultValue = cat.default_threshold ?? 0; - els.threshold.value = Math.min(Math.max(defaultValue, 0), max); - resetThresholdOnNextRange = false; - } else if (Number(els.threshold.value) > max) { - // Season/position change within the same category — keep the user's - // value, only clamping if the new slice's max no longer covers it. - els.threshold.value = max; - } - - els.thresholdNumber.min = 0; - els.thresholdNumber.max = max; - els.thresholdNumber.value = els.threshold.value; -} - -// Metric labels can contain "%" or "/" (e.g. "Pressure %"), which aren't -// safe/clean in a downloaded filename — collapse any run of non-alphanumeric -// characters to a single underscore. -function sanitizeForFilename(value) { - return String(value).trim().replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); -} - -// Credit strip baked into exported PNGs only — the on-screen chart never -// shows this (the page's own .meta-band already covers it for site -// visitors). Drawn via canvas rather than a Plotly annotation: the extra -// margin an in-chart annotation would need depends on the live isMobile -// axis-title sizing (see render()'s margin.b), which is fragile to -// replicate here — layering a fixed-height strip onto the finished raster -// is simpler and pixel-exact regardless of what layout produced it. -const EXPORT_FOOTER_TEXT = "LinesShines · www.lines-shines.com · Source: PFF Premium Stats"; -const EXPORT_FOOTER_HEIGHT = 30; // logical px, pre-scale -const EXPORT_FOOTER_FONT_SIZE = 12; // logical px, pre-scale — chart-annotation size -const EXPORT_FOOTER_PADDING_X = 16; // logical px, pre-scale -const EXPORT_FOOTER_BG = "#16301f"; // matches --turf-800, same swap render() does for export bg -const EXPORT_FOOTER_COLOR = "rgba(169, 182, 169, 0.75)"; // --chalk-dim, muted so it doesn't compete with the plot - -// Renders the chart to a PNG via Plotly.toImage, then composites a footer -// strip onto a taller canvas before triggering the download — keeps the -// credit line out of the on-screen/exported-without-footer chart state. -function exportChartPngWithFooter(chartDiv, { width, height, scale, filename }) { - return Plotly.toImage(chartDiv, { format: "png", width, height, scale }).then( - (dataUrl) => - new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - const footerPx = Math.round(EXPORT_FOOTER_HEIGHT * scale); - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height + footerPx; - - const ctx = canvas.getContext("2d"); - ctx.fillStyle = EXPORT_FOOTER_BG; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0); - - ctx.fillStyle = EXPORT_FOOTER_COLOR; - ctx.font = `${Math.round(EXPORT_FOOTER_FONT_SIZE * scale)}px Inter, sans-serif`; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - ctx.fillText( - EXPORT_FOOTER_TEXT, - canvas.width - Math.round(EXPORT_FOOTER_PADDING_X * scale), - img.height + footerPx / 2 - ); - - canvas.toBlob((blob) => { - if (!blob) { - reject(new Error("canvas.toBlob returned null")); - return; - } - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${filename}.png`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); - resolve(); - }, "image/png"); - }; - img.onerror = () => reject(new Error("Failed to load rendered chart image")); - img.src = dataUrl; - }) - ); -} - -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - const n = sorted.length; - if (n === 0) return null; - if (n % 2 === 1) return sorted[(n - 1) / 2]; - return Math.round(((sorted[n / 2 - 1] + sorted[n / 2]) / 2) * 10) / 10; -} - -// Shared by the threshold control's own label and the chart subtitle, so -// the two can't drift out of sync with each other. -function thresholdFieldLabel(cat) { - return cat.threshold_field === "PR Opp" ? "pass rush opportunities" : "non-spike pass block snaps"; -} - -function formatValue(value, meta) { - if (value == null) return "—"; - const unit = meta && meta.unit ? meta.unit : ""; - return `${value}${unit}`; -} - -// Some metric display names (OL's "Allowed Pressure %", "TPS Allowed Havoc %") -// already end in the unit symbol, since PFF's naming bakes it in — appending -// " (%)" on top of that would duplicate it. DL names ("Win Rate", "Havoc Rate") -// don't carry the unit, so they still need the suffix appended. -function axisTitle(metricName, meta) { - const unit = meta && meta.unit ? meta.unit : ""; - if (!unit) return metricName; - if (metricName.trimEnd().endsWith(unit)) return metricName; - return `${metricName} (${unit})`; -} - -function ordinal(n) { - const rem100 = n % 100; - if (rem100 >= 11 && rem100 <= 13) return `${n}th`; - switch (n % 10) { - case 1: - return `${n}st`; - case 2: - return `${n}nd`; - case 3: - return `${n}rd`; - default: - return `${n}th`; - } -} - -// Rank (1 = best) and percentile (100 = best) for `value` on metric `key` -// among `pool`, respecting the metric's higher/lower-is-better direction -// (see PASS_RUSH_METRICS / PASS_BLOCK_METRICS in main.py). Ties share a -// rank — competition ranking, so equal values don't get an arbitrary -// tiebreak order — and the pool is exactly `currentFiltered`, i.e. the -// same season/position/category/threshold population the chart is -// currently plotting, not the unfiltered slice. -function rankAndPercentile(pool, key, higherIsBetter, value) { - if (value == null) return null; - const values = pool.map((r) => r[key]).filter((v) => v != null); - const n = values.length; - if (n < 2) return null; - const better = (v) => (higherIsBetter ? v > value : v < value); - const rank = values.filter(better).length + 1; - const percentile = Math.round(((n - rank) / (n - 1)) * 100); - return { rank, n, percentile }; -} - -// Avoids a stray double period in messages that tack a full stop onto a -// player name — "Jr."/"Sr." suffixes (and any other name already ending in -// a period) don't get a second one. -function withTrailingPeriod(text) { - return text.endsWith(".") ? text : `${text}.`; -} - -function teamColor(code) { - const t = metadata.teams && metadata.teams[code]; - return (t && t.primary_color) || "#6b7a6f"; -} - -function teamName(code) { - const t = metadata.teams && metadata.teams[code]; - return (t && t.full_name) || code; -} - -// Fallback swatch for the teams-dropdown checklist when a team's logo file -// 404s — mirrors openScoutCard()'s logoImg.onerror treatment. -function teamSwatch(code) { - const span = document.createElement("span"); - span.className = "team-swatch"; - span.style.background = teamColor(code); - return span; -} - -// sizex/sizey for layout.images are in DATA units, not pixels, so logo size -// needs recomputing whenever the visible axis range or plot size changes -// (zoom, pan, resize) — otherwise logos balloon, shrink, or drift off their -// intended on-screen scale. -// `isDimmed` (aligned index-for-index with `records`) fades logos for -// players whose team isn't in the current Teams selection, rather than -// dropping them from the plot entirely — see DIM_OPACITY. -function computeLogoImages(chartDiv, records, xKey, yKey, isDimmed) { - const fullLayout = chartDiv._fullLayout; - const xAxis = fullLayout && fullLayout.xaxis; - const yAxis = fullLayout && fullLayout.yaxis; - if (!xAxis || !yAxis || !xAxis._length || !yAxis._length) return []; - - // Target size in pixels (same for x and y so logos render square), then - // converted back to each axis's data units. - const targetPx = targetLogoPx(); - const xRangeSpan = Math.abs(xAxis.range[1] - xAxis.range[0]); - const yRangeSpan = Math.abs(yAxis.range[1] - yAxis.range[0]); - const sizex = targetPx * (xRangeSpan / xAxis._length); - const sizey = targetPx * (yRangeSpan / yAxis._length); - - return records.map((r, i) => ({ - source: logoSrc(r.team), - xref: "x", yref: "y", - x: r[xKey], y: r[yKey], - sizex, sizey, - xanchor: "center", yanchor: "middle", - layer: "above", - opacity: isDimmed && isDimmed[i] ? DIM_OPACITY.logo : 1, - })); -} - -// Approximates adjustText's declutter effect without the library: process -// labels in descending threshold_field order (so star players get first -// claim), keep a label only if its approximate pixel bounding box doesn't -// 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, 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; - const yAxis = fullLayout && fullLayout.yaxis; - if (!xAxis || !yAxis || typeof xAxis.l2p !== "function") { - return records.map(() => true); - } - - 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, - // so two labels have to genuinely overlap (not just sit close) to bump one - // another — trades a bit of edge-touching/kerning overlap for showing more - // names in dense clusters. - const OVERLAP_TOLERANCE = 0.35; - // Comfortably larger than any real threshold_field value, so it dominates - // the sort without needing a second sort key. - const HIGHLIGHT_BOOST = 1e9; - - const boxes = records.map((r, i) => { - const label = r.abbr_name || r.player || ""; - const cx = xAxis.l2p(r[xKey]); - const top = yAxis.l2p(r[yKey]) + LABEL_GAP; - const halfWidth = (label.length * CHAR_WIDTH) / 2; - const shrinkX = halfWidth * OVERLAP_TOLERANCE; - const shrinkY = (LABEL_HEIGHT / 2) * OVERLAP_TOLERANCE; - return { - left: cx - halfWidth + shrinkX, right: cx + halfWidth - shrinkX, - top: top + shrinkY, bottom: top + LABEL_HEIGHT - shrinkY, - priority: (isDimmed && isDimmed[i] ? 0 : HIGHLIGHT_BOOST) + (r[thresholdField] ?? 0), - }; - }); - - const order = boxes.map((_, i) => i).sort((a, b) => boxes[b].priority - boxes[a].priority); - const kept = new Array(records.length).fill(false); - const placed = []; - - order.forEach((i) => { - const box = boxes[i]; - const overlaps = placed.some( - (p) => box.left < p.right && box.right > p.left && box.top < p.bottom && box.bottom > p.top - ); - if (!overlaps) { - kept[i] = true; - placed.push(box); - } - }); - - return kept; -} - -// Teams is a highlight, not a filter — a player whose team isn't selected -// stays on the plot (still visible, still clickable, still counted in the -// median) but fades to these opacities instead of disappearing. -const DIM_OPACITY = { marker: 0.15, logo: 0.22, label: 0.12 }; -const LABEL_ALPHA = 0.8; // 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(",") : []); - - // currentRecords holds every position in the category (see fetchSlice) — - // scope to the applied position here, same as positionPool() does for - // Merge/Linemate cards, so the chart's own percentile pool never mixes - // positions. - currentFiltered = currentRecords.filter( - (r) => r.position === appliedFilters.position && r[cat.threshold_field] >= minThreshold - ); - // Players joins Teams via OR — a player is highlighted if their team is - // selected OR they were explicitly added, so an explicitly-picked player - // off a dimmed team still stands out. Empty selectedTeams (Teams → None) - // 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; - Plotly.purge(els.chart); - els.savePngBtn.disabled = true; - els.savePngBtn.title = "Load some data first"; - return; - } - els.emptyState.hidden = true; - els.savePngBtn.disabled = false; - els.savePngBtn.title = "Save chart as PNG"; - - const xKey = appliedFilters.xMetric; - const yKey = appliedFilters.yMetric; - const xMeta = cat.metrics[xKey] || {}; - const yMeta = cat.metrics[yKey] || {}; - - const activeNotes = []; - if (xMeta.note) activeNotes.push(xMeta.note); - if (yMeta.note && yMeta.note !== xMeta.note) { - activeNotes.push(yMeta.note); // avoid dup when both axes use the same metric family - } - - const xVals = currentFiltered.map((r) => r[xKey]); - const yVals = currentFiltered.map((r) => r[yKey]); - const colors = currentFiltered.map((r) => teamColor(r.team)); - - const showLabels = els.labelsToggle.checked; - const showLogos = els.logosToggle.checked; - - const trace = { - x: xVals, - y: yVals, - mode: showLabels ? "markers+text" : "markers", - type: "scatter", - text: currentFiltered.map((r) => r.abbr_name || r.player), - // logos sit on the dot itself, so push labels below to avoid clashing - textposition: showLogos && showLabels ? "bottom center" : "top center", - textfont: { - color: isDimmed.map((dim) => `rgba(241,236,221,${dim ? DIM_OPACITY.label : LABEL_ALPHA})`), - size: 10, - family: "IBM Plex Mono, monospace", - }, - marker: { - color: colors, - size: 13, - // invisible dots still receive hover/click — only the fill disappears — - // so the scouting card keeps working with logos drawn on top via - // layout.images (Plotly has no native "image as marker" option). When - // logos are off, dimmed (unselected-team) markers still fade in place - // rather than being dropped from the trace. - opacity: showLogos ? 0 : isDimmed.map((dim) => (dim ? DIM_OPACITY.marker : 1)), - line: { color: "rgba(15,33,25,0.65)", width: 1 }, - }, - // The scouting card is the hover UI — Plotly's own tooltip would just - // duplicate it right next to the cursor, so suppress it here. hover/click - // events still fire with hoverinfo:'none', only the built-in popup dies. - hoverinfo: "none", - }; - - const xMedian = median(xVals); - const yMedian = median(yVals); - - const shapes = [ - { - type: "line", xref: "x", yref: "paper", x0: xMedian, x1: xMedian, y0: 0, y1: 1, - line: { color: "#a9b6a9", width: 1, dash: "dash" }, - }, - { - type: "line", xref: "paper", yref: "y", x0: 0, x1: 1, y0: yMedian, y1: yMedian, - line: { color: "#a9b6a9", width: 1, dash: "dash" }, - }, - ]; - - const medianAnnotations = [ - { - x: xMedian, y: 0, yref: "paper", yanchor: "top", yshift: -6, - text: `Median: ${xMedian}`, showarrow: false, - font: { color: "#a9b6a9", size: 10, family: "IBM Plex Mono, monospace" }, - }, - { - x: 0, xref: "paper", xanchor: "right", xshift: -6, y: yMedian, - text: `Median: ${yMedian}`, showarrow: false, textangle: -90, - font: { color: "#a9b6a9", size: 10, family: "IBM Plex Mono, monospace" }, - }, - ]; - - // Metric definition callouts (e.g. what "Havoc Rate" means) — only shown - // when a Havoc-family metric is on an axis, see activeNotes above. - const isMobile = window.innerWidth < 860; - const noteAnnotations = activeNotes.map((note, i) => ({ - xref: "paper", yref: "paper", - x: 1, y: 1 - i * 0.05, // stack multiple notes vertically if both axes have notes - xanchor: "right", yanchor: "top", - text: `ⓘ ${note}`, - showarrow: false, - font: { - family: "IBM Plex Mono, monospace", - size: isMobile ? 9 : 11, - color: "#a9b6a9", - }, - bgcolor: "rgba(15,33,25,0.85)", // turf-950 with alpha - bordercolor: "rgba(211,167,61,0.4)", // faint gold border - borderwidth: 1, - borderpad: 6, - })); - - const annotations = [...medianAnnotations, ...noteAnnotations]; - - const reversed = appliedFilters.category === "pass_block"; // lower allowed% is better - - // total_selected counts everyone clearing threshold, not just highlighted teams; - // Teams dims players rather than removing them (see DIM_OPACITY above), - // 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 = highlightSubtitle(cat, currentFiltered, isDimmed, selectedTeams, selectedPlayerKeys, minThreshold); - - const layout = { - paper_bgcolor: "transparent", - plot_bgcolor: "transparent", - font: { family: "Inter, sans-serif", color: "#f1ecdd" }, - // Extra headroom above the plot area (beyond what the title/subtitle - // text itself needs) so the metric-definition note box — pinned to the - // plot's own y:1 top edge, not the title block — doesn't sit flush - // against the subtitle. - margin: { l: 60, r: 24, t: isMobile ? 96 : 88, b: 56 }, - title: { - text: titleText, - font: { family: "Anton, Arial Narrow, sans-serif", size: isMobile ? 16 : 22, color: "#f1ecdd" }, - x: 0.5, - xanchor: "center", - subtitle: { - text: subtitleText, - font: { family: "IBM Plex Mono, monospace", size: isMobile ? 9 : 12, color: "#f1ecdd" }, - }, - }, - dragmode: false, - xaxis: { - // Plotly 3.x requires title as {text: ...} — a bare string is - // silently ignored (renders as an empty ). - title: { text: axisTitle(xKey, xMeta) }, - gridcolor: "rgba(241,236,221,0.08)", - zerolinecolor: "rgba(241,236,221,0.15)", - autorange: reversed ? "reversed" : true, - }, - yaxis: { - title: { text: axisTitle(yKey, yMeta) }, - gridcolor: "rgba(241,236,221,0.08)", - zerolinecolor: "rgba(241,236,221,0.15)", - autorange: reversed ? "reversed" : true, - }, - shapes, - annotations, - hoverlabel: { - bgcolor: "#1e3d28", - bordercolor: "#2a4d33", - font: { family: "IBM Plex Mono, monospace", size: 12, color: "#f1ecdd" }, - }, - }; - - function applyLogoImages() { - if (!showLogos) return; - const images = computeLogoImages(els.chart, currentFiltered, xKey, yKey, isDimmed); - if (!images.length) return; - logoRelayoutGuard = true; - Plotly.relayout(els.chart, { images }).then(() => { - logoRelayoutGuard = false; - }); - } - - // Only declutters when logos are on — with plain colored dots the labels - // sit right above a small marker and collide far less. - function applyLabelDeclutter() { - if (!showLabels || !showLogos) return; - const kept = computeKeptLabels(els.chart, currentFiltered, xKey, yKey, cat.threshold_field, isDimmed); - const text = currentFiltered.map((r, i) => (kept[i] ? (r.abbr_name || r.player) : "")); - Plotly.restyle(els.chart, { text: [text] }, [0]); - } - - // No `images` key here on purpose — Plotly.react fully replaces - // layout, so leaving it out clears any logos from a previous render when - // toggle is off. Sizing needs post-draw axis range, so logos are - // added in a follow-up relayout once this render settles. - Plotly.react(els.chart, [trace], layout, { - displayModeBar: false, - responsive: true, - scrollZoom: false, - doubleClick: false, - }) - .then(() => { - applyLogoImages(); - applyLabelDeclutter(); - }); - - // Clear stale listeners each render — Plotly.react reuses the same graph - // div, and every call otherwise adds another copy of the click handler. - ["plotly_click", "plotly_relayout"].forEach((evt) => - els.chart.removeAllListeners?.(evt) - ); - - // Zoom/pan/resize change the axis range, so sizex/sizey (data units) need - // recomputing to keep logos a constant on-screen size, and label overlaps - // need re-evaluating since pixel spacing between points also changed. - // Guard against our own relayout call re-triggering this handler. - els.chart.on("plotly_relayout", () => { - if (logoRelayoutGuard) return; - applyLogoImages(); - applyLabelDeclutter(); - }); - - els.chart.on("plotly_click", (e) => { - const idx = e.points[0].pointIndex; - viewFloatingCard(currentFiltered[idx]); - }); -} - -// 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; -} - -// --- Card identity (Player / Merge / Linemate) ------------------------------ -// -// Every open card gets a stable {id, origin} pair per BLUEPRINT.md §5, so a -// future recommender (v2.0.0) can read merge provenance without a card's -// identity depending on its current member set. origin is 'seed' for a -// Player Card the user opened directly off the chart, 'merge' for one built -// via the Merge button, 'linemate' for a Linemate Association Card. -let cardSeq = 0; -function nextCardId() { - cardSeq += 1; - return `card-${cardSeq}`; -} - -// Pending membership for the Create-merge popup (BLUEPRINT_PinnedPlayers.md -// v1.2.0 §5) — record.player → record, in pick order. Staged locally and -// discarded on Cancel; only becomes a real Merge Card on Submit -// (submitCreateMergePopup()). Unlike the Edit popup's live-commit model, so a -// half-built card never briefly exists as a real, addressable Merge Card. -const createSelection = new Map(); - -// Open Merge Cards, keyed by their own generated id (never by player — a -// Merge Card has several members). Entry: { id, origin:'merge', -// memberKeys:[player,...], el, folded }. -const mergeCards = new Map(); - -// Open Linemate Association Cards, keyed by the anchor's player string — one -// per anchor regardless of whether the toggle that opened it lives on a -// Player Card or a Merge Card member row. This is what makes BLUEPRINT.md -// §2.3's recursion guard trivial: a Linemate Card never renders a linemate -// toggle of its own, so there's no second layer of anchors to key around. -// Entry: { id, origin:'linemate', anchorKey, anchorRecord, roster, el, -// folded, seeMore }. anchorRecord/roster are read and overwritten by -// renderLinemateCardBody() on every open/refresh — see refreshOpenLinemateCards(). -const linemateCards = new Map(); - -const MERGE_CARD_MAX_MEMBERS = 5; -const MERGE_QUOTA = 8; -// Position-group pulled into a Linemate Card's roster and the per-category -// cap on how many can be shown — both keyed by category, not position, -// since a Linemate Card spans every position within its anchor's category -// (BLUEPRINT.md §2.2). -const LINEMATE_POSITIONS = { pass_block: ["T", "G", "C"], pass_rush: ["ED", "DI"] }; -const LINEMATE_CAP = { pass_block: 5, pass_rush: 7 }; -const LINEMATE_VISIBLE_DEFAULT = 5; - -// The 8-player quota's universe (BLUEPRINT_PinnedPlayers.md §6): every -// distinct player in Single Cards OR any Merged Card, counted once -// regardless of how many places he appears — a player merged into three -// cards, or merged AND still a Single Cards row, still costs exactly 1 slot. -function distinctWorkspacePlayers() { - const keys = new Set(workspaceSingles.keys()); - mergeCards.forEach((c) => c.memberKeys.forEach((k) => keys.add(k))); - return keys; -} - -// A merged member can outlive his Single Cards row (Remove only touches -// Single Cards, never merged instances — §4/§6), so his full record has to -// come from the applied slice itself rather than from workspaceSingles. -function findRecordByPlayer(key) { - return currentRecords.find((r) => r.player === key) || null; -} - -// BLUEPRINT_PinnedPlayers.md v1.2.0 §5/CHANGE 5 — order-independent -// membership hash for duplicate-card detection, keyed by player `id` (the DB -// row id, not the display name, per the spec) rather than record.player: -// two cards with the same player SET, picked in any order, must hash -// identically. -function memberIdsKey(memberKeys) { - const ids = memberKeys - .map((key) => findRecordByPlayer(key)) - .filter(Boolean) - .map((record) => record.id) - .sort((a, b) => a - b); - return ids.join(","); -} - -// Finds an existing Merge Card whose membership hash matches `memberKeys`, -// other than `excludeId` (a card being edited must not compare against its -// own unchanged membership) — used by both the Create and Edit popups to -// block a membership that would produce two identical cards. -function findDuplicateMergeCard(memberKeys, excludeId) { - const key = memberIdsKey(memberKeys); - if (!key) return null; - for (const [id, entry] of mergeCards) { - if (id === excludeId) continue; - if (memberIdsKey(entry.memberKeys) === key) return entry; - } - return null; -} - -// Merge Card entries can outlive their floating view (closeMergeCardFloating() -// leaves the row in mergeCards with entry.el === null), so counting mergeCards.size -// directly would overcount what's actually on screen — only count its open entries. -function openCardsCount() { - let openMergeCount = 0; - mergeCards.forEach((entry) => { - if (entry.el) openMergeCount += 1; - }); - return scoutCards.size + openMergeCount + linemateCards.size; -} - -function updateScoutEmptyHint() { - els.scoutEmptyHint.hidden = openCardsCount() > 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 (Player, Merge, or Linemate — they all share -// this one cascade sequence), so opening several in a row fans them out -// instead of stacking them exactly on top of each other. They're still -// fully draggable afterward, and dragging one on top of another is exactly -// the overlap the user asked to allow. -const SCOUT_CASCADE_STEP = 28; -const SCOUT_CASCADE_WRAP = 8; // wrap the offset so a long run of opens can't drift off-panel -function cascadeScoutCardPosition(cardEl) { - if (openCardsCount() === 0 || !isDesktopScoutLayout()) return; - const panelRect = els.chartPanel.getBoundingClientRect(); - const cardRect = cardEl.getBoundingClientRect(); - const offset = (openCardsCount() % SCOUT_CASCADE_WRAP) * SCOUT_CASCADE_STEP; - const maxLeft = Math.max(panelRect.width - cardRect.width, 0); - const maxTop = Math.max(panelRect.height - cardRect.height, 0); - cardEl.style.left = `${Math.min(Math.max(panelRect.width - cardRect.width - 24 - offset, 0), maxLeft)}px`; - cardEl.style.top = `${Math.min(24 + offset, maxTop)}px`; - cardEl.style.right = "auto"; -} - -// Applies fold/unfold visuals for whatever state.folded currently holds — -// shared by the explicit fold-button click (toggleCardFold) and the -// resize-driven auto fold/unfold below (setCardFolded), so both paths stay -// in sync on the same CSS class, icon, and stashed-height behavior. Folding -// hides the metric body via CSS (.is-folded); a card the user has edge- -// resized taller would otherwise leave a tall dead gap under the header once -// that body disappears, so its inline panel height is stashed here and -// restored on unfold rather than lost. `onUnfold` lets a Linemate Card reset -// its "See more" state back to collapsed every time it re-expands (§2.5). -function applyCardFoldVisual(cardEl, state, onUnfold) { - cardEl.classList.toggle("is-folded", state.folded); - const foldBtn = cardEl.querySelector(".scout-fold"); - const icon = foldBtn && foldBtn.querySelector("i"); - if (icon) icon.className = state.folded ? "fa-solid fa-chevron-down" : "fa-solid fa-chevron-up"; - if (foldBtn) foldBtn.setAttribute("aria-label", state.folded ? "Unfold card" : "Fold card"); - - const panelEl = cardEl.querySelector(".scout-card-panel"); - if (panelEl) { - if (state.folded) { - if (panelEl.style.height) panelEl.dataset.resizedHeight = panelEl.style.height; - panelEl.style.height = ""; - panelEl.style.maxHeight = ""; - } else if (panelEl.dataset.resizedHeight) { - panelEl.style.height = panelEl.dataset.resizedHeight; - panelEl.style.maxHeight = "none"; - } - } - - if (!state.folded && onUnfold) onUnfold(); -} - -// Wired to every card type's fold button — flips state.folded and applies it. -function toggleCardFold(cardEl, state, onUnfold) { - state.folded = !state.folded; - applyCardFoldVisual(cardEl, state, onUnfold); -} - -// Wired to the resize handles (see beginScoutResize/endScoutResize below) so -// a pull/push can drive fold state directly instead of only the button — -// dragging a folded card's edge auto-unfolds it (there's nothing to reveal -// while its body is display:none), and pushing an unfolded card's edge back -// down to the resize floor auto-folds it. No-ops if already in that state. -function setCardFolded(cardEl, state, folded, onUnfold) { - if (state.folded === folded) return; - state.folded = folded; - applyCardFoldVisual(cardEl, state, onUnfold); -} - -// Builds/rebuilds a Player Card's stat rows (value + rank/percentile) — used -// both at open time and to refresh an already-open card after Apply, since -// currentFiltered/appliedFilters.xMetric/yMetric can all change without the -// card ever closing (a threshold-only change doesn't call closeAllScoutCards -// — see applyFilters()). Rebuilding from scratch each call is simplest and -// cheap at the card counts this app ever has open. -function renderScoutCardStats(cardEl, record) { - const cat = appliedCategoryMeta(); - const statsEl = cardEl.querySelector(".scout-stats"); - statsEl.innerHTML = ""; - - const xKey = appliedFilters.xMetric; - const yKey = appliedFilters.yMetric; - - // 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. - // Games / the threshold field get an empty rank cell for exactly this - // reason, not because rank text was omitted by accident. - const addRow = (label, value, highlighted, rankText) => { - const dt = document.createElement("dt"); - dt.textContent = label; - const dd = document.createElement("dd"); - dd.className = "scout-stat-value"; - dd.textContent = value; - if (highlighted) dd.classList.add("is-highlighted"); - const rankDd = document.createElement("dd"); - rankDd.className = "scout-stat-rank"; - rankDd.textContent = rankText || "—"; - statsEl.appendChild(dt); - statsEl.appendChild(dd); - statsEl.appendChild(rankDd); - }; - - // Games and the threshold field (PR Opp / Non Spike PB Snaps) are volume - // stats, not rate metrics — rank/percentile against them wouldn't mean - // "how well this player performed," so only the metrics loop below gets a rank. - addRow("Games", record.games); - addRow(cat.threshold_field, record[cat.threshold_field]); - Object.entries(cat.metrics).forEach(([key, meta]) => { - const rank = rankAndPercentile(currentFiltered, key, meta.higher_is_better, record[key]); - const rankText = rank ? `#${rank.rank}/${rank.n} · ${ordinal(rank.percentile)} pct` : ""; - addRow(key, formatValue(record[key], meta), key === xKey || key === yKey, rankText); - }); -} - -// Re-renders every open Player Card's stats against the current -// currentFiltered/appliedFilters — called after every render() in -// applyFilters() so a threshold change (which doesn't close Player Cards, -// only a season/category/position change does) can't leave a card showing -// ranks computed against a pool that no longer exists. Safe to call -// unconditionally: if a slice change closed every card first, this is just -// an empty loop. -function refreshOpenScoutCards() { - scoutCards.forEach((entry) => renderScoutCardStats(entry.el, entry.record)); -} - -function openScoutCard(record) { - const cardEl = els.scoutCardTemplate.content.firstElementChild.cloneNode(true); - - const closeBtn = cardEl.querySelector(".scout-close"); - const foldBtn = cardEl.querySelector(".scout-fold"); - const dragHandle = cardEl.querySelector(".scout-drag-handle"); - const logoImg = cardEl.querySelector(".scout-logo"); - const badge = cardEl.querySelector(".scout-badge"); - const nameEl = cardEl.querySelector(".scout-name"); - const metaEl = cardEl.querySelector(".scout-meta"); - const linemateBtn = cardEl.querySelector(".card-linemate-toggle"); - - const color = teamColor(record.team); - 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; - }; - - nameEl.textContent = record.player; - metaEl.textContent = `${teamName(record.team)} · ${record.position}`; - - renderScoutCardStats(cardEl, record); - - els.scoutCards.appendChild(cardEl); - cascadeScoutCardPosition(cardEl); // reads openCardsCount(), so must run before scoutCards.set() below - cardEl.classList.add("is-active"); - bringScoutCardToFront(cardEl); - - const entry = { record, el: cardEl, id: nextCardId(), origin: "seed", folded: false }; - attachScoutResize(cardEl, entry); - - closeBtn.addEventListener("click", () => closeScoutCard(record.player)); - foldBtn.addEventListener("click", () => toggleCardFold(cardEl, entry)); - dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); - dragHandle.addEventListener("pointermove", onScoutDragMove); - dragHandle.addEventListener("pointerup", endScoutDrag); - dragHandle.addEventListener("pointercancel", endScoutDrag); - // Raises a card even on a plain click, not just a drag, so tapping an - // overlapped card's stats brings it to front too. - cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); - linemateBtn.addEventListener("click", () => toggleLinemateCard(record)); - - scoutCards.set(record.player, entry); - updateScoutEmptyHint(); -} - -// The floating card's own × — per BLUEPRINT_PinnedPlayers.md §3, this -// ONLY closes the floating card. It does NOT touch workspaceSingles or the -// Pinned Players in any way — a player's Single Cards row survives -// regardless, exactly mirroring how his own Linemate Card, if open, is also -// left alone: its roster/summary were computed once at open time and never -// read back from scoutCards, so it has nothing left to depend on and can -// keep floating on screen after its anchor Player Card is gone. -function closeScoutCard(key) { - const entry = scoutCards.get(key); - if (!entry) return; - entry.el.remove(); - scoutCards.delete(key); - updateScoutEmptyHint(); -} - -// Bulk-closes every floating Player Card — used by clearWorkspace() (a real -// Workspace-clearing operation, unlike the individual × above). -function closeAllScoutCards() { - scoutCards.forEach((entry) => entry.el.remove()); - scoutCards.clear(); - updateScoutEmptyHint(); -} - -// Dissolves every Merge Card — the pool invalidation from BLUEPRINT.md §4 -// (season/category/position/threshold changes). Called from applyFilters() -// once any confirm prompt it needed has already resolved. Merge Cards still -// dissolve on every pool change, threshold included: refreshing up to 5 -// players' percentiles in place, possibly spanning multiple positions, is -// more surface area than the confirm-and-rebuild UX already buys the user. -// Linemate Cards don't share this — see refreshOpenLinemateCards() in -// applyFilters(). -function clearMergeCards() { - mergeCards.forEach((entry) => entry.el && entry.el.remove()); - mergeCards.clear(); - updateScoutEmptyHint(); - renderPlayerCardsSpace(); -} - -// Closes every Linemate Card — only for a season/category/position change -// (BLUEPRINT.md §4). A threshold-only change no longer dissolves them; see -// refreshOpenLinemateCards(), called from applyFilters() instead. -function clearLinemateCards() { - linemateCards.forEach((entry) => entry.el.remove()); - linemateCards.clear(); - updateScoutEmptyHint(); -} - -// Inline left/top/width/height (set by dragging, edge-resizing, or -// cascadeScoutCardPosition) sit at higher specificity than the mobile media -// query's `top: auto; right: auto; width: auto;` reset, so they'd otherwise -// survive a resize down to mobile and break the stacked layout. Clearing -// them lets the stylesheet's position/size rules take back over for every -// currently-open card of every type. -function clearScoutCardDragPositions() { - [...scoutCards.values(), ...mergeCards.values(), ...linemateCards.values()].forEach((entry) => { - if (!entry.el) return; // a Merge Card row can survive its floating view being closed, see closeMergeCardFloating() - entry.el.style.left = ""; - entry.el.style.top = ""; - entry.el.style.right = ""; - entry.el.style.width = ""; - const panelEl = entry.el.querySelector(".scout-card-panel"); - if (panelEl) { - panelEl.style.height = ""; - panelEl.style.maxHeight = ""; - delete panelEl.dataset.resizedHeight; - } - }); -} - -// 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, cardEl) { - if (!isDesktopScoutLayout()) return; - const panelRect = els.chartPanel.getBoundingClientRect(); - const cardRect = cardEl.getBoundingClientRect(); - scoutDragState = { - cardEl, - pointerId: e.pointerId, - startX: e.clientX, - startY: e.clientY, - startLeft: cardRect.left - panelRect.left, - startTop: cardRect.top - panelRect.top, - // Clamp targets, computed once at drag start rather than every move — - // the panel doesn't resize mid-drag. - maxLeft: Math.max(panelRect.width - cardRect.width, 0), - maxTop: Math.max(panelRect.height - cardRect.height, 0), - }; - // 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. - 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) { - if (!scoutDragState || e.pointerId !== scoutDragState.pointerId) return; - const dx = e.clientX - scoutDragState.startX; - 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); - scoutDragState.cardEl.style.left = `${left}px`; - scoutDragState.cardEl.style.top = `${top}px`; -} - -function endScoutDrag(e) { - if (!scoutDragState || e.pointerId !== scoutDragState.pointerId) return; - e.currentTarget.releasePointerCapture(e.pointerId); - scoutDragState.cardEl.classList.remove("is-dragging"); - scoutDragState = null; -} - -// Edge/corner resize — pulled to expand, pushed to shrink, like a native -// window. Shared by every card type via .scout-card/.scout-card-panel, same -// as the drag/fold/cascade systems above. Width lives on the card itself; -// height lives on .scout-card-panel (the element that actually owns the -// visible box + the open/close max-height transition), so growing/shrinking -// vertically overrides that transition's cap directly instead of fighting it. -const SCOUT_RESIZE_DIRS = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; -const SCOUT_RESIZE_MIN_HEIGHT = 160; - -// `state`/`onUnfold` are the same fold-state object and callback the card's -// fold button was wired with (see toggleCardFold) — passed through so a -// pull/push on the resize handles can drive fold state too, see -// beginScoutResize/endScoutResize below. -function attachScoutResize(cardEl, state, onUnfold) { - SCOUT_RESIZE_DIRS.forEach((dir) => { - const handle = document.createElement("div"); - handle.className = `scout-resize-handle scout-resize-${dir}`; - handle.addEventListener("pointerdown", (e) => beginScoutResize(e, cardEl, dir, state, onUnfold)); - handle.addEventListener("pointermove", onScoutResizeMove); - handle.addEventListener("pointerup", endScoutResize); - handle.addEventListener("pointercancel", endScoutResize); - cardEl.appendChild(handle); - }); -} - -// Resize state for the one pointer currently resizing a card, or null — same -// single-gesture-at-a-time shape as scoutDragState above. -let scoutResizeState = null; - -function beginScoutResize(e, cardEl, dir, state, onUnfold) { - if (!isDesktopScoutLayout()) return; - e.stopPropagation(); - e.preventDefault(); - // A folded card's body is display:none (see .is-folded in style.css), so - // dragging its height open wouldn't reveal anything without also - // unfolding it first — do that before measuring rects below, so the - // gesture's start height/width reflect the now-unfolded layout instead of - // the collapsed header-only one. - if ((dir.includes("n") || dir.includes("s")) && state && state.folded) { - setCardFolded(cardEl, state, false, onUnfold); - } - const panelEl = cardEl.querySelector(".scout-card-panel"); - const panelRect = els.chartPanel.getBoundingClientRect(); - const cardRect = cardEl.getBoundingClientRect(); - const computed = getComputedStyle(cardEl); - const left = cardRect.left - panelRect.left; - const top = cardRect.top - panelRect.top; - scoutResizeState = { - cardEl, - panelEl, - dir, - state, - onUnfold, - pointerId: e.pointerId, - startX: e.clientX, - startY: e.clientY, - startWidth: cardRect.width, - startHeight: panelEl.getBoundingClientRect().height, - startLeft: left, - startTop: top, - // Clamp targets computed once at resize start, not every move — the - // surrounding chart panel doesn't itself resize mid-gesture. - minWidth: parseFloat(computed.minWidth) || 260, - maxWidth: parseFloat(computed.maxWidth) || 640, - panelWidth: panelRect.width, - panelHeight: panelRect.height, - }; - // Switch from the default top/right anchor to an explicit left/top, same - // as beginScoutDrag — growing from the north/west edge needs a fixed point - // to grow from. - cardEl.style.left = `${left}px`; - cardEl.style.top = `${top}px`; - cardEl.style.right = "auto"; - cardEl.classList.add("is-resizing"); - bringScoutCardToFront(cardEl); - e.currentTarget.setPointerCapture(e.pointerId); -} - -function onScoutResizeMove(e) { - const s = scoutResizeState; - if (!s || e.pointerId !== s.pointerId) return; - const dx = e.clientX - s.startX; - const dy = e.clientY - s.startY; - const dir = s.dir; - - if (dir.includes("e")) { - const rawWidth = Math.min(Math.max(s.startWidth + dx, s.minWidth), s.maxWidth); - const width = Math.min(rawWidth, s.panelWidth - s.startLeft); - s.cardEl.style.width = `${width}px`; - } else if (dir.includes("w")) { - const rawWidth = Math.min(Math.max(s.startWidth - dx, s.minWidth), s.maxWidth); - const left = Math.max(s.startLeft + (s.startWidth - rawWidth), 0); - const width = s.startLeft + s.startWidth - left; - s.cardEl.style.width = `${width}px`; - s.cardEl.style.left = `${left}px`; - } - - if (dir.includes("s")) { - const rawHeight = Math.max(s.startHeight + dy, SCOUT_RESIZE_MIN_HEIGHT); - const height = Math.min(rawHeight, s.panelHeight - s.startTop); - s.panelEl.style.maxHeight = "none"; - s.panelEl.style.height = `${height}px`; - } else if (dir.includes("n")) { - const rawHeight = Math.max(s.startHeight - dy, SCOUT_RESIZE_MIN_HEIGHT); - const top = Math.max(s.startTop + (s.startHeight - rawHeight), 0); - const height = s.startTop + s.startHeight - top; - s.panelEl.style.maxHeight = "none"; - s.panelEl.style.height = `${height}px`; - s.cardEl.style.top = `${top}px`; - } -} - -function endScoutResize(e) { - const s = scoutResizeState; - if (!s || e.pointerId !== s.pointerId) return; - e.currentTarget.releasePointerCapture(e.pointerId); - s.cardEl.classList.remove("is-resizing"); - // Pushed all the way down to the resize floor reads as "collapse this" — - // auto-fold rather than leaving it sitting open at its smallest size, and - // flip the fold button to match. - if (s.state && (s.dir.includes("n") || s.dir.includes("s"))) { - const currentHeight = parseFloat(s.panelEl.style.height); - if (currentHeight <= SCOUT_RESIZE_MIN_HEIGHT) { - setCardFolded(s.cardEl, s.state, true, s.onUnfold); - } - } - scoutResizeState = null; -} - -// --- Pinned Players: Workspace admission/removal ------------------------ -// (BLUEPRINT_PinnedPlayers.md §3/§4) - -// Pops up a reminder for a quota/cap refusal (Workspace full, Merge Card -// 5-member cap, Merge 8-player limit) — reused everywhere one of those -// decisions needs to explain itself. Was an easy-to-miss inline message in -// Pinned Players' compact bar; a popup can't go unnoticed regardless of which -// control triggered it, and there's no "clear the message" case to track -// anymore since it dismisses itself via its own OK button. -function showWorkspaceNotice(text) { - els.workspaceNoticeBody.textContent = text; - els.workspaceNoticeOverlay.hidden = false; - const onOk = () => { - els.workspaceNoticeOverlay.hidden = true; - els.workspaceNoticeOk.removeEventListener("click", onOk); - }; - els.workspaceNoticeOk.addEventListener("click", onOk); -} - -// Wired to the chart's plotly_click handler (v1.2.0 §4 — plot-click and -// Space-membership are now fully decoupled: this ONLY opens or flashes the -// floating card for viewing, it never touches workspaceSingles). A click on -// a player whose card is already open just flashes/refocuses it; repeat -// clicks must be safe and never toggle-close anything. -function viewFloatingCard(record) { - const key = record.player; - if (scoutCards.has(key)) { - flashFloatingCard(key); - } else { - openScoutCard(record); - } -} - -// Single Cards' only entry point now (v1.2.0 §4) — the fuzzy-search box at -// the top of the column, NOT the plot (see viewFloatingCard()) and NOT a -// per-row Merge action (removed, v1.2.0 §5's Create popup replaces it). -// Deliberately does not open the floating card — that stays the job of -// clicking the player's name in the list once he's added, an existing, -// unchanged behavior (see the row click handler in renderSinglesList()). -function addPlayerToSingleCards(record) { - const key = record.player; - if (workspaceSingles.has(key)) return; - - const distinct = distinctWorkspacePlayers(); - if (!distinct.has(key) && distinct.size >= MERGE_QUOTA) { - showWorkspaceNotice( - `Workspace full (${MERGE_QUOTA}/${MERGE_QUOTA}) — remove a player to add ${withTrailingPeriod(record.abbr_name || key)}` - ); - return; - } - - workspaceSingles.set(key, record); - renderPlayerCardsSpace(); -} - -// Single Cards "Remove" (§4) — exits the Workspace entirely: drops the row -// and closes the floating card if it's open (decision 3). Does NOT touch any -// Merge Card the player still belongs to (§4/§6) — his stats there keep -// coming from findRecordByPlayer(). -function removeSingleCard(key) { - if (!workspaceSingles.has(key)) return; - workspaceSingles.delete(key); - closeScoutCard(key); - renderPlayerCardsSpace(); -} - -// --- Single Cards add-search (v1.2.0 §4) ------------------------------------ -// Pinned Players' only entry point now. Same fuzzy-match style/behavior as the -// Players filter (mirrors runPlayersSearch()/renderPlayersDropdown()), just -// against pcsSearchPool() and excluding players already on the Workspace. - -function hidePcsAddDropdown() { - els.pcsAddDropdown.hidden = true; - els.pcsAddDropdown.innerHTML = ""; -} - -function renderPcsAddDropdown(matches) { - els.pcsAddDropdown.innerHTML = ""; - if (!matches.length) { - hidePcsAddDropdown(); - return; - } - - matches.forEach((record) => { - const opt = document.createElement("button"); - opt.type = "button"; - opt.className = "player-option"; - - 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", () => { - addPlayerToSingleCards(record); - els.pcsAddInput.value = ""; - els.pcsAddInput.focus(); - runPcsAddSearch(); - }); - - els.pcsAddDropdown.appendChild(opt); - }); - - els.pcsAddDropdown.hidden = false; -} - -function runPcsAddSearch() { - const query = els.pcsAddInput.value.trim(); - if (!query) { - hidePcsAddDropdown(); - return; - } - renderPcsAddDropdown(searchPlayersExcluding(query, pcsSearchPool(), new Set(workspaceSingles.keys()))); -} - -function flashFloatingCard(key) { - const entry = scoutCards.get(key); - if (!entry) return; - bringScoutCardToFront(entry.el); - entry.el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); - entry.el.classList.remove("is-flash"); - void entry.el.offsetWidth; - entry.el.classList.add("is-flash"); - entry.el.addEventListener("animationend", () => entry.el.classList.remove("is-flash"), { once: true }); -} - -// --- Player Merge Cards ------------------------------------------------------ -// (BLUEPRINT.md §1, BLUEPRINT_PinnedPlayers.md §5/§6) - -// --- Create Merged Card popup (v1.2.0 §5) ----------------------------------- -// Replaces the old per-row Merge-button + header-Merge-button flow entirely: -// the Merged Cards column's "Create" button is now the only way to start a -// new merged card. Membership is staged in createSelection (module state, -// declared above) until Submit — Cancel discards it untouched, so a -// half-built card never briefly exists as a real, addressable Merge Card. - -function setCreateMessage(text) { - els.mergeCreateMessage.textContent = text || ""; -} - -function hideCreateDropdown() { - els.mergeCreateDropdown.hidden = true; - els.mergeCreateDropdown.innerHTML = ""; -} - -function openCreateMergePopup() { - createSelection.clear(); - renderCreateMembers(); - els.mergeCreateInput.value = ""; - hideCreateDropdown(); - setCreateMessage(""); - els.mergeCreateOverlay.hidden = false; - els.mergeCreateInput.focus(); -} - -// Cancel — discards the staged selection outright, no card created. -function closeCreateMergePopup() { - createSelection.clear(); - els.mergeCreateOverlay.hidden = true; - hideCreateDropdown(); -} - -function renderCreateMembers() { - els.mergeCreateMembers.innerHTML = ""; - createSelection.forEach((record, key) => { - const label = record.abbr_name || record.player; - - const row = document.createElement("div"); - row.className = "merge-edit-member"; - - const name = document.createElement("span"); - name.className = "merge-edit-member-name"; - name.textContent = label; - row.appendChild(name); - - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "merge-edit-member-remove"; - removeBtn.setAttribute("aria-label", `Remove ${label}`); - removeBtn.textContent = "×"; - removeBtn.addEventListener("click", () => removeCreateMember(key)); - row.appendChild(removeBtn); - - els.mergeCreateMembers.appendChild(row); - }); -} - -// Mirrors renderMergeEditDropdown()'s markup/classes exactly (.player-option -// etc.) — same fuzzy-match input style as the Players filter (v1.2.0 §5). -function renderCreateDropdown(matches) { - els.mergeCreateDropdown.innerHTML = ""; - if (!matches.length) { - hideCreateDropdown(); - return; - } - - matches.forEach((record) => { - const opt = document.createElement("button"); - opt.type = "button"; - opt.className = "player-option"; - - 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", () => addCreateMember(record)); - - els.mergeCreateDropdown.appendChild(opt); - }); - - els.mergeCreateDropdown.hidden = false; -} - -function runCreateSearch() { - const query = els.mergeCreateInput.value.trim(); - if (!query) { - hideCreateDropdown(); - return; - } - // No exclusion set — an already-picked player is caught (and explained) in - // addCreateMember() instead, same "blocked, with a prompt" convention the - // Edit popup already uses. Pool is position-scoped only (v1.2.0 §4). - renderCreateDropdown(searchPlayersExcluding(query, pcsSearchPool(), new Set())); -} - -// 5-per-card cap enforced at pick time (v1.2.0 §5) — deliberately does NOT -// check the 8-player Workspace quota here; that's a Submit-time-only check -// against the DISTINCT total (submitCreateMergePopup()), not the raw number -// picked in the popup, per the spec. -function addCreateMember(record) { - const key = record.player; - if (createSelection.has(key)) { - setCreateMessage(`${record.abbr_name || key} is already selected — pick someone else.`); - return; - } - if (createSelection.size >= MERGE_CARD_MAX_MEMBERS) { - setCreateMessage(`Merge Cards hold at most ${MERGE_CARD_MAX_MEMBERS} players — remove one first.`); - return; - } - createSelection.set(key, record); - renderCreateMembers(); - setCreateMessage(""); - els.mergeCreateInput.value = ""; - runCreateSearch(); -} - -function removeCreateMember(key) { - createSelection.delete(key); - renderCreateMembers(); -} - -// Submit: the one point membership is checked against the duplicate-card -// hash and the DISTINCT-player quota (v1.2.0 §5/CHANGE 5) — a player already -// in the Workspace elsewhere costs nothing toward the 8. Mirrors -// addMergeMember()'s "surface new members as Single Cards rows too" habit, -// and — like the old performMerge() — does not open the members' floating -// cards; their Single Cards rows are enough. -function submitCreateMergePopup() { - if (createSelection.size < 2) { - setCreateMessage("Select at least 2 players for a merged card."); - return; - } - - const memberKeys = Array.from(createSelection.keys()); - if (findDuplicateMergeCard(memberKeys, null)) { - setCreateMessage("This merged card already exists."); - return; - } - - const distinct = distinctWorkspacePlayers(); - memberKeys.forEach((key) => distinct.add(key)); - if (distinct.size > MERGE_QUOTA) { - setCreateMessage( - `Workspace full (${MERGE_QUOTA}/${MERGE_QUOTA}) — this card would add too many new players. Remove someone first.` - ); - return; - } - - const memberRecords = Array.from(createSelection.values()); - memberRecords.forEach((record) => { - if (!workspaceSingles.has(record.player)) workspaceSingles.set(record.player, record); - }); - - createSelection.clear(); - els.mergeCreateOverlay.hidden = true; - hideCreateDropdown(); - openMergeCard(memberRecords); -} - -// A single holding this row's linemate-toggle — shared by every row in -// a Merge Card's table (BLUEPRINT.md §2.1: one linemate toggle per merged -// member, no team-level dedupe even when two members share a team). -function makeLinemateCell(record) { - const td = document.createElement("td"); - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "merge-row-linemate-toggle"; - btn.innerHTML = ''; - btn.setAttribute("aria-label", `Linemates for ${record.player}`); - btn.addEventListener("click", () => toggleLinemateCard(record)); - td.appendChild(btn); - return td; -} - -// One row per player, one column per metric, percentile-only cells -// (BLUEPRINT.md §1.2) — never the raw value, never #rank/N. -// Builds/rebuilds a Merge Card's title, subtitle, and percentile table from -// memberRecords — used both at creation (openMergeCard) and by the Edit -// popup (rebuildMergeCardFromMembers, BLUEPRINT_PinnedPlayers.md §5) to -// update an existing card in place after its membership changes, instead of -// destroying/recreating the floating card. One row per player, one column -// per metric, percentile-only cells (BLUEPRINT.md §1.2) — never the raw -// value, never #rank/N. -function renderMergeCardBody(cardEl, memberRecords) { - const cat = appliedCategoryMeta(); - const titleEl = cardEl.querySelector(".merge-card-title"); - const subtitleEl = cardEl.querySelector(".merge-card-subtitle"); - const poolEl = cardEl.querySelector(".merge-card-pool"); - const table = cardEl.querySelector(".merge-table"); - - titleEl.textContent = `Merge Card · ${memberRecords.length} players`; - subtitleEl.textContent = memberRecords.map((r) => r.abbr_name || r.player).join(" + "); - - const pools = memberRecords.map((record) => ({ record, pool: positionPool(record.position) })); - const sharedPosition = pools.every((p) => p.record.position === pools[0].record.position) - ? pools[0].record.position - : null; - poolEl.textContent = sharedPosition - ? `Percentiles calculated among ${pools[0].pool.length} ${sharedPosition}.` - : `Percentiles calculated among ${pools - .map((p) => `${p.pool.length} ${p.record.position} (${p.record.abbr_name || p.record.player})`) - .join(", ")}.`; - - const metricKeys = Object.keys(cat.metrics); - const thead = document.createElement("thead"); - const headRow = document.createElement("tr"); - ["Player", "Linemates", "Games", cat.threshold_field, ...metricKeys].forEach((label) => { - const th = document.createElement("th"); - th.textContent = label; - headRow.appendChild(th); - }); - thead.appendChild(headRow); - - const tbody = document.createElement("tbody"); - pools.forEach(({ record, pool }) => { - const tr = document.createElement("tr"); - - const nameTd = document.createElement("td"); - nameTd.className = "merge-table-name"; - nameTd.textContent = record.abbr_name || record.player; - tr.appendChild(nameTd); - - tr.appendChild(makeLinemateCell(record)); - - const gamesTd = document.createElement("td"); - gamesTd.textContent = record.games ?? "—"; - tr.appendChild(gamesTd); - - const snapsTd = document.createElement("td"); - snapsTd.textContent = record[cat.threshold_field] ?? "—"; - tr.appendChild(snapsTd); - - metricKeys.forEach((key) => { - const meta = cat.metrics[key]; - const rank = rankAndPercentile(pool, key, meta.higher_is_better, record[key]); - const td = document.createElement("td"); - td.textContent = rank ? ordinal(rank.percentile) : "—"; - tr.appendChild(td); - }); - - tbody.appendChild(tr); - }); - - table.innerHTML = ""; - table.appendChild(thead); - table.appendChild(tbody); -} - -// Builds/mounts a Merge Card's floating DOM node onto an existing entry — -// shared by openMergeCard() (fresh entry) and reopenMergeCard() (an entry -// whose row survived a previous floating-card close, see -// closeMergeCardFloating() below). Mirrors openScoutCard()'s own DOM-build -// step; a reopen always starts from a clean node, same as a Player Card's -// own close-then-reopen, so prior resize/position/fold state doesn't carry -// over. -function mountMergeCardElement(entry, memberRecords) { - const cardEl = els.mergeCardTemplate.content.firstElementChild.cloneNode(true); - - const closeBtn = cardEl.querySelector(".scout-close"); - const foldBtn = cardEl.querySelector(".scout-fold"); - const dragHandle = cardEl.querySelector(".scout-drag-handle"); - - renderMergeCardBody(cardEl, memberRecords); - - els.scoutCards.appendChild(cardEl); - cascadeScoutCardPosition(cardEl); // reads openCardsCount(), so must run before entry.el is set below - cardEl.classList.add("is-active"); - bringScoutCardToFront(cardEl); - - entry.el = cardEl; - entry.folded = false; - attachScoutResize(cardEl, entry); - - closeBtn.addEventListener("click", () => closeMergeCardFloating(entry.id)); - foldBtn.addEventListener("click", () => toggleCardFold(cardEl, entry)); - dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); - dragHandle.addEventListener("pointermove", onScoutDragMove); - dragHandle.addEventListener("pointerup", endScoutDrag); - dragHandle.addEventListener("pointercancel", endScoutDrag); - cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); - - updateScoutEmptyHint(); -} - -function openMergeCard(memberRecords) { - const id = nextCardId(); - const memberKeys = memberRecords.map((r) => r.player); - const entry = { id, origin: "merge", memberKeys, el: null, folded: false }; - mergeCards.set(id, entry); - mountMergeCardElement(entry, memberRecords); - renderPlayerCardsSpace(); -} - -// The floating card's own × — mirrors closeScoutCard()'s "view only" close -// (BLUEPRINT_PinnedPlayers.md §3/§4 extended to Merge Cards): the entry -// stays in mergeCards, so the Merged Cards row survives and reopenOrFocusMergeCard() -// can bring the card back. Linemate Cards spawned from this card's rows are -// left alone too, same as closeScoutCard() leaves a Player Card's. -function closeMergeCardFloating(id) { - const entry = mergeCards.get(id); - if (!entry || !entry.el) return; - entry.el.remove(); - entry.el = null; - updateScoutEmptyHint(); -} - -// Reopens a previously-closed Merge Card's floating view from its surviving -// entry — the Merged Cards row's click target, see renderMergedList(). -function reopenMergeCard(id) { - const entry = mergeCards.get(id); - if (!entry || entry.el) return; - const memberRecords = entry.memberKeys.map(findRecordByPlayer).filter(Boolean); - mountMergeCardElement(entry, memberRecords); -} - -function flashMergeCard(id) { - const entry = mergeCards.get(id); - if (!entry || !entry.el) return; - bringScoutCardToFront(entry.el); - entry.el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); - entry.el.classList.remove("is-flash"); - void entry.el.offsetWidth; - entry.el.classList.add("is-flash"); - entry.el.addEventListener("animationend", () => entry.el.classList.remove("is-flash"), { once: true }); -} - -// Merged Cards row click (§4-style "admit or focus", extended to Merge -// Cards): reopens the floating card if it's closed, or brings it to front -// (flashed, scrolled into view) if it's already open somewhere off-screen. -function reopenOrFocusMergeCard(id) { - const entry = mergeCards.get(id); - if (!entry) return; - if (entry.el) { - flashMergeCard(id); - } else { - reopenMergeCard(id); - } -} - -// Dissolve: releases the merged players' quota slots and removes the card -// entirely, row and all. Pinned Players' "Dismiss" button, and the Edit popup -// auto-dissolving down to <= 1 remaining member (removeMergeMember()/ -// closeMergeEditPopup()), both call this — unlike the floating card's own × -// above, which only hides the view. -function closeMergeCard(id) { - const entry = mergeCards.get(id); - if (!entry) return; - if (entry.el) entry.el.remove(); - mergeCards.delete(id); - entry.memberKeys.forEach((key) => closeLinemateCard(key)); - updateScoutEmptyHint(); - renderPlayerCardsSpace(); -} - -// --- Merge Card membership editor (BLUEPRINT_PinnedPlayers.md §5) -------- -// Its add pool is pcsSearchPool() (v1.2.0 §4) — the current position pool -// only, same as the Create popup and the Single Cards add-search; merge -// cards no longer cross positions. - -// The one merged card currently open in the popup, or null — single static -// overlay instance (like #merge-confirm-overlay), repopulated per open. -let mergeEditCardId = null; - -function setMergeEditMessage(text) { - els.mergeEditMessage.textContent = text || ""; -} - -function hideMergeEditDropdown() { - els.mergeEditDropdown.hidden = true; - els.mergeEditDropdown.innerHTML = ""; -} - -function openMergeEditPopup(id) { - const entry = mergeCards.get(id); - if (!entry) return; - mergeEditCardId = id; - renderMergeEditMembers(entry); - els.mergeEditInput.value = ""; - hideMergeEditDropdown(); - setMergeEditMessage(""); - els.mergeEditOverlay.hidden = false; - els.mergeEditInput.focus(); -} - -// Closing the popup (Done, or Escape backing all the way out — both route -// here) is the one point membership edits are checked against the <= 1 -// auto-dissolve rule, not each individual removeMergeMember() click — so the -// user can freely remove members and see the card update live without it -// disappearing out from under them until they're actually done editing. -function closeMergeEditPopup() { - const id = mergeEditCardId; - mergeEditCardId = null; - els.mergeEditOverlay.hidden = true; - hideMergeEditDropdown(); - - if (id == null) return; - const entry = mergeCards.get(id); - if (!entry || entry.memberKeys.length > 1) return; - - const survivorKey = entry.memberKeys[0]; - closeMergeCard(id); - if (survivorKey && !workspaceSingles.has(survivorKey)) { - const survivorRecord = findRecordByPlayer(survivorKey); - if (survivorRecord) workspaceSingles.set(survivorKey, survivorRecord); - } - renderPlayerCardsSpace(); -} - -function renderMergeEditMembers(entry) { - els.mergeEditMembers.innerHTML = ""; - entry.memberKeys.forEach((key) => { - const record = findRecordByPlayer(key); - const label = (record && (record.abbr_name || record.player)) || key; - - const row = document.createElement("div"); - row.className = "merge-edit-member"; - - const name = document.createElement("span"); - name.className = "merge-edit-member-name"; - name.textContent = label; - row.appendChild(name); - - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "merge-edit-member-remove"; - removeBtn.setAttribute("aria-label", `Remove ${label}`); - removeBtn.textContent = "×"; - removeBtn.addEventListener("click", () => removeMergeMember(entry.id, key)); - row.appendChild(removeBtn); - - els.mergeEditMembers.appendChild(row); - }); -} - -// Mirrors renderPlayersDropdown()'s markup/classes exactly (.player-option -// etc.) for the "same fuzzy-match input style as the Players filter" rule. -function renderMergeEditDropdown(matches) { - els.mergeEditDropdown.innerHTML = ""; - if (!matches.length) { - hideMergeEditDropdown(); - return; - } - - matches.forEach((record) => { - const opt = document.createElement("button"); - opt.type = "button"; - opt.className = "player-option"; - - 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", () => { - if (mergeEditCardId != null) addMergeMember(mergeEditCardId, record); - }); - - els.mergeEditDropdown.appendChild(opt); - }); - - els.mergeEditDropdown.hidden = false; -} - -function runMergeEditSearch() { - const query = els.mergeEditInput.value.trim(); - if (!query) { - hideMergeEditDropdown(); - return; - } - // No exclusion set — an already-on-this-card pick is caught (and - // explained) defensively in addMergeMember() rather than hidden from the - // list, matching the checklist's "blocked, with a prompt" wording. - renderMergeEditDropdown(searchPlayersExcluding(query, pcsSearchPool(), new Set())); -} - -// Rebuilds the floating Merge Card's title/subtitle/table from its current -// memberKeys — the only place a merged member's record has to be looked up -// fresh (findRecordByPlayer), since he may no longer be in workspaceSingles -// (§4/§6: Remove doesn't touch merged instances). -function rebuildMergeCardFromMembers(entry) { - if (!entry.el) return; // floating card closed (BLUEPRINT_PinnedPlayers.md §4-style) — nothing on screen to update - const memberRecords = entry.memberKeys.map(findRecordByPlayer).filter(Boolean); - renderMergeCardBody(entry.el, memberRecords); -} - -// §5 Edit popup rules: blocks + explains a duplicate add, a 6th member, the -// same 8-player hard cap as any other new admission, or a resulting -// membership that would match another existing card's (CHANGE 5). Otherwise -// appends, surfaces the new member as a Single Cards row too if he wasn't -// already one (mirroring the invariant a normal Merge creates), and rebuilds -// the card in place. -function addMergeMember(id, record) { - const entry = mergeCards.get(id); - if (!entry) return; - const key = record.player; - - if (entry.memberKeys.includes(key)) { - setMergeEditMessage(`${record.abbr_name || key} is already on this card — pick someone else.`); - return; - } - if (entry.memberKeys.length >= MERGE_CARD_MAX_MEMBERS) { - setMergeEditMessage(`Merge Cards hold at most ${MERGE_CARD_MAX_MEMBERS} players — remove one first.`); - return; - } - const distinct = distinctWorkspacePlayers(); - if (!distinct.has(key) && distinct.size >= MERGE_QUOTA) { - setMergeEditMessage( - `Workspace full (${MERGE_QUOTA}/${MERGE_QUOTA}) — remove a player to add ${withTrailingPeriod(record.abbr_name || key)}` - ); - return; - } - const proposedKeys = [...entry.memberKeys, key]; - if (findDuplicateMergeCard(proposedKeys, id)) { - setMergeEditMessage("This merged card already exists."); - return; - } - - entry.memberKeys = proposedKeys; - if (!workspaceSingles.has(key)) workspaceSingles.set(key, record); - - rebuildMergeCardFromMembers(entry); - renderMergeEditMembers(entry); - setMergeEditMessage(""); - els.mergeEditInput.value = ""; - runMergeEditSearch(); - renderPlayerCardsSpace(); -} - -// Removing a member down to <= 1 no longer dissolves the card immediately — -// the popup stays open showing whatever's left, and the dissolve (with the -// same "return the lone survivor to Single Cards" behavior as before) only -// actually happens once the user hits Done, see closeMergeEditPopup(). A -// removal that would leave this card's membership matching another existing -// card's is blocked too (CHANGE 5) — the invariant holds on every mutation, -// not just adds. -function removeMergeMember(id, key) { - const entry = mergeCards.get(id); - if (!entry) return; - const remaining = entry.memberKeys.filter((k) => k !== key); - if (findDuplicateMergeCard(remaining, id)) { - setMergeEditMessage("This merged card already exists."); - return; - } - entry.memberKeys = remaining; - rebuildMergeCardFromMembers(entry); - renderMergeEditMembers(entry); - renderPlayerCardsSpace(); -} - -// Small, reliable hover/focus tooltip for elements where the native `title` -// attribute isn't good enough — either the trigger is small (a percentile -// badge, an info icon) so the browser's dwell-time-before-showing makes it -// feel broken, or the trigger lives inside a scrolling ancestor -// (.linemate-summary-wrap, .scout-card-panel) whose overflow would clip a -// CSS ::after popup the way .info-hint uses elsewhere. Appending straight to -// document.body sidesteps both: it paints immediately on hover/focus and no -// ancestor's overflow can clip it. `title` is still set alongside this as a -// plain-text fallback for screen readers and no-hover touch devices. -let appTooltipEl = null; - -function showAppTooltip(triggerEl, text) { - hideAppTooltip(); - const tip = document.createElement("div"); - tip.className = "app-tooltip"; - tip.textContent = text; - document.body.appendChild(tip); - - const triggerRect = triggerEl.getBoundingClientRect(); - const tipRect = tip.getBoundingClientRect(); - const left = Math.min( - Math.max(triggerRect.left + triggerRect.width / 2 - tipRect.width / 2, 8), - window.innerWidth - tipRect.width - 8 - ); - const above = triggerRect.top - tipRect.height - 8; - const top = above >= 8 ? above : triggerRect.bottom + 8; - tip.style.left = `${left}px`; - tip.style.top = `${top}px`; - appTooltipEl = tip; -} - -function hideAppTooltip() { - if (appTooltipEl) { - appTooltipEl.remove(); - appTooltipEl = null; - } -} - -function attachAppTooltip(el, text) { - el.title = text; - el.addEventListener("mouseenter", () => showAppTooltip(el, text)); - el.addEventListener("mouseleave", hideAppTooltip); - el.addEventListener("focus", () => showAppTooltip(el, text)); - el.addEventListener("blur", hideAppTooltip); -} - -// --- Linemate Association Cards (BLUEPRINT.md §2) --------------------------- - -// Same-team, same-season, same-category roster around `anchorRecord`, -// including same-position linemates (BLUEPRINT.md §2.2's position table), -// excluding the anchor himself and anyone below the applied snap threshold, -// then sorts by snap count (the category's threshold_field, the only volume -// stat available per player) descending and caps at the category's limit — -// dropping the lowest first, no positional reservation. -function computeLinemateRoster(anchorRecord) { - const cat = appliedCategoryMeta(); - const positions = LINEMATE_POSITIONS[appliedFilters.category]; - const threshold = Number(appliedFilters.threshold); - - const qualifying = currentRecords.filter( - (r) => - positions.includes(r.position) && - r.team === anchorRecord.team && - r.player !== anchorRecord.player && - r[cat.threshold_field] >= threshold - ); - - const sorted = qualifying.slice().sort((a, b) => b[cat.threshold_field] - a[cat.threshold_field]); - const cap = LINEMATE_CAP[appliedFilters.category]; - - return sorted.slice(0, cap); -} - -// Min/Median/RSWA/Max per metric over `rosterRecords` — always the full -// capped roster, regardless of "See more" state (BLUEPRINT.md §2.5: that -// control is display-only). RSWA (Relative Snaps Weighted Average) weights -// each linemate's percentile by his own share of the roster's total snaps — -// weight[t] = snaps[t] / sum(snaps) — exactly the formula in BLUEPRINT.md -// §2.5, so a linemate who played more snaps alongside the anchor's unit -// counts for more of the summary. -function computeThreeMRSWA(rosterRecords, cat) { - const perPlayer = rosterRecords.map((t) => { - const pool = positionPool(t.position); - const percentiles = {}; - Object.entries(cat.metrics).forEach(([key, meta]) => { - const rank = rankAndPercentile(pool, key, meta.higher_is_better, t[key]); - percentiles[key] = rank ? rank.percentile : null; - }); - return { percentiles, snaps: t[cat.threshold_field] || 0 }; - }); - - const totalSnaps = perPlayer.reduce((sum, p) => sum + p.snaps, 0); - - const results = {}; - Object.keys(cat.metrics).forEach((key) => { - const values = perPlayer.map((p) => p.percentiles[key]).filter((v) => v != null); - let rswa = null; - if (totalSnaps > 0) { - rswa = perPlayer.reduce((sum, p) => sum + (p.percentiles[key] ?? 0) * (p.snaps / totalSnaps), 0); - rswa = Math.round(rswa * 10) / 10; - } - results[key] = { - min: values.length ? Math.min(...values) : null, - median: median(values), - max: values.length ? Math.max(...values) : null, - rswa, - }; - }); - return results; -} - -function toggleLinemateCard(anchorRecord) { - if (!isDesktopScoutLayout()) return; - if (linemateCards.has(anchorRecord.player)) { - closeLinemateCard(anchorRecord.player); - } else { - openLinemateCard(anchorRecord); - } -} - -function closeLinemateCard(anchorKey) { - const entry = linemateCards.get(anchorKey); - if (!entry) return; - entry.el.remove(); - linemateCards.delete(anchorKey); - updateScoutEmptyHint(); -} - -// Renders the visible slice of the roster (first 5 by snap count, or all of -// it once "See more" is toggled) into listEl — percentiles only, each -// against the linemate's own position pool, with the pool denominator shown -// per BLUEPRINT.md §2.4. -function renderLinemateRoster(entry, roster, listEl) { - const cat = appliedCategoryMeta(); - listEl.innerHTML = ""; - const visibleCount = entry.seeMore ? roster.length : Math.min(LINEMATE_VISIBLE_DEFAULT, roster.length); - - roster.slice(0, visibleCount).forEach((t) => { - const pool = positionPool(t.position); - const row = document.createElement("div"); - row.className = "linemate-row"; - - const name = document.createElement("span"); - name.className = "linemate-row-name"; - name.textContent = `${t.position} — ${t.abbr_name || t.player}`; - row.appendChild(name); - - const cellsWrap = document.createElement("div"); - cellsWrap.className = "linemate-row-cells"; - Object.entries(cat.metrics).forEach(([key, meta]) => { - const rank = rankAndPercentile(pool, key, meta.higher_is_better, t[key]); - const cell = document.createElement("span"); - cell.className = "linemate-cell"; - cell.textContent = rank ? ordinal(rank.percentile) : "—"; - attachAppTooltip( - cell, - rank ? `${key}:\n#${rank.rank}/${rank.n} ${cat.positions[t.position] || t.position}` : key - ); - cellsWrap.appendChild(cell); - }); - row.appendChild(cellsWrap); - - listEl.appendChild(row); - }); -} - -// Fully rebuilds a Linemate Card's title tooltip, roster, and Line Summary -// table from entry.anchorRecord's current team/position/category/threshold — -// used both to build a freshly opened card and to refresh an already-open -// one after a threshold-only Apply (which doesn't dissolve Linemate Cards, -// only a season/category/position change does — see applyFilters()). A -// threshold change moves who qualifies, so a card left showing the old -// roster/percentiles would be silently wrong, not just stale display. -function renderLinemateCardBody(entry) { - const cat = appliedCategoryMeta(); - const cardEl = entry.el; - const titleEl = cardEl.querySelector(".linemate-card-title"); - const listEl = cardEl.querySelector(".linemate-roster"); - const seeMoreBtn = cardEl.querySelector(".linemate-see-more"); - const summaryTable = cardEl.querySelector(".linemate-summary-table"); - - entry.roster = computeLinemateRoster(entry.anchorRecord); - const roster = entry.roster; - - titleEl.textContent = `${entry.anchorRecord.player} Linemates`; - // The roster-size/threshold sentence used to sit on its own line under the - // title; it's now a tooltip on this icon instead, freeing that line for - // the "All numbers are..." disclaimer and the "Tooltip each percentile..." - // hint below it. - const rosterSummary = `${roster.length} linemate${roster.length === 1 ? "" : "s"} ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`; - const titleHint = document.createElement("i"); - titleHint.className = "fa-solid fa-circle-info linemate-card-title-hint"; - titleHint.setAttribute("aria-label", rosterSummary); - attachAppTooltip(titleHint, rosterSummary); - titleEl.appendChild(titleHint); - - // A refresh that shrinks the roster to <= the default visible count - // resets "See more" back to collapsed — there's nothing left to hide, so - // a lingering "See less" state would just be confusing. - if (roster.length <= LINEMATE_VISIBLE_DEFAULT) entry.seeMore = false; - renderLinemateRoster(entry, roster, listEl); - seeMoreBtn.hidden = roster.length <= LINEMATE_VISIBLE_DEFAULT; - seeMoreBtn.textContent = entry.seeMore ? "See less" : "See more"; - - // 3M + RSWA summary, computed over every qualifying (capped) linemate - // regardless of "See more" state. - const summary = computeThreeMRSWA(roster, cat); - summaryTable.innerHTML = ""; - const theadRow = document.createElement("tr"); - const RSWA_TOOLTIP = - "Relative Snaps Weighted Average — each linemate's percentile weighted by his own " + - `share of roster's total ${thresholdFieldLabel(cat)} (weight = his snaps ÷ the filtered roster's ` + - "total snaps), so a linemate who played more counts for more of line summary."; - [ - { label: "Metric" }, - { label: "Min" }, - { label: "Median" }, - { label: "RSWA", title: RSWA_TOOLTIP }, - { label: "Max" }, - ].forEach(({ label, title }) => { - const th = document.createElement("th"); - th.appendChild(document.createTextNode(label)); - if (title) { - // A plain `title` on the alone is easy to miss — nothing next to - // the text signals it's hoverable. The icon is the visible affordance; - // attachAppTooltip() (not the native title's dwell-time popup, and not - // the app's usual .info-hint ::after) is what actually shows the - // explanation, since .linemate-summary-wrap scrolls with - // overflow-x:auto and would otherwise clip it. - const hint = document.createElement("i"); - hint.className = "fa-solid fa-circle-info linemate-summary-hint"; - hint.setAttribute("aria-label", title); - attachAppTooltip(hint, title); - th.appendChild(hint); - } - theadRow.appendChild(th); - }); - const thead = document.createElement("thead"); - thead.appendChild(theadRow); - - const tbody = document.createElement("tbody"); - Object.keys(cat.metrics).forEach((key) => { - const row = summary[key]; - const tr = document.createElement("tr"); - const labelTd = document.createElement("td"); - labelTd.className = "linemate-summary-metric"; - labelTd.textContent = key; - tr.appendChild(labelTd); - [row.min, row.median, row.rswa, row.max].forEach((v) => { - const td = document.createElement("td"); - td.textContent = v == null ? "—" : ordinal(Math.round(v)); - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - summaryTable.appendChild(thead); - summaryTable.appendChild(tbody); -} - -// Re-renders every open Linemate Card in place against the current -// threshold/category/season — called after every render() in applyFilters() -// alongside refreshOpenScoutCards(). Safe to call unconditionally: if a -// slice change dissolved every card first, this is just an empty loop. -function refreshOpenLinemateCards() { - linemateCards.forEach((entry) => renderLinemateCardBody(entry)); -} - -function openLinemateCard(anchorRecord) { - const cardEl = els.linemateCardTemplate.content.firstElementChild.cloneNode(true); - - const closeBtn = cardEl.querySelector(".scout-close"); - const foldBtn = cardEl.querySelector(".scout-fold"); - const dragHandle = cardEl.querySelector(".scout-drag-handle"); - const listEl = cardEl.querySelector(".linemate-roster"); - const seeMoreBtn = cardEl.querySelector(".linemate-see-more"); - - const id = nextCardId(); - const entry = { - id, - origin: "linemate", - anchorKey: anchorRecord.player, - anchorRecord, - roster: [], - el: cardEl, - folded: false, - seeMore: false, - }; - - // Wired once — reads entry.roster/entry.seeMore fresh on every click, so - // it keeps working correctly across renderLinemateCardBody() refreshes - // without needing to be re-attached. - seeMoreBtn.addEventListener("click", () => { - entry.seeMore = !entry.seeMore; - seeMoreBtn.textContent = entry.seeMore ? "See less" : "See more"; - renderLinemateRoster(entry, entry.roster, listEl); - }); - - renderLinemateCardBody(entry); - - els.scoutCards.appendChild(cardEl); - cascadeScoutCardPosition(cardEl); // must run before linemateCards.set() below - cardEl.classList.add("is-active"); - bringScoutCardToFront(cardEl); - - // Shared by the fold button and the resize-driven auto-unfold (see - // beginScoutResize) so both paths reset "See more" the same way. - const onUnfold = () => { - entry.seeMore = false; - seeMoreBtn.textContent = "See more"; - renderLinemateRoster(entry, entry.roster, listEl); - }; - attachScoutResize(cardEl, entry, onUnfold); - - closeBtn.addEventListener("click", () => closeLinemateCard(anchorRecord.player)); - foldBtn.addEventListener("click", () => toggleCardFold(cardEl, entry, onUnfold)); - dragHandle.addEventListener("pointerdown", (e) => beginScoutDrag(e, cardEl)); - dragHandle.addEventListener("pointermove", onScoutDragMove); - dragHandle.addEventListener("pointerup", endScoutDrag); - dragHandle.addEventListener("pointercancel", endScoutDrag); - cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl)); - - linemateCards.set(anchorRecord.player, entry); - updateScoutEmptyHint(); -} - -// --- Pinned Players UI (BLUEPRINT_PinnedPlayers.md) ------------------ - -// Pinned Players (quota bar + Single Cards / Merged Cards columns) is always -// visible on desktop/tablet — unlike the old merge-toolbar it replaces, its -// visibility isn't gated on any card being open (§2: "the quota counter -// stays visible in BOTH [collapsed/expanded] states"). Called from every -// place workspaceSingles/mergeCards change, plus the resize handler for the -// breakpoint crossing. The Create button (v1.2.0 §5) is a static control — -// no selection-count/disabled state to keep in sync here anymore, since the -// Create popup runs its own checks at Submit time. -function renderPlayerCardsSpace() { - const visible = isDesktopScoutLayout(); - els.playerCardsSpace.hidden = !visible; - if (!visible) return; - - const distinct = distinctWorkspacePlayers(); - els.pcsQuotaLabel.textContent = `Pinned Players: ${distinct.size} / ${MERGE_QUOTA} Distinct Players.`; - - renderSinglesList(); - renderMergedList(); -} - -// Single Cards column (§1/§4): one row per Workspace player, name/team + -// Remove (exits the Workspace). Entry is via the add-search box above the -// list (runPcsAddSearch()/addPlayerToSingleCards()), not a plot click and -// not a per-row Merge action (v1.2.0 §4/§5 — see the Create popup instead). -function renderSinglesList() { - const empty = workspaceSingles.size === 0; - els.pcsSinglesList.hidden = empty; - els.pcsSinglesList.innerHTML = ""; - if (empty) return; - - workspaceSingles.forEach((record, key) => { - const row = document.createElement("div"); - row.className = "pcs-row pcs-row--single is-clickable"; - row.dataset.player = key; - // Reopens the floating card if it's been closed, or brings it to front - // (scrolled into view) if it's already open somewhere off-screen — this - // existing behavior is unchanged/reused as-is (v1.2.0 §4). Remove stops - // propagation so it keeps its own single-purpose click. - row.addEventListener("click", () => { - if (scoutCards.has(key)) { - flashFloatingCard(key); - } else { - openScoutCard(record); - } - }); - - const name = document.createElement("span"); - name.className = "pcs-row-name"; - name.textContent = record.abbr_name || key; - row.appendChild(name); - - const meta = document.createElement("span"); - meta.className = "pcs-row-meta"; - meta.textContent = `${record.team} · ${record.position}`; - row.appendChild(meta); - - const spacer = document.createElement("span"); - spacer.className = "pcs-row-spacer"; - row.appendChild(spacer); - - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "pcs-row-remove"; - removeBtn.textContent = "Remove"; - removeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - removeSingleCard(key); - }); - row.appendChild(removeBtn); - - els.pcsSinglesList.appendChild(row); - }); -} - -// Merged Cards column (§1/§5): one row per Merge Card — open or closed, see -// closeMergeCardFloating() — a short member-name summary + Edit (membership -// popup) + Dismiss (closeMergeCard, the only full dissolve). The row itself -// is clickable to reopen/focus the floating card, same "admit or focus" -// pattern as Single Cards rows. -function renderMergedList() { - const empty = mergeCards.size === 0; - els.pcsMergedEmpty.hidden = !empty; - els.pcsMergedList.hidden = empty; - els.pcsMergedList.innerHTML = ""; - if (empty) return; - - mergeCards.forEach((entry) => { - const row = document.createElement("div"); - row.className = "pcs-row is-clickable"; - row.dataset.id = entry.id; - row.addEventListener("click", () => reopenOrFocusMergeCard(entry.id)); - - const names = entry.memberKeys.map((key) => { - const record = findRecordByPlayer(key); - return (record && (record.abbr_name || record.player)) || key; - }); - - const name = document.createElement("span"); - name.className = "pcs-row-name"; - name.textContent = `Merged — ${names.join(" · ")}`; - row.appendChild(name); - - const editBtn = document.createElement("button"); - editBtn.type = "button"; - editBtn.className = "pcs-row-edit"; - editBtn.textContent = "Edit"; - editBtn.addEventListener("click", (e) => { - e.stopPropagation(); - openMergeEditPopup(entry.id); - }); - row.appendChild(editBtn); - - const dismissBtn = document.createElement("button"); - dismissBtn.type = "button"; - dismissBtn.className = "pcs-row-dismiss"; - dismissBtn.textContent = "Dismiss"; - dismissBtn.addEventListener("click", (e) => { - e.stopPropagation(); - closeMergeCard(entry.id); - }); - row.appendChild(dismissBtn); - - els.pcsMergedList.appendChild(row); - }); -} - -// Manage (§2) — a panel that expands/collapses in place, deliberately not a -// native + + - -
- - -
+ +

Type a player's name to add a card.

+ +

Merged Cards

@@ -286,6 +296,9 @@

Merged Cards