From e8d23bf443e14cfe62f87a89642e6556816c12de Mon Sep 17 00:00:00 2001 From: Jack Yao <105488074+StarsExpress@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:49:06 -0400 Subject: [PATCH 1/3] Prepared for NCAAF. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Teams references. • Database schemas. Co-Authored-By: Claude --- README.md | 2 +- config.py | 24 +- database/db_ingestion.py | 117 +- database/db_models.py | 59 +- frontend/card-export.js | 37 +- frontend/cards-base.js | 5 +- frontend/chart-export.js | 79 +- frontend/config.js | 15 +- frontend/data.js | 10 + frontend/filters.js | 4 +- frontend/index.html | 1046 +++++++++-------- frontend/linemate-card.js | 396 ++++--- frontend/main.js | 37 +- frontend/merge-card.js | 55 +- frontend/merge-table-model.js | 57 - frontend/player-table-model.js | 80 ++ frontend/scout-card.js | 57 +- frontend/style.css | 908 +++++++++++--- frontend/workspace.js | 53 +- main.py | 63 +- playbook/DECISIONS.md | 17 +- playbook/INVARIANTS.md | 3 + playbook/ROSTER.md | 3 +- playbook/architecture/BACKEND-AND-DEPLOY.md | 17 +- playbook/architecture/CARDS.md | 45 +- playbook/architecture/FRONTEND-STRUCTURE.md | 2 +- playbook/architecture/METRICS-AND-NOTES.md | 5 +- playbook/architecture/THRESHOLDS.md | 16 +- playbook/architecture/UI-LAYOUT-AND-EXPORT.md | 2 +- playbook/tests/CONFTEST.md | 2 +- playbook/tests/INDEX.md | 6 + playbook/tests/TEST_API_ENDPOINTS.md | 2 +- playbook/tests/TEST_CARD_EXPORT_FRAME.md | 9 + playbook/tests/TEST_DB_INGESTION.md | 2 +- playbook/tests/TEST_DB_MODELS.md | 9 + playbook/tests/TEST_FILTERS.md | 4 +- playbook/tests/TEST_LINEMATE_CARD.md | 5 +- playbook/tests/TEST_MERGE_TABLE.md | 6 +- playbook/tests/TEST_NEUTRAL_METRIC.md | 9 + playbook/tests/TEST_PATHS.md | 2 +- playbook/tests/TEST_PINNED_PANEL.md | 9 + playbook/tests/TEST_PREPROCESSING_LEAGUE.md | 9 + playbook/tests/TEST_TEAMS_NCAAF.md | 9 + preprocessing/front_7.py | 33 +- preprocessing/offensive_line.py | 28 +- preprocessing/preprocess.py | 83 +- teams/ncaaf.py | 178 +++ teams/nfl.py | 45 + tests/conftest.py | 32 +- tests/test_api_endpoints.py | 62 + tests/test_card_export_frame.py | 266 +++++ tests/test_db_ingestion.py | 117 +- tests/test_db_models.py | 116 ++ tests/test_filters.py | 49 + tests/test_linemate_card.py | 268 ++++- tests/test_merge_table.py | 107 +- tests/test_neutral_metric.py | 211 ++++ tests/test_paths.py | 8 + tests/test_pinned_panel.py | 91 ++ tests/test_playbook_docs.py | 2 +- tests/test_preprocessing_league.py | 177 +++ tests/test_teams_ncaaf.py | 134 +++ 62 files changed, 4182 insertions(+), 1122 deletions(-) delete mode 100644 frontend/merge-table-model.js create mode 100644 frontend/player-table-model.js create mode 100644 playbook/tests/TEST_CARD_EXPORT_FRAME.md create mode 100644 playbook/tests/TEST_DB_MODELS.md create mode 100644 playbook/tests/TEST_NEUTRAL_METRIC.md create mode 100644 playbook/tests/TEST_PINNED_PANEL.md create mode 100644 playbook/tests/TEST_PREPROCESSING_LEAGUE.md create mode 100644 playbook/tests/TEST_TEAMS_NCAAF.md create mode 100644 teams/ncaaf.py create mode 100644 teams/nfl.py create mode 100644 tests/test_card_export_frame.py create mode 100644 tests/test_db_models.py create mode 100644 tests/test_neutral_metric.py create mode 100644 tests/test_pinned_panel.py create mode 100644 tests/test_preprocessing_league.py create mode 100644 tests/test_teams_ncaaf.py diff --git a/README.md b/README.md index 1c7474f..b5f63cd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ![Latest](https://img.shields.io/badge/Latest-Data-violet?labelColor=violet&style=flat) ![Season](https://img.shields.io/badge/Season-2026-crimson) -![Week](https://img.shields.io/badge/Week-6-gold) +![Week](https://img.shields.io/badge/Week-7-gold) ![Oldest](https://img.shields.io/badge/Oldest-Data-brown?labelColor=brown&style=flat) ![Season](https://img.shields.io/badge/Season-2022-crimson) diff --git a/config.py b/config.py index 1f6aeb4..c697e34 100644 --- a/config.py +++ b/config.py @@ -2,9 +2,28 @@ import os from datetime import date +from enum import Enum NFL_BASE_PATH = os.path.dirname(os.path.abspath(__file__)) DATA_FOLDER_PATH = os.path.join(NFL_BASE_PATH, "data") +# Each league keeps its files in its own subfolder: +# data/{nfl,ncaaf}/{front_7_pass_rush,ol_pass_block}/. +# DATA_FOLDER_PATH stays the root of all data. +NFL_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "nfl") +NCAAF_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "ncaaf") + + +class League(str, Enum): + """The leagues the data pipeline knows. The value is the data folder name.""" + + NFL = "nfl" + NCAAF = "ncaaf" + + +LEAGUE_DATA_PATHS: dict[League, str] = { + League.NFL: NFL_DATA_PATH, + League.NCAAF: NCAAF_DATA_PATH, +} ROUNDING_DECIMALS = 3 # Better precision alleviates overlaps in plots. @@ -21,6 +40,7 @@ ) PRP_NOTE = "PRP = (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Rush Snaps x 100." +PASS_COV_PERCENT_NOTE = "Pass Cov Percent = 100 - Pass Rush Percent, where Pass Rush Percent = Pass Rush Snaps / Pass Play Snaps x 100." PBE_NOTE = "PBE = 100 - (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Block Snaps." # Shared across every "TPS "-prefixed metric — not a per-metric formula, so @@ -54,8 +74,8 @@ # Key, which means current season, must be "renamed" when a new season comes. DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = { 2026: { - "pass_rush": 80, # Min PR Opp for pass rush filter. - "pass_block": 100, # Min Non Spike PB Snaps for pass block filter. + "pass_rush": 95, # Min PR Opp for pass rush filter. + "pass_block": 105, # Min Non Spike PB Snaps for pass block filter. }, } diff --git a/database/db_ingestion.py b/database/db_ingestion.py index 6044106..7c21967 100644 --- a/database/db_ingestion.py +++ b/database/db_ingestion.py @@ -1,9 +1,13 @@ """Ingest preprocessed PFF Excel workbooks into database. -Reads `front_7_pass_rush/{season}.xlsx` and -`ol_pass_block/{season}.xlsx` files that +Reads `nfl/front_7_pass_rush/{season}.xlsx` and +`nfl/ol_pass_block/{season}.xlsx` files (under the data root) that `preprocessing/front_7.py` and `preprocessing/offensive_line.py` already -produce, and bulk-inserts them into `pass_rush_stats` / `pass_block_stats` tables. +produce, and bulk-inserts them into the `nfl_pass_rush_stats` / +`nfl_pass_block_stats` tables. + +Strict: a sheet that lacks any column the tables need, or a row without a +`Player Id`, stops the run with an error instead of loading NULLs. Idempotent: rerunning for same season/position first deletes existing rows for that slice, then bulk-inserts fresh ones — cleaner than @@ -16,7 +20,7 @@ For Railway, set DATABASE_URL to Postgres URL Railway assigns. Point LINESHINES_REPO_ROOT at LinesShines project root so scripts can -locate xlsx files under $LINESHINES_REPO_ROOT/data/. +locate xlsx files under $LINESHINES_REPO_ROOT/data/nfl/. """ from __future__ import annotations @@ -30,15 +34,55 @@ from sqlalchemy import delete from sqlalchemy.orm import Session from config import DYNAMIC_THRESHOLDS -from database.db_models import Base, PassBlockStat, PassRushStat, Team +from database.db_models import Base, NFLPassBlockStat, NFLPassRushStat, NFLTeam from main import engine, SessionLocal -from teams_reference import TEAMS +from teams.nfl import TEAMS FRONT_7_POSITIONS = ("DI", "ED", "LB") OL_POSITIONS = ("T", "G", "C") +# The data root holds one folder per league; the NFL files are under data/nfl/. +NFL_SUBDIR = "nfl" + SEASON_CATEGORY_DIRS = ("front_7_pass_rush", "ol_pass_block") +# Columns each category's sheets must have; ingestion fails loudly without them. +PASS_RUSH_COLUMNS = ( + "Team", + "Player Id", + "Player", + "Abbr Name", + "Games", + "PR Opp", + "TPS PR Opp", + "Pass Rush Percent", + "TPS Pass Rush Percent", + "Win Rate", + "TPS Win Rate", + "Pressure Rate", + "TPS Pressure Rate", + "Havoc Rate", + "TPS Havoc Rate", + "PRP", + "TPS PRP", +) + +PASS_BLOCK_COLUMNS = ( + "Team", + "Player Id", + "Player", + "Abbr Name", + "Games", + "Non Spike PB Snaps", + "TPS Non Spike PB Snaps", + "Allowed Pressure %", + "TPS Allowed Pressure %", + "Allowed Havoc %", + "TPS Allowed Havoc %", + "PBE", + "TPS PBE", +) + def _find_project_root() -> Path: """Walk up from this file until we find a marker that identifies project root.""" @@ -51,7 +95,7 @@ def _find_project_root() -> Path: def _repo_root() -> Path: - """Where to find preprocessed xlsx files. + """The data root: the folder that holds `nfl/` (and later `ncaaf/`). Order of precedence: 1. `--data-dir` CLI flag. @@ -72,7 +116,7 @@ def _repo_root() -> Path: def _discover_seasons(data_dir: Path) -> list[int]: """Union of seasons with a `{season}.xlsx` file under either category - subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir`. + subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir/nfl`. Union, not intersection — OL and DL are independent categories with no joint table, so it's fine for one to have a season's file before the other does. @@ -85,7 +129,7 @@ def _discover_seasons(data_dir: Path) -> list[int]: seasons = set() for category_dir in SEASON_CATEGORY_DIRS: - for path in (data_dir / category_dir).glob("*.xlsx"): + for path in (data_dir / NFL_SUBDIR / category_dir).glob("*.xlsx"): if re.fullmatch(r"\d{4}", path.stem): seasons.add(int(path.stem)) @@ -103,17 +147,38 @@ def _safe_float(val): return None if pd.isna(val) else float(val) +def _complement(val): + """Pass Cov Percent = 100 - Pass Rush Percent (the source file holds the latter).""" + return None if pd.isna(val) else round(100.0 - float(val), 10) + + +def _player_id(row, path, sheet) -> int: + val = row.get("Player Id") + if pd.isna(val): + raise ValueError( + f"{path} sheet {sheet}: row for {row.get('Player')!r} has no Player Id" + ) + + return int(val) + + +def _require_columns(df: pd.DataFrame, columns, path, sheet) -> None: + missing = [c for c in columns if c not in df.columns] + if missing: + raise ValueError(f"{path} sheet {sheet} is missing columns: {missing}") + + def upsert_teams(sess: Session) -> None: - existing = {t.code for t in sess.query(Team).all()} + existing = {t.code for t in sess.query(NFLTeam).all()} for code, (full_name, color) in TEAMS.items(): if code in existing: - row = sess.get(Team, code) + row = sess.get(NFLTeam, code) row.full_name = full_name row.primary_color = color else: - sess.add(Team(code=code, full_name=full_name, primary_color=color)) + sess.add(NFLTeam(code=code, full_name=full_name, primary_color=color)) sess.commit() @@ -121,7 +186,7 @@ def upsert_teams(sess: Session) -> None: def ingest_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: total = 0 for season in seasons: - path = data_dir / "front_7_pass_rush" / f"{season}.xlsx" + path = data_dir / NFL_SUBDIR / "front_7_pass_rush" / f"{season}.xlsx" if not path.exists(): print(f"Skip pass rush {season}: {path} not found") @@ -133,28 +198,34 @@ def ingest_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: if position_df is None or position_df.empty: continue + _require_columns(position_df, PASS_RUSH_COLUMNS, path, position) position_df.dropna(subset=["PR Opp"], inplace=True) # Wipe slice before re-insertion. sess.execute( - delete(PassRushStat).where( - PassRushStat.season == season, - PassRushStat.position == position, + delete(NFLPassRushStat).where( + NFLPassRushStat.season == season, + NFLPassRushStat.position == position, ) ) rows = [] for _, row in position_df.iterrows(): rows.append( - PassRushStat( + NFLPassRushStat( season=season, position=position, team_code=row["Team"], + player_id=_player_id(row, path, position), player=row["Player"], abbr_name=row["Abbr Name"], games=_safe_int(row.get("Games")), pr_opp=_safe_int(row.get("PR Opp")), tps_pr_opp=_safe_int(row.get("TPS PR Opp")), + pass_cov_percent=_complement(row.get("Pass Rush Percent")), + tps_pass_cov_percent=_complement( + row.get("TPS Pass Rush Percent") + ), win_rate=_safe_float(row.get("Win Rate")), tps_win_rate=_safe_float(row.get("TPS Win Rate")), pressure_rate=_safe_float(row.get("Pressure Rate")), @@ -177,7 +248,7 @@ def ingest_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: total = 0 for season in seasons: - path = data_dir / "ol_pass_block" / f"{season}.xlsx" + path = data_dir / NFL_SUBDIR / "ol_pass_block" / f"{season}.xlsx" if not path.exists(): print(f"Skip pass block {season}: {path} not found") continue @@ -188,22 +259,24 @@ def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: if position_df is None or position_df.empty: continue + _require_columns(position_df, PASS_BLOCK_COLUMNS, path, position) position_df.dropna(subset=["Non Spike PB Snaps"], inplace=True) sess.execute( - delete(PassBlockStat).where( - PassBlockStat.season == season, - PassBlockStat.position == position, + delete(NFLPassBlockStat).where( + NFLPassBlockStat.season == season, + NFLPassBlockStat.position == position, ) ) rows = [] for _, row in position_df.iterrows(): rows.append( - PassBlockStat( + NFLPassBlockStat( season=season, position=position, team_code=row["Team"], + player_id=_player_id(row, path, position), player=row["Player"], abbr_name=row["Abbr Name"], games=_safe_int(row.get("Games")), diff --git a/database/db_models.py b/database/db_models.py index b137a31..60c9387 100644 --- a/database/db_models.py +++ b/database/db_models.py @@ -3,6 +3,12 @@ Portable across SQLite (local dev) and PostgreSQL (Railway prod) — the only dialect-specific concern is autoincrement, which SQLAlchemy handles transparently for `Integer` primary keys. + +Every NFL table, constraint and index carries an `nfl` prefix (`nfl_teams`, +`uq_nfl_pr_...`, `idx_nfl_pb_...`) so the college tables can sit beside them +in the same database as `ncaaf_...`. The stat tables are keyed by PFF's +`player_id` (one row per player per season and position), which is also the +id the college files use. """ from __future__ import annotations @@ -22,25 +28,26 @@ class Base(DeclarativeBase): pass -class Team(Base): - __tablename__ = "teams" +class NFLTeam(Base): + __tablename__ = "nfl_teams" code = Column(String(4), primary_key=True) full_name = Column(String, nullable=False) primary_color = Column(String(7)) # Hex like "#0085CA". - pass_rush_stats = relationship("PassRushStat", back_populates="team") - pass_block_stats = relationship("PassBlockStat", back_populates="team") + pass_rush_stats = relationship("NFLPassRushStat", back_populates="team") + pass_block_stats = relationship("NFLPassBlockStat", back_populates="team") -class PassRushStat(Base): - __tablename__ = "pass_rush_stats" +class NFLPassRushStat(Base): + __tablename__ = "nfl_pass_rush_stats" id = Column(Integer, primary_key=True, autoincrement=True) season = Column(Integer, nullable=False) position = Column(String(4), nullable=False) # DI / ED. - team_code = Column(String(4), ForeignKey("teams.code"), nullable=False) + team_code = Column(String(4), ForeignKey("nfl_teams.code"), nullable=False) + player_id = Column(Integer, nullable=False) # PFF player id. player = Column(String, nullable=False) abbr_name = Column(String, nullable=False) @@ -48,6 +55,9 @@ class PassRushStat(Base): pr_opp = Column(Integer) # PR Opp. tps_pr_opp = Column(Integer) # TPS PR Opp. + pass_cov_percent = Column(Float) # Pass Cov Percent = 100 - Pass Rush Percent. + tps_pass_cov_percent = Column(Float) # TPS Pass Cov Percent. + win_rate = Column(Float) # Win Rate. tps_win_rate = Column(Float) # TPS Win Rate. @@ -60,28 +70,29 @@ class PassRushStat(Base): prp = Column(Float) # PRP. tps_prp = Column(Float) # TPS PRP. - team = relationship("Team", back_populates="pass_rush_stats") + team = relationship("NFLTeam", back_populates="pass_rush_stats") __table_args__ = ( UniqueConstraint( "season", "position", - "player", - "team_code", - name="uq_pr_season_position_player_team", + "player_id", + name="uq_nfl_pr_season_position_player_id", ), - Index("idx_pr_filter", "season", "position"), + Index("idx_nfl_pr_filter", "season", "position"), + Index("idx_nfl_pr_player_id", "player_id"), ) -class PassBlockStat(Base): - __tablename__ = "pass_block_stats" +class NFLPassBlockStat(Base): + __tablename__ = "nfl_pass_block_stats" id = Column(Integer, primary_key=True, autoincrement=True) season = Column(Integer, nullable=False) position = Column(String(4), nullable=False) # T / G / C. - team_code = Column(String(4), ForeignKey("teams.code"), nullable=False) + team_code = Column(String(4), ForeignKey("nfl_teams.code"), nullable=False) + player_id = Column(Integer, nullable=False) # PFF player id. player = Column(String, nullable=False) abbr_name = Column(String, nullable=False) @@ -98,17 +109,17 @@ class PassBlockStat(Base): pbe = Column(Float) # PBE. tps_pbe = Column(Float) # TPS PBE. - team = relationship("Team", back_populates="pass_block_stats") + team = relationship("NFLTeam", back_populates="pass_block_stats") __table_args__ = ( UniqueConstraint( "season", "position", - "player", - "team_code", - name="uq_pb_season_position_player_team", + "player_id", + name="uq_nfl_pb_season_position_player_id", ), - Index("idx_pb_filter", "season", "position"), + Index("idx_nfl_pb_filter", "season", "position"), + Index("idx_nfl_pb_player_id", "player_id"), ) @@ -117,17 +128,20 @@ class PassBlockStat(Base): # preserved so no client-side renaming is needed). -def pass_rush_row_to_dict(row: PassRushStat) -> dict: +def pass_rush_row_to_dict(row: NFLPassRushStat) -> dict: return { "id": row.id, "season": row.season, "position": row.position, "team": row.team_code, + "player_id": row.player_id, "player": row.player, "abbr_name": row.abbr_name, "games": row.games, "PR Opp": row.pr_opp, "TPS PR Opp": row.tps_pr_opp, + "Pass Cov Percent": row.pass_cov_percent, + "TPS Pass Cov Percent": row.tps_pass_cov_percent, "Win Rate": row.win_rate, "TPS Win Rate": row.tps_win_rate, "Pressure Rate": row.pressure_rate, @@ -139,12 +153,13 @@ def pass_rush_row_to_dict(row: PassRushStat) -> dict: } -def pass_block_row_to_dict(row: PassBlockStat) -> dict: +def pass_block_row_to_dict(row: NFLPassBlockStat) -> dict: return { "id": row.id, "season": row.season, "position": row.position, "team": row.team_code, + "player_id": row.player_id, "player": row.player, "abbr_name": row.abbr_name, "games": row.games, diff --git a/frontend/card-export.js b/frontend/card-export.js index 47c4bcb..f60a31d 100644 --- a/frontend/card-export.js +++ b/frontend/card-export.js @@ -32,10 +32,8 @@ const CARD_EXPORT_BG = "#16301f"; // extra right-edge background is harmless, clipping the content isn't. const CARD_HORIZONTAL_PADDING = 40; -// Every card's own close/fold/resize chrome, this Save button itself, and -// Linemate Card's "See more" toggle (meaningless once buildExportClone below -// force-expands the roster to its full length — see linemate-card.js's -// prepareClone) shouldn't appear in the exported image. html2canvas's +// Every card's own close/fold/resize chrome and this Save button itself +// shouldn't appear in the exported image. html2canvas's // ignoreElements is the standard way to exclude specific nodes from a // capture without a hide-then-restore choreography. function shouldIgnoreForExport(el) { @@ -47,7 +45,8 @@ function shouldIgnoreForExport(el) { el.classList.contains("card-save-btn") || el.classList.contains("merge-mode-switch") || el.classList.contains("merge-card-scroll-hint") || - el.classList.contains("linemate-see-more")) + el.classList.contains("linemate-card-scroll-hint") || + el.classList.contains("linemate-card-size-reminder")) ); } @@ -75,16 +74,19 @@ function fitCloneToContent(clone) { // sidesteps that entirely — with nothing scrolling underneath them in the // export, sticky was only ever pinning them to their own already-correct // flow position anyway. - clone.querySelectorAll(".merge-table-frozen, .linemate-table-frozen, .merge-read-frozen").forEach((cell) => { - cell.style.position = "static"; - cell.style.left = ""; - }); + clone + .querySelectorAll(".merge-table-frozen, .linemate-table-frozen, .merge-read-frozen, .linemate-read-label") + .forEach((cell) => { + cell.style.position = "static"; + cell.style.left = ""; + }); // Lays every column flat in one row instead of clipping to whatever was // scrolled into view, then widens the card itself to fit. let requiredWidth = 0; clone.querySelectorAll(".merge-table-wrap, .linemate-table-wrap, .linemate-summary-wrap").forEach((scrollEl) => { scrollEl.style.overflow = "visible"; + scrollEl.style.maxHeight = "none"; requiredWidth = Math.max(requiredWidth, scrollEl.scrollWidth); }); if (requiredWidth > 0) { @@ -119,10 +121,15 @@ function buildExportClone(cardEl) { // layout — a detached node always reports 0. document.body.appendChild(clone); - clone.classList.remove("is-folded", "is-dragging", "is-resizing"); + clone.classList.remove("is-folded", "is-dragging", "is-resizing", "is-wide"); const panelEl = clone.querySelector(".scout-card-panel"); - if (panelEl) panelEl.style.cssText = "height: auto; max-height: none; overflow: visible;"; + // The panel's own 2px rounded border is dropped here: compositeFooterCanvas draws one + // square frame on the final image's outer edge instead, around the card, notes and credit. + if (panelEl) { + panelEl.style.cssText = + "height: auto; max-height: none; overflow: visible; border: none; border-radius: 0; box-shadow: none;"; + } fitCloneToContent(clone); @@ -148,7 +155,13 @@ export async function exportCardPng(cardEl, filename, prepareClone, appendixNote scale: CARD_EXPORT_SCALE, ignoreElements: shouldIgnoreForExport, }); - await downloadCanvasAsPng(compositeFooterCanvas(sourceCanvas, CARD_EXPORT_SCALE, { appendixNotes }), filename); + const finalCanvas = compositeFooterCanvas(sourceCanvas, CARD_EXPORT_SCALE, { + appendixNotes, + frame: true, + creditBand: true, + notesDivider: true, + }); + await downloadCanvasAsPng(finalCanvas, filename); } finally { clone.remove(); } diff --git a/frontend/cards-base.js b/frontend/cards-base.js index 1d77a6b..88a614b 100644 --- a/frontend/cards-base.js +++ b/frontend/cards-base.js @@ -133,8 +133,9 @@ export function cascadeScoutCardPosition(cardEl) { // 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). +// restored on unfold rather than lost. `onUnfold`, when given, lets a card +// refresh something that has no layout while folded (the Comparison Card's +// sticky-column offsets) every time it re-expands. export function applyCardFoldVisual(cardEl, state, onUnfold) { cardEl.classList.toggle("is-folded", state.folded); const foldBtn = cardEl.querySelector(".scout-fold"); diff --git a/frontend/chart-export.js b/frontend/chart-export.js index affd2e8..d237de8 100644 --- a/frontend/chart-export.js +++ b/frontend/chart-export.js @@ -53,6 +53,25 @@ export const EXPORT_FOOTER_PADDING_X = 16; // Logical px, pre-scale. export const EXPORT_FOOTER_BG = "#16301f"; // Matches --turf-800, same swap `render()` does for export bg. +// The card exports' outer frame (compositeFooterCanvas's `frame` option): the same +// 2px #f0ede4 line the on-screen card panel has, square-cornered, drawn on the +// very edge of the final image so it encloses the card, notes and credit line. +export const EXPORT_FRAME_COLOR = "#f0ede4"; +export const EXPORT_FRAME_WIDTH = 2; // Logical px, pre-scale. + +// The card exports' credit line sits on a gold band (compositeFooterCanvas's +// `creditBand` option): --flag-gold behind --turf-950 text, the same pair as the card's +// tab. Dark text because white on this gold is only about 2:1. +export const EXPORT_BAND_BG = "#d3a73d"; +export const EXPORT_BAND_COLOR = "#0f2119"; + +// The card exports' dashed divider between the card's metrics and the notes strip +// (compositeFooterCanvas's `notesDivider` option): 1px, dashes and gaps 4px, the +// frame's chalk colour but faint, inset from both sides by the notes' own side padding. +export const EXPORT_DIVIDER_COLOR = "rgba(240, 237, 228, 0.45)"; +export const EXPORT_DIVIDER_WIDTH = 1; // Logical px, pre-scale. +export const EXPORT_DIVIDER_DASH = 4; // Logical px, pre-scale — dash and gap alike. + // `--chalk-dim`, muted so it doesn't compete with the plot. export const EXPORT_FOOTER_COLOR = "rgba(169, 182, 169, 0.75)"; @@ -80,8 +99,13 @@ export const APPENDIX_FONT_FAMILY = "Inter, sans-serif"; // Matches footer's own // exportChartPngWithFooter so card-export.js's html2canvas-based exports can // draw the exact same footer without duplicating this, keeping every PNG the // app produces (chart or card) on one shared brand footer. -export function drawExportFooter(ctx, canvasWidth, sourceHeight, footerPx, scale) { - ctx.fillStyle = EXPORT_FOOTER_COLOR; +export function drawExportFooter(ctx, canvasWidth, sourceHeight, footerPx, scale, { band = false } = {}) { + if (band) { + ctx.fillStyle = EXPORT_BAND_BG; + ctx.fillRect(0, sourceHeight, canvasWidth, footerPx); + } + + ctx.fillStyle = band ? EXPORT_BAND_COLOR : EXPORT_FOOTER_COLOR; ctx.font = `${Math.round(EXPORT_FOOTER_FONT_SIZE * scale)}px Inter, sans-serif`; ctx.textAlign = "right"; ctx.textBaseline = "middle"; @@ -167,6 +191,30 @@ function drawAppendix(ctx, canvasWidth, startY, lines, scale) { }); } +// Dashes are filled rectangles, like the frame's bars, so every dash is a whole number of pixels. +function drawNotesDivider(ctx, canvasWidth, y, scale) { + const inset = Math.round(APPENDIX_PADDING_X * scale); + const t = Math.round(EXPORT_DIVIDER_WIDTH * scale); + const dash = Math.round(EXPORT_DIVIDER_DASH * scale); + + ctx.fillStyle = EXPORT_DIVIDER_COLOR; + for (let x = inset; x < canvasWidth - inset; x += dash * 2) { + ctx.fillRect(x, y, Math.min(dash, canvasWidth - inset - x), t); + } +} + +// Four filled bars rather than a stroked rectangle, so the line is a whole number of +// pixels with no anti-aliased fringe on the image's edge pixels. +function drawExportFrame(ctx, width, height, scale) { + const t = Math.round(EXPORT_FRAME_WIDTH * scale); + + ctx.fillStyle = EXPORT_FRAME_COLOR; + ctx.fillRect(0, 0, width, t); + ctx.fillRect(0, height - t, width, t); + ctx.fillRect(0, 0, t, height); + ctx.fillRect(width - t, 0, t, height); +} + // Returns a new canvas: `sourceCanvas` with EXPORT_FOOTER_BG behind it (so // any transparent source pixels don't fall back to white), an optional // "Include metric notes" appendix strip, and the credit line drawn into the @@ -187,7 +235,19 @@ function drawAppendix(ctx, canvasWidth, startY, lines, scale) { // every card's html2canvas-rendered one) already funnels through this one // function for the footer, so the appendix rides along for free at every // call site rather than needing separate handling per export type. -export function compositeFooterCanvas(sourceCanvas, scale, { appendixNotes } = {}) { +// +// `frame` (card exports only; the chart export leaves it off and is unchanged) +// draws a square EXPORT_FRAME_WIDTH line in EXPORT_FRAME_COLOR on the outermost +// edge of the final canvas, last, so it sits above everything and encloses the +// card, the notes strip and the credit line. `creditBand` (also card exports only) +// puts the credit line on a full-width gold band with dark text instead of plain +// grey text on the background. `notesDivider` (also card exports only) draws a dashed +// line between the card and the notes strip, when there are notes. +export function compositeFooterCanvas( + sourceCanvas, + scale, + { appendixNotes, frame = false, creditBand = false, notesDivider = false } = {} +) { const footerPx = Math.round(EXPORT_FOOTER_HEIGHT * scale); const paddingPx = Math.round(EXPORT_FOOTER_PADDING_X * scale); @@ -211,8 +271,12 @@ export function compositeFooterCanvas(sourceCanvas, scale, { appendixNotes } = { ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(sourceCanvas, Math.round((canvas.width - sourceCanvas.width) / 2), 0); - if (appendixLines.length) drawAppendix(ctx, canvas.width, sourceCanvas.height, appendixLines, scale); - drawExportFooter(ctx, canvas.width, sourceCanvas.height + appendixPx, footerPx, scale); + if (appendixLines.length) { + drawAppendix(ctx, canvas.width, sourceCanvas.height, appendixLines, scale); + if (notesDivider) drawNotesDivider(ctx, canvas.width, sourceCanvas.height, scale); + } + drawExportFooter(ctx, canvas.width, sourceCanvas.height + appendixPx, footerPx, scale, { band: creditBand }); + if (frame) drawExportFrame(ctx, canvas.width, canvas.height, scale); return canvas; } @@ -431,7 +495,10 @@ export async function exportChartPngWithFooter(chartDiv, { width, height, scale, await document.fonts.ready; // Guards against a fallback-font flash if Oswald somehow hasn't finished loading yet. drawLabelEmphasisOverlay(ctx, positions, scale); - await downloadCanvasAsPng(compositeFooterCanvas(sourceCanvas, scale, { appendixNotes }), filename); + await downloadCanvasAsPng( + compositeFooterCanvas(sourceCanvas, scale, { appendixNotes, creditBand: true }), + filename + ); } finally { Plotly.purge(clone); clone.remove(); diff --git a/frontend/config.js b/frontend/config.js index 0590087..f74cbbe 100644 --- a/frontend/config.js +++ b/frontend/config.js @@ -10,7 +10,7 @@ // 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, +// the LinesShines/PFF spellings in teams/nfl.py (BLT, CLV, HST, LA, // LV, ...), not the NFL's own abbreviations. export const CONFERENCES = { AFC: { @@ -46,13 +46,14 @@ export const LABEL_ALPHA = 1; export const MERGE_CARD_MAX_MEMBERS = 5; export 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 -//. +// Position-group pulled into a Linemate Card's roster, keyed by category, +// not position, since a Linemate Card spans every position within its +// anchor's category. The roster itself has no size limit — the threshold is +// the only gate — so LINEMATE_SIZE_REMINDER_AT is only where the card starts +// telling the user that a higher threshold keeps it compact (more linemates +// than that, per category: the point where the old roster cap used to cut). export const LINEMATE_POSITIONS = { pass_block: ["T", "G", "C"], pass_rush: ["ED", "DI"] }; -export const LINEMATE_CAP = { pass_block: 5, pass_rush: 7 }; -export const LINEMATE_VISIBLE_DEFAULT = 5; +export const LINEMATE_SIZE_REMINDER_AT = { pass_block: 5, pass_rush: 7 }; // Mirrors config.py's DISPLAY_DECIMALS — keep in sync. UI-display rounding // only: plots (render.js's xVals/yVals, drawn straight from record[key]) diff --git a/frontend/data.js b/frontend/data.js index 83939f0..90ce222 100644 --- a/frontend/data.js +++ b/frontend/data.js @@ -113,6 +113,16 @@ export function currentCategoryMeta() { return metadata[els.category.value]; } +// True when switching between these two seasons changes the category's default +// threshold — an ongoing season (DYNAMIC_THRESHOLDS) against a finalized one +// (DEFAULT_THRESHOLDS), in either direction. Two finalized seasons share one +// static default, so the user's value stays. Reads the default each season +// carries in /api/metadata, so it follows whatever config.py resolved. +export function seasonSwitchChangesThresholdDefault(fromSeason, toSeason) { + const defaults = currentCategoryMeta().default_thresholds; + return defaults[fromSeason] !== defaults[toSeason]; +} + // True for whatever season is still in progress this calendar year, false // for every finalized/historical one — same Calendar Year comparison // config.py's resolve_default_threshold() routes the static-vs-dynamic diff --git a/frontend/filters.js b/frontend/filters.js index 722e9be..1ba42cd 100644 --- a/frontend/filters.js +++ b/frontend/filters.js @@ -777,7 +777,7 @@ export async function updatePlayerPool() { // reassigns .max/.value, checked in priority order below: // // 1. resetThresholdOnNextRange is still true — a Category switch or an -// ongoing→historical Season switch is still pending. A Category switch +// ongoing↔historical Season switch is still pending. A Category switch // already ran resetThresholdToCategoryDefault() synchronously (a rough // placeholder — it can only grow .max just far enough to fit the new // default off whatever pending data was already on hand, not this @@ -873,7 +873,7 @@ export function updateThresholdRange() { resetThresholdOnNextRange = false; } else if (Number(els.threshold.value) > max) { // Every other Season/Position change (Position always; Season only - // when it isn't an ongoing→historical transition — main.js's season + // when it isn't an ongoing↔historical transition — main.js's season // "change" handler already forces resetThresholdOnNextRange for that // case via resetThresholdToCategoryDefault(), so this branch won't even // run for it) — keep the user's value if it still fits; otherwise fall diff --git a/frontend/index.html b/frontend/index.html index 73ef5d3..e5e8bdc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - - - - -LinesShines · 鋒光 - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- -
-

LinesShines · 鋒光

-

🏈NFL OL & DL PFF Premium Stats Interactive Exploration

- -
-
-
- -

- Source: PFF Premium Stats -   ·  Downloaded PFF data stays private -   ·  Median lines are calculated according to current filter -

- -
- -
- -
- -
-
-
- - + (function () { + var PHONE_MAX_SHORT_EDGE = 600; + var DESKTOP_LAYOUT_WIDTH = 1024; + var shortEdge = Math.min(screen.width, screen.height); + if (shortEdge > PHONE_MAX_SHORT_EDGE) return; // tablet/desktop: leave width=device-width alone + var scale = screen.width / DESKTOP_LAYOUT_WIDTH; + var viewportMeta = document.querySelector('meta[name="viewport"]'); + viewportMeta.setAttribute( + "content", + "width=" + + DESKTOP_LAYOUT_WIDTH + + ", initial-scale=" + + scale + + ", minimum-scale=" + + scale + + ", maximum-scale=5" + ); + })(); + + LinesShines · 鋒光 + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+

LinesShines · 鋒光

+

🏈NFL OL & DL PFF Premium Stats Interactive Exploration

+ +
+
+
+ +

+ Source: PFF Premium Stats +   ·  Downloaded PFF data stays private +   ·  Median lines are calculated according to current filter +

+ +
+
+
-
-
- - -
-
- -
- - - -
+ + + + - - - -