diff --git a/README.md b/README.md
index 1cde028..0701a19 100644
--- a/README.md
+++ b/README.md
@@ -35,10 +35,11 @@
#### ② Frontend

+


-- JavaScript + CSS + HTML — visualize plotly graphs and interactive components.
+- JavaScript + Plotly + CSS + HTML — visualize plotly graphs and interactive components.
#### ③ CI/CD

@@ -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