diff --git a/CLAUDE.md b/CLAUDE.md index 1da9d0a..7d416e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,9 @@ Before any task, read `playbook/INVARIANTS.md`. It holds the working rules (git, PFF data, frontend limits, versioning) and the invariants that must stay true. Then use the table below to find the detail for your task. +Read it again in full at the start of every new session and after every context compaction, before editing any file: a summary +of an earlier conversation is not a substitute for the rules, and a rule recalled from memory may be incomplete. + To find a work item or an architecture file, do not open whole folders. First read the top of each file (the title and one-line summary, or the "Load when" line), then open only the ones that apply. The commands are in `playbook/ROSTER.md`. diff --git a/README.md b/README.md index b5f63cd..eac85ed 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-7-gold) +![Week](https://img.shields.io/badge/Week-8-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 c697e34..d64f997 100644 --- a/config.py +++ b/config.py @@ -20,6 +20,13 @@ class League(str, Enum): NCAAF = "ncaaf" +class CareerStatus(str, Enum): + """Who is on the college Career page. The value is what the table stores and the page shows.""" + + JOINED_NFL = "Joined NFL" + PROSPECTS = "Prospects" + + LEAGUE_DATA_PATHS: dict[League, str] = { League.NFL: NFL_DATA_PATH, League.NCAAF: NCAAF_DATA_PATH, @@ -39,9 +46,9 @@ class League(str, Enum): "Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps." ) -PRP_NOTE = "PRP = (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Rush Snaps x 100." +PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities 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." +PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100." # Shared across every "TPS "-prefixed metric — not a per-metric formula, so # it lives once here rather than duplicated into a TPS_*_NOTE per metric. @@ -80,8 +87,41 @@ class League(str, Enum): } +# NCAAF thresholds. The NFL values do not fit: the NFL's 230 pass rush opportunities +# lets only 12 to 17% of the college ED and DI players through (about 36% in the NFL), +# so the college value is about 140; the NFL's 300 pass block snaps already fits the OL. +# Starting values, confirmed by trying the real page; change them if they feel wrong. +NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = { + "pass_rush": 230, # Min PR Opp for pass rush filter. + "pass_block": 360, # Min Non Spike PB Snaps for pass block filter. +} + +# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder +# of about 2/3 of the static values (a college season has about 12 games). +NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = { + 2026: { + "pass_rush": 120, # Min PR Opp for pass rush filter. + "pass_block": 205, # Min Non Spike PB Snaps for pass block filter. + }, +} + +LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = { + League.NFL: DEFAULT_THRESHOLDS, + League.NCAAF: NCAAF_DEFAULT_THRESHOLDS, +} + +LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = { + League.NFL: DYNAMIC_THRESHOLDS, + League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS, +} + + def resolve_default_threshold( - category: str, season: int, *, today: date | None = None + category: str, + season: int, + league: League = League.NFL, + *, + today: date | None = None ) -> int: """Static preset for a finalized season, dynamic preset for the season currently in progress. Routing is a Calendar Year comparison @@ -92,8 +132,8 @@ def resolve_default_threshold( current_year = (today or date.today()).year if season == current_year: - dynamic = DYNAMIC_THRESHOLDS.get(season, {}) + dynamic = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {}) if category in dynamic: return dynamic[category] - return DEFAULT_THRESHOLDS[category] + return LEAGUE_DEFAULT_THRESHOLDS[league][category] diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..939e395 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,71 @@ +"""All configurations, split by what they describe. + +- `leagues`: the leagues, who is on the college Career page, and the data paths. +- `metrics`: position names, rounding, and the notes that explain each metric. +- `thresholds`: the default thresholds per league and the rule that picks one. + +Every name is re-exported here, so code writes `from config import League` and does +not care which file defines it. +""" + +from config.leagues import ( + BASE_PATH, + DATA_FOLDER_PATH, + LEAGUE_DATA_PATHS, + NCAAF_DATA_PATH, + NFL_DATA_PATH, + CareerStatus, + League, +) +from config.metrics import ( + ALLOWED_HAVOC_RATE_NOTE, + DISPLAY_DECIMALS, + FRONT_7_NAMES, + HAVOC_RATE_NOTE, + OL_NAMES, + PASS_COV_PERCENT_NOTE, + PBE_NOTE, + PRP_NOTE, + ROUNDING_DECIMALS, + TPS_NOTE, + TPS_PROPORTION_NOTE, +) +from config.thresholds import ( + DEFAULT_THRESHOLDS, + DYNAMIC_THRESHOLDS, + LEAGUE_DEFAULT_THRESHOLDS, + LEAGUE_DYNAMIC_THRESHOLDS, + NCAAF_CAREER_DEFAULT_THRESHOLDS, + NCAAF_DEFAULT_THRESHOLDS, + NCAAF_DYNAMIC_THRESHOLDS, + resolve_default_threshold, +) + +__all__ = [ + "ALLOWED_HAVOC_RATE_NOTE", + "BASE_PATH", + "DATA_FOLDER_PATH", + "DEFAULT_THRESHOLDS", + "DISPLAY_DECIMALS", + "DYNAMIC_THRESHOLDS", + "FRONT_7_NAMES", + "HAVOC_RATE_NOTE", + "LEAGUE_DATA_PATHS", + "LEAGUE_DEFAULT_THRESHOLDS", + "LEAGUE_DYNAMIC_THRESHOLDS", + "NCAAF_CAREER_DEFAULT_THRESHOLDS", + "NCAAF_DATA_PATH", + "NCAAF_DEFAULT_THRESHOLDS", + "NCAAF_DYNAMIC_THRESHOLDS", + "NFL_DATA_PATH", + "OL_NAMES", + "PASS_COV_PERCENT_NOTE", + "PBE_NOTE", + "PRP_NOTE", + "ROUNDING_DECIMALS", + "TPS_NOTE", + "TPS_PROPORTION_NOTE", + "CareerStatus", + "League", + "resolve_default_threshold", +] diff --git a/config/leagues.py b/config/leagues.py new file mode 100644 index 0000000..c5a6d50 --- /dev/null +++ b/config/leagues.py @@ -0,0 +1,34 @@ +"""The leagues, who is on the college Career page, and where the data lives.""" + +import os +from enum import Enum + +# Repo root: this file sits one folder below it, in config/. +BASE_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_FOLDER_PATH = os.path.join(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 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" + + +class CareerStatus(str, Enum): + """Who is on the college Career page. The value is what the table stores and the page shows.""" + + JOINED_NFL = "Joined NFL" + PROSPECTS = "Prospects" + + +LEAGUE_DATA_PATHS: dict[League, str] = { + League.NFL: NFL_DATA_PATH, + League.NCAAF: NCAAF_DATA_PATH, +} diff --git a/config/metrics.py b/config/metrics.py new file mode 100644 index 0000000..21d0854 --- /dev/null +++ b/config/metrics.py @@ -0,0 +1,29 @@ +"""Position names, rounding, and the notes that explain each metric.""" + +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"} + + +HAVOC_RATE_NOTE = "Havoc Rate = (Sacks + QB Hits) / Pass Rush Opportunities." +ALLOWED_HAVOC_RATE_NOTE = ( + "Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps." +) + +PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities 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." +TPS_PROPORTION_NOTE = "TPS Proportion % = TPS Non Spike Pass Block Snaps / Non Spike Pass Block Snaps x 100." +PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100." + +# Shared across every "TPS "-prefixed metric — not a per-metric formula, so +# it lives once here rather than duplicated into a TPS_*_NOTE per metric. +# The frontend appends this to whatever a TPS metric's own note/formula +# already says, keyed off the "TPS " prefix rather than a per-metric flag +# (see main.py's /api/metadata: exposed once at the top level, not per metric). +TPS_NOTE = ( + "True Pass Set: excludes plays with less than 4 rushers, play action, " + "screens, short dropbacks and time-to-throws under 2 seconds." +) diff --git a/config/thresholds.py b/config/thresholds.py new file mode 100644 index 0000000..5eb8c89 --- /dev/null +++ b/config/thresholds.py @@ -0,0 +1,97 @@ +"""The default thresholds per league, and the rule that picks one for a season.""" + +from datetime import date +from config.leagues import CareerStatus, League + +# Default "historical seasons'" thresholds applied on page load. +DEFAULT_THRESHOLDS: dict[str, int] = { + "pass_rush": 230, # Min PR Opp for pass rush filter. + "pass_block": 300, # Min Non Spike PB Snaps for pass block filter. +} + +# Dynamic threshold for the current season in progress. +# Set thresholds BY HAND each week during data ingestion. +# Eyeballed against how far the season has actually progressed. + +# Deliberately NOT derived from a fraction-of-season-elapsed formula. +# Because season pacing is not uniform. But — the final end is quite stable throughout years. +# Hereby, we use static thresholds for already ended seasons. + +# Add/edit current year's entry weekly; once a season ends, leave it in place. +# With a season's end, resolve_default_threshold() falls back to static +# DEFAULT_THRESHOLDS automatically after entering next following calendar year. +# Key, which means current season, must be "renamed" when a new season comes. +DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = { + 2026: { + "pass_rush": 95, # Min PR Opp for pass rush filter. + "pass_block": 105, # Min Non Spike PB Snaps for pass block filter. + }, +} + + +# NCAAF thresholds, their own because the NFL values left too many or too few players on +# the college chart: the front 7 uses the same 230 as the NFL, the OL a stricter 360. +# Set by the owner after trying the real page; change them if they feel wrong. +NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = { + "pass_rush": 230, # Min PR Opp for pass rush filter. + "pass_block": 360, # Min Non Spike PB Snaps for pass block filter. +} + +# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder +# of about 2/3 of the static values (a college season has about 12 games). +NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = { + 2026: { + "pass_rush": 120, # Min PR Opp for pass rush filter. + "pass_block": 205, # Min Non Spike PB Snaps for pass block filter. + }, +} + +# The college Career page has no season, so no dynamic entry, and two thresholds per +# category: a Joined NFL player must have played more college snaps than a Prospect, +# whose few snaps (a young player) still make a projection worth showing. The +# numbers count the merged career (summed PR Opp, summed Non Spike PB Snaps). The owner +# tunes them by running the page locally; the front 7 pair was set that way, the OL pair +# is still a starting point. +NCAAF_CAREER_DEFAULT_THRESHOLDS: dict[str, dict[str, int]] = { + "pass_rush": { + CareerStatus.JOINED_NFL.value: 400, + CareerStatus.PROSPECTS.value: 300, + }, + "pass_block": { + CareerStatus.JOINED_NFL.value: 650, + CareerStatus.PROSPECTS.value: 500, + }, +} + +LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = { + League.NFL: DEFAULT_THRESHOLDS, + League.NCAAF: NCAAF_DEFAULT_THRESHOLDS, +} + +LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = { + League.NFL: DYNAMIC_THRESHOLDS, + League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS, +} + + +def resolve_default_threshold( + category: str, + season: int, + league: League = League.NFL, + *, + today: date | None = None +) -> int: + """Static preset for a finalized season, dynamic preset for the season + currently in progress. Routing is a Calendar Year comparison + (season_year vs. today's year), not schedule-aware. + Regular season is ~90% complete before New Year's Day, so this is an accepted + near-100%-accurate approximation. No need to be perfectly precise. + """ + current_year = (today or date.today()).year + + if season == current_year: + dynamic = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {}) + if category in dynamic: + return dynamic[category] + + return LEAGUE_DEFAULT_THRESHOLDS[league][category] diff --git a/database/career.py b/database/career.py new file mode 100644 index 0000000..6e83ec6 --- /dev/null +++ b/database/career.py @@ -0,0 +1,127 @@ +"""The college Career page's members, rebuilt from the season tables. + +Career Stats lists only two kinds of college player, and the two never overlap: + +- Joined NFL: the `player_id` also occurs in the NFL table of the same category. + A college player keeps his PFF id in the NFL files, so the id is the link. +- Prospects: the player has a row in the latest college season and is + not Joined NFL. + +The membership is precomputed into one small table per category, so the website +never runs the join. It holds no stat and no percentile: the Career row of each +member is merged from the season tables at request time, and the browser ranks. + +Rebuilt whole (delete, then insert) at the end of every `--league ncaaf` ingestion, +never by the NFL run, so a member added by the NFL tables since the last college +run shows up only at the next college run. Run the NFL ingestion first when both +run on the same day. +""" + +from __future__ import annotations +from dataclasses import dataclass +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session +from config import CareerStatus +from database.db_models import ( + NCAAFPassBlockCareerMember, + NCAAFPassBlockStat, + NCAAFPassRushCareerMember, + NCAAFPassRushStat, + NFLPassBlockStat, + NFLPassRushStat, +) + + +@dataclass(frozen=True) +class _CareerTables: + """The three tables one category's membership is built from and written to.""" + + label: str # Wording of the printed line. + college_model: type + nfl_model: type + member_model: type + + +CAREER_TABLES = ( + _CareerTables( + "pass rush", + NCAAFPassRushStat, + NFLPassRushStat, + NCAAFPassRushCareerMember, + ), + _CareerTables( + "pass block", + NCAAFPassBlockStat, + NFLPassBlockStat, + NCAAFPassBlockCareerMember, + ), +) + + +def rebuild_career_members(sess: Session, tables: _CareerTables) -> dict[str, int]: + """Replace one category's members and return how many there are per status.""" + college_model = tables.college_model + + joined_ids = set( + sess.scalars( + select(college_model.player_id) + .where(college_model.player_id.in_(select(tables.nfl_model.player_id))) + .distinct() + ) + ) + + # The latest season comes from the table itself, never from the seasons just + # ingested, so back-filling an old season cannot change who is a Prospect. + latest_season = sess.scalar(select(func.max(college_model.season))) + latest_ids = set() + + if latest_season is not None: + latest_ids = set( + sess.scalars( + select(college_model.player_id) + .where(college_model.season == latest_season) + .distinct() + ) + ) + + prospect_ids = latest_ids - joined_ids + + sess.execute(delete(tables.member_model)) + sess.add_all( + [ + tables.member_model( + player_id=player_id, status=CareerStatus.JOINED_NFL.value + ) + for player_id in sorted(joined_ids) + ] + ) + sess.add_all( + [ + tables.member_model( + player_id=player_id, status=CareerStatus.PROSPECTS.value + ) + for player_id in sorted(prospect_ids) + ] + ) + sess.commit() + + return { + CareerStatus.JOINED_NFL.value: len(joined_ids), + CareerStatus.PROSPECTS.value: len(prospect_ids), + } + + +def rebuild_all_career_members(sess: Session) -> None: + """Rebuild the members of every category and print what each now holds.""" + for tables in CAREER_TABLES: + counts = rebuild_career_members(sess, tables) + summary = ", ".join(f"{count} {status}" for status, count in counts.items()) + print(f" career members, {tables.label}: {summary}") + + nfl_row_count = sess.scalar(select(func.count()).select_from(tables.nfl_model)) + + if nfl_row_count == 0: + print( + f" Warning: the NFL {tables.label} table is empty, so nobody can be " + "Joined NFL. Run the NFL ingestion first." + ) diff --git a/database/career_merge.py b/database/career_merge.py new file mode 100644 index 0000000..3399fe9 --- /dev/null +++ b/database/career_merge.py @@ -0,0 +1,239 @@ +"""One Career row per member, merged from the college season tables. + +Career Stats pools a player's seasons into a single row, so there is no season to +rank in and no percentile stored: the browser ranks the merged rows. The merge is +a `GROUP BY player_id` over every season the player has in the category's table. + +- Games and the opportunity or snap counts are summed. +- A rate is a weighted average over its own denominator (PR Opp for the front 7, + Non Spike PB Snaps for the OL; the TPS versions use the TPS counts). The tables + hold rates, not counts, so `SUM(rate * weight) / SUM(weight)` is the rate of the + pooled counts, within the rounding of the stored rates. +- Pass Cov Percent cannot be weighted by PR Opp, because PR Opp is not Pass Rush + Snaps. It is rebuilt from the four snap counts the college pass-rush table keeps: + 100 - SUM(pass rush snaps) / SUM(pass play snaps) x 100. + +The name, position, team and conference are those of the player's last college season. +The columns come from `database/columns.py`, so a metric added there is merged too. +""" + +from __future__ import annotations +from dataclasses import dataclass +from sqlalchemy import and_, case, func, select +from sqlalchemy.orm import Session +from config import ROUNDING_DECIMALS +from database.columns import ( + PASS_BLOCK_DATA_COLUMNS, + PASS_RUSH_DATA_COLUMNS, + DataColumn, + with_tps_proportion, +) +from database.db_models import ( + NCAAFPassBlockCareerMember, + NCAAFPassBlockStat, + NCAAFPassRushCareerMember, + NCAAFPassRushStat, +) + + +@dataclass(frozen=True) +class CareerSpec: + """What one category's Career merge reads, and how each column is combined.""" + + college_model: type + member_model: type + data_columns: tuple[DataColumn, ...] + weight_attr: str # The denominator of the rates. + tps_weight_attr: str # The denominator of the TPS rates. + sum_attrs: tuple[str, ...] # Columns that are plainly summed. + order_attr: str # The count a record is ordered by, descending. + # (column attr, rush snaps attr, pass play snaps attr) of each Pass Cov Percent column. + complement_snaps: tuple[tuple[str, str, str], ...] = () + # Whether the record gets TPS Proportion % (the OL only), from its summed snap counts. + adds_tps_proportion: bool = False + + +PASS_RUSH_CAREER = CareerSpec( + college_model=NCAAFPassRushStat, + member_model=NCAAFPassRushCareerMember, + data_columns=PASS_RUSH_DATA_COLUMNS, + weight_attr="pr_opp", + tps_weight_attr="tps_pr_opp", + sum_attrs=("games", "pr_opp", "tps_pr_opp"), + order_attr="pr_opp", + complement_snaps=( + ("pass_cov_percent", "pass_rush_snaps", "pass_play_snaps"), + ("tps_pass_cov_percent", "tps_pass_rush_snaps", "tps_pass_play_snaps"), + ), +) + +PASS_BLOCK_CAREER = CareerSpec( + college_model=NCAAFPassBlockStat, + member_model=NCAAFPassBlockCareerMember, + data_columns=PASS_BLOCK_DATA_COLUMNS, + weight_attr="non_spike_pb_snaps", + tps_weight_attr="tps_non_spike_pb_snaps", + sum_attrs=("games", "non_spike_pb_snaps", "tps_non_spike_pb_snaps"), + order_attr="non_spike_pb_snaps", + adds_tps_proportion=True, +) + + +def _aggregate_expressions(spec: CareerSpec) -> dict: + """The SQL expression that merges each data column, keyed by its API key.""" + model = spec.college_model + snaps_of = {attr: (rush, play) for attr, rush, play in spec.complement_snaps} + expressions = dict() + + for column in spec.data_columns: + attr = getattr(model, column.attr) + + if column.attr in spec.sum_attrs: + expressions[column.api_key] = func.sum(attr) + + elif column.attr in snaps_of: + rush_attr, play_attr = snaps_of[column.attr] + rush_snaps = func.sum(getattr(model, rush_attr)) + play_snaps = func.sum(getattr(model, play_attr)) + expressions[column.api_key] = 100.0 - 100.0 * rush_snaps / func.nullif( + play_snaps, 0 + ) + + else: + weight_name = ( + spec.tps_weight_attr + if column.attr.startswith("tps_") + else spec.weight_attr + ) + + weight = getattr(model, weight_name) + + # A season with no opportunities can hold an infinite rate (a pressure on a zero + # denominator). Infinity times zero is NaN on Postgres, so such seasons are skipped. + weighted_rate = case((weight > 0, attr * weight), else_=0) + expressions[column.api_key] = func.sum(weighted_rate) / func.nullif( + func.sum(weight), 0 + ) + + return expressions + + +def _last_season_rows(sess: Session, spec: CareerSpec) -> dict: + """Each member's row of his last college season, with his status. + + A player can hold two rows in one season (two positions); the one with more + opportunities stands for him. + """ + model = spec.college_model + member_model = spec.member_model + + last_season = ( + select(model.player_id, func.max(model.season).label("last_season")) + .group_by(model.player_id) + .subquery() + ) + + rows = sess.execute( + select(model, member_model.status) + .join( + last_season, + and_( + model.player_id == last_season.c.player_id, + model.season == last_season.c.last_season, + ), + ) + .join(member_model, member_model.player_id == model.player_id) + ).all() + + chosen = dict() + + for row, status in rows: + current = chosen.get(row.player_id) + count = getattr(row, spec.order_attr) or 0 + + if current is None or count > (getattr(current[0], spec.order_attr) or 0): + chosen[row.player_id] = (row, status) + + return chosen + + +def last_season_conferences(sess: Session, spec: CareerSpec) -> dict[int, str]: + """The conference of each school a member last played for, for the Teams checklist.""" + return { + row.team_code: row.conference + for row, _status in _last_season_rows(sess, spec).values() + } + + +def merged_career_rows( + sess: Session, spec: CareerSpec, positions: tuple[str, ...] +) -> list[dict]: + """The Career payload of every member whose last college position is in `positions`. + + Records come ordered by the category's count, most first. A member with no row + left after the position filter (or none at all) is simply not listed. + """ + model = spec.college_model + expressions = _aggregate_expressions(spec) + api_keys = list(expressions) + sum_api_keys = { + column.api_key for column in spec.data_columns if column.attr in spec.sum_attrs + } + + aggregate_rows = sess.execute( + select( + model.player_id, + func.min(model.season), + func.max(model.season), + func.count(func.distinct(model.season)), + *expressions.values(), + ) + .join(spec.member_model, spec.member_model.player_id == model.player_id) + .group_by(model.player_id) + ).all() + + last_rows = _last_season_rows(sess, spec) + records = [] + + for player_id, first_season, last_season, season_count, *values in aggregate_rows: + last_row, status = last_rows[player_id] + + if last_row.position not in positions: + continue + + record = { + "id": player_id, + "player_id": player_id, + "player": last_row.player, + "abbr_name": last_row.abbr_name, + "position": last_row.position, + "team": last_row.team_code, + "conference": last_row.conference, + "status": status, + "first_season": first_season, + "last_season": last_season, + "seasons": season_count, + } + + for api_key, value in zip(api_keys, values): + if value is None: + record[api_key] = None + + elif api_key in sum_api_keys: + record[api_key] = int(value) + + else: + record[api_key] = round(float(value), ROUNDING_DECIMALS) + + if spec.adds_tps_proportion: + # A ratio of the summed counts, never the mean of the seasons' ratios. + with_tps_proportion(record) + + records.append(record) + + order_key = next( + column.api_key for column in spec.data_columns if column.attr == spec.order_attr + ) + records.sort(key=lambda record: (-(record[order_key] or 0), record["player_id"])) + + return records diff --git a/database/columns.py b/database/columns.py index d19c6dd..5487b21 100644 --- a/database/columns.py +++ b/database/columns.py @@ -77,6 +77,46 @@ class DataColumn: ) +# The four snap counts behind Pass Cov Percent, kept only for the college Career merge +# (Career Pass Cov Percent = 100 - the sum of the rush snaps over the sum of the pass plays, +# which the stored percentage alone cannot rebuild). They are never part of the /api payload, +# so they are not in PASS_RUSH_DATA_COLUMNS; the ingestion reads them as extra columns. +NCAAF_PASS_RUSH_SNAP_COLUMNS = ( + DataColumn("PR Snaps", "pass_rush_snaps", "PR Snaps", "int"), + DataColumn("Snap Counts Pass Play", "pass_play_snaps", "Pass Play Snaps", "int"), + DataColumn("TPS PR Snaps", "tps_pass_rush_snaps", "TPS PR Snaps", "int"), + DataColumn( + "TPS Snap Counts Pass Play", + "tps_pass_play_snaps", + "TPS Pass Play Snaps", + "int", + ), +) + + +# The OL-only share of pass block snaps that were true pass sets. It is never stored: the API +# computes it from the two snap counts, so the season and Career payloads stay in step. +TPS_PROPORTION_API_KEY = "TPS Proportion %" +TPS_PROPORTION_DECIMALS = 1 + + +def tps_proportion_pct(tps_snaps: int | None, snaps: int | None) -> float | None: + """TPS Non Spike PB Snaps / Non Spike PB Snaps x 100, or None when it is undefined.""" + if tps_snaps is None or not snaps: + return None + + return round(100.0 * tps_snaps / snaps, TPS_PROPORTION_DECIMALS) + + +def with_tps_proportion(payload: dict) -> dict: + """The pass block payload with its TPS Proportion % added, from its two snap counts.""" + payload[TPS_PROPORTION_API_KEY] = tps_proportion_pct( + payload.get("TPS Non Spike PB Snaps"), payload.get("Non Spike PB Snaps") + ) + + return payload + + def required_excel_headers(data_columns: tuple[DataColumn, ...]) -> tuple[str, ...]: """The headers a sheet must have: the identity columns plus every data column.""" return IDENTITY_EXCEL_HEADERS + tuple( diff --git a/database/db_ingestion.py b/database/db_ingestion.py index ad0dfff..7722804 100644 --- a/database/db_ingestion.py +++ b/database/db_ingestion.py @@ -1,10 +1,14 @@ """Ingest preprocessed PFF Excel workbooks into database. -Reads `nfl/front_7_pass_rush/{season}.xlsx` and -`nfl/ol_pass_block/{season}.xlsx` files (under the data root) that +Reads `{league}/front_7_pass_rush/{season}.xlsx` and +`{league}/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 the `nfl_pass_rush_stats` / -`nfl_pass_block_stats` tables. +produce, and bulk-inserts them into that league's tables: `nfl_pass_rush_stats` / +`nfl_pass_block_stats` or `ncaaf_pass_rush_stats` / `ncaaf_pass_block_stats`. +The league is always named (`--league`), so the wrong one is never loaded by accident. + +The college run (`--league ncaaf`) also rebuilds the Career page's member tables +(`database/career.py`) after loading, so run the NFL ingestion first when both run. 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. @@ -16,17 +20,18 @@ Usage: 1. Must always go from project root. 2. After running two preprocessing scripts. -3. python -m database.db_ingestion +3. python -m database.db_ingestion --league nfl 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/nfl/. +locate xlsx files under $LINESHINES_REPO_ROOT/data/{nfl,ncaaf}/. """ from __future__ import annotations import argparse import os from dataclasses import dataclass +from typing import Callable import re import sys from datetime import date @@ -34,22 +39,31 @@ import pandas as pd from sqlalchemy import delete from sqlalchemy.orm import Session -from config import DYNAMIC_THRESHOLDS +from config import LEAGUE_DYNAMIC_THRESHOLDS, League +from database.career import rebuild_all_career_members from database.columns import ( + NCAAF_PASS_RUSH_SNAP_COLUMNS, PASS_BLOCK_DATA_COLUMNS, PASS_RUSH_DATA_COLUMNS, required_excel_headers, ) -from database.db_models import Base, NFLPassBlockStat, NFLPassRushStat, NFLTeam +from database.db_models import ( + Base, + NCAAFPassBlockStat, + NCAAFPassRushStat, + NCAAFTeam, + NFLPassBlockStat, + NFLPassRushStat, + NFLTeam, +) from database.session import engine, SessionLocal +from teams.ncaaf import SCHOOL_NAMES 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" - +# The data root holds one folder per league (the League value): data/nfl/, data/ncaaf/. SEASON_CATEGORY_DIRS = ("front_7_pass_rush", "ol_pass_block") @@ -64,7 +78,7 @@ def _find_project_root() -> Path: def _repo_root() -> Path: - """The data root: the folder that holds `nfl/` (and later `ncaaf/`). + """The data root: the folder that holds `nfl/` and `ncaaf/`. Order of precedence: 1. `--data-dir` CLI flag. @@ -83,9 +97,9 @@ def _repo_root() -> Path: return _find_project_root() / "data" -def _discover_seasons(data_dir: Path) -> list[int]: +def _discover_seasons(data_dir: Path, league: League = League.NFL) -> list[int]: """Union of seasons with a `{season}.xlsx` file under either category - subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir/nfl`. + subdir (`front_7_pass_rush`, `ol_pass_block`) of `data_dir/{league}`. 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. @@ -98,16 +112,13 @@ def _discover_seasons(data_dir: Path) -> list[int]: seasons = set() for category_dir in SEASON_CATEGORY_DIRS: - for path in (data_dir / NFL_SUBDIR / category_dir).glob("*.xlsx"): + for path in (data_dir / league.value / category_dir).glob("*.xlsx"): if re.fullmatch(r"\d{4}", path.stem): seasons.add(int(path.stem)) return list(seasons) -DEFAULT_SEASONS = _discover_seasons(_repo_root()) - - def _safe_int(val): return None if pd.isna(val) else int(val) @@ -137,7 +148,7 @@ def _require_columns(df: pd.DataFrame, columns, path, sheet) -> None: raise ValueError(f"{path} sheet {sheet} is missing columns: {missing}") -def upsert_teams(sess: Session) -> None: +def upsert_nfl_teams(sess: Session) -> None: existing = {t.code for t in sess.query(NFLTeam).all()} for code, (full_name, color) in TEAMS.items(): @@ -152,6 +163,20 @@ def upsert_teams(sess: Session) -> None: sess.commit() +def upsert_ncaaf_teams(sess: Session) -> None: + """One `ncaaf_teams` row per scoped school; colours are left as they are (unset).""" + existing = {team.franchise_id for team in sess.query(NCAAFTeam).all()} + + for franchise_id, full_name in SCHOOL_NAMES.items(): + if franchise_id in existing: + sess.get(NCAAFTeam, franchise_id).full_name = full_name + + else: + sess.add(NCAAFTeam(franchise_id=franchise_id, full_name=full_name)) + + sess.commit() + + def _read_value(kind: str, value): """Read one Excel cell the way its column's kind says.""" if kind == "int": @@ -170,6 +195,7 @@ def _read_value(kind: str, value): class _Category: """Everything the ingestion needs to load one category into its table.""" + league: League model: type folder: str positions: tuple[str, ...] @@ -177,9 +203,16 @@ class _Category: snap_header: str # The column a row must have a value in (dropna subset). skip_label: str # Wording of the "file not found" line. row_label: str # Wording of the per-position "N rows" line. + team_header: str = "Team" # The column that holds the team key. + team_code_type: Callable = str # The NFL key is a code, the college key an id. + extra_columns: tuple[tuple[str, str], ...] = () # (model attribute, Excel header). + hidden_columns: ( + tuple + ) = () # Read like data columns, but not part of the API payload. _PASS_RUSH = _Category( + league=League.NFL, model=NFLPassRushStat, folder="front_7_pass_rush", positions=FRONT_7_POSITIONS, @@ -190,6 +223,7 @@ class _Category: ) _PASS_BLOCK = _Category( + league=League.NFL, model=NFLPassBlockStat, folder="ol_pass_block", positions=OL_POSITIONS, @@ -199,20 +233,65 @@ class _Category: row_label="pass_block", ) +# The college rows key the team by franchise_id and carry the conference attached at conversion. +_NCAAF_PASS_RUSH = _Category( + league=League.NCAAF, + model=NCAAFPassRushStat, + folder="front_7_pass_rush", + positions=FRONT_7_POSITIONS, + data_columns=PASS_RUSH_DATA_COLUMNS, + snap_header="PR Opp", + skip_label="ncaaf pass rush", + row_label="ncaaf_pass_rush", + team_header="Franchise Id", + team_code_type=int, + extra_columns=(("conference", "Conference"),), + hidden_columns=NCAAF_PASS_RUSH_SNAP_COLUMNS, +) + +_NCAAF_PASS_BLOCK = _Category( + league=League.NCAAF, + model=NCAAFPassBlockStat, + folder="ol_pass_block", + positions=OL_POSITIONS, + data_columns=PASS_BLOCK_DATA_COLUMNS, + snap_header="Non Spike PB Snaps", + skip_label="ncaaf pass block", + row_label="ncaaf_pass_block", + team_header="Franchise Id", + team_code_type=int, + extra_columns=(("conference", "Conference"),), +) + +LEAGUE_CATEGORIES: dict[League, tuple[_Category, ...]] = { + League.NFL: (_PASS_RUSH, _PASS_BLOCK), + League.NCAAF: (_NCAAF_PASS_RUSH, _NCAAF_PASS_BLOCK), +} + +LEAGUE_TEAM_UPSERTS: dict[League, Callable[[Session], None]] = { + League.NFL: upsert_nfl_teams, + League.NCAAF: upsert_ncaaf_teams, +} + def ingest_category( sess: Session, data_dir: Path, seasons: list[int], category: _Category ) -> int: """Load one category's seasons into its table, replacing each slice first. - The body is shared by the two categories (and later the two leagues); the - column reads come from the declaration in `database/columns.py`. + The body is shared by the categories of both leagues; the column reads come + from the declaration in `database/columns.py`. """ - required = required_excel_headers(category.data_columns) + required = required_excel_headers( + category.data_columns + category.hidden_columns + ) + ( + category.team_header, + *(header for _, header in category.extra_columns), + ) total = 0 for season in seasons: - path = data_dir / NFL_SUBDIR / category.folder / f"{season}.xlsx" + path = data_dir / category.league.value / category.folder / f"{season}.xlsx" if not path.exists(): print(f"Skip {category.skip_label} {season}: {path} not found") @@ -240,16 +319,19 @@ def ingest_category( values = { "season": season, "position": position, - "team_code": row["Team"], + "team_code": category.team_code_type(row[category.team_header]), "player_id": _player_id(row, path, position), "player": row["Player"], "abbr_name": row["Abbr Name"], } - for column in category.data_columns: + for column in category.data_columns + category.hidden_columns: values[column.attr] = _read_value( column.kind, row.get(column.excel_header) ) + for attr, header in category.extra_columns: + values[attr] = row[header] + rows.append(category.model(**values)) sess.add_all(rows) @@ -268,15 +350,27 @@ def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: return ingest_category(sess, data_dir, seasons, _PASS_BLOCK) +def ingest_ncaaf_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: + return ingest_category(sess, data_dir, seasons, _NCAAF_PASS_RUSH) + + +def ingest_ncaaf_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: + return ingest_category(sess, data_dir, seasons, _NCAAF_PASS_BLOCK) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--league", required=True, choices=[league.value for league in League] + ) + parser.add_argument( "--seasons", type=int, nargs="+", - default=DEFAULT_SEASONS, - help=f"Seasons to ingest (default: {DEFAULT_SEASONS})", + default=None, + help="Seasons to ingest (default: every season with an xlsx file for the league).", ) parser.add_argument( @@ -297,27 +391,38 @@ def main() -> None: ) sys.exit(1) - print(f"Reading xlsx from: {data_dir}") + league = League(args.league) + seasons = args.seasons or _discover_seasons(data_dir, league) + print(f"Reading {league.value} xlsx from: {data_dir}") Base.metadata.create_all(engine) with SessionLocal() as sess: - upsert_teams(sess) - pass_rush_rows = ingest_pass_rush(sess, data_dir, args.seasons) - pass_block_rows = ingest_pass_block(sess, data_dir, args.seasons) + LEAGUE_TEAM_UPSERTS[league](sess) + pass_rush_category, pass_block_category = LEAGUE_CATEGORIES[league] + pass_rush_rows = ingest_category(sess, data_dir, seasons, pass_rush_category) + pass_block_rows = ingest_category(sess, data_dir, seasons, pass_block_category) + + # The Career members read both leagues' tables, so only the college run rebuilds them. + if league is League.NCAAF: + rebuild_all_career_members(sess) print( - f"\nBulk-inserted {pass_rush_rows} pass-rush rows and {pass_block_rows} pass-block rows." + f"\nBulk-inserted {pass_rush_rows} {league.value} pass-rush rows and " + f"{pass_block_rows} pass-block rows." ) # In-progress season's threshold is hand-eyeballed weekly, not - # auto-computed (see config.py's DYNAMIC_THRESHOLDS). + # auto-computed (see config/thresholds.py's DYNAMIC_THRESHOLDS and NCAAF_DYNAMIC_THRESHOLDS). # Nudge rather than silently falling back if this week's ingestion covers # the current season and nobody's touched it yet. current_year = date.today().year - if current_year in args.seasons and current_year not in DYNAMIC_THRESHOLDS: + if ( + current_year in seasons + and current_year not in LEAGUE_DYNAMIC_THRESHOLDS[league] + ): print( - f"Reminder: {current_year} has no entry in config.DYNAMIC_THRESHOLDS yet — " - "falling back to the static default thresholds until you set one." + f"Reminder: {current_year} has no {league.value} entry in the dynamic thresholds " + "of config/thresholds.py yet — falling back to the static default thresholds until you set one." ) diff --git a/database/db_models.py b/database/db_models.py index dd4a986..a4d0744 100644 --- a/database/db_models.py +++ b/database/db_models.py @@ -34,6 +34,7 @@ PASS_BLOCK_DATA_COLUMNS, PASS_RUSH_DATA_COLUMNS, row_to_payload, + with_tps_proportion, ) @@ -146,6 +147,82 @@ class NFLPassBlockStat(StatIdentityMixin, PassBlockMetricsMixin, Base): ) +class NCAAFTeam(Base): + __tablename__ = "ncaaf_teams" + + franchise_id = Column(Integer, primary_key=True) # PFF's stable school id. + full_name = Column(String, nullable=False) + primary_color = Column(String(7)) # Hex like "#0085CA"; may be unset for now. + + pass_rush_stats = relationship("NCAAFPassRushStat", back_populates="team") + pass_block_stats = relationship("NCAAFPassBlockStat", back_populates="team") + + +class NCAAFPassRushStat(StatIdentityMixin, PassRushMetricsMixin, Base): + __tablename__ = "ncaaf_pass_rush_stats" + + # College team key is franchise_id, not a short code. + team_code = Column(Integer, ForeignKey("ncaaf_teams.franchise_id"), nullable=False) + conference = Column(String, nullable=False) # Attached at conversion; in scope. + + # Behind Pass Cov Percent, for Career merge only; never in API payload. + pass_rush_snaps = Column(Integer) # PR Snaps. + pass_play_snaps = Column(Integer) # Snap Counts Pass Play. + tps_pass_rush_snaps = Column(Integer) # TPS PR Snaps. + tps_pass_play_snaps = Column(Integer) # TPS Snap Counts Pass Play. + + team = relationship("NCAAFTeam", back_populates="pass_rush_stats") + + __table_args__ = ( + UniqueConstraint( + "season", + "position", + "player_id", + name="uq_ncaaf_pr_season_position_player_id", + ), + Index("idx_ncaaf_pr_filter", "season", "position"), + Index("idx_ncaaf_pr_player_id", "player_id"), + ) + + +class NCAAFPassBlockStat(StatIdentityMixin, PassBlockMetricsMixin, Base): + __tablename__ = "ncaaf_pass_block_stats" + + team_code = Column(Integer, ForeignKey("ncaaf_teams.franchise_id"), nullable=False) + conference = Column(String, nullable=False) + + team = relationship("NCAAFTeam", back_populates="pass_block_stats") + + __table_args__ = ( + UniqueConstraint( + "season", + "position", + "player_id", + name="uq_ncaaf_pb_season_position_player_id", + ), + Index("idx_ncaaf_pb_filter", "season", "position"), + Index("idx_ncaaf_pb_player_id", "player_id"), + ) + + +class NCAAFPassRushCareerMember(Base): + """Who is on the college Career page for the front 7 (see `database/career.py`).""" + + __tablename__ = "ncaaf_pass_rush_career_members" + + player_id = Column(Integer, primary_key=True, autoincrement=False) + status = Column(String, nullable=False) # A `CareerStatus` value. + + +class NCAAFPassBlockCareerMember(Base): + """Who is on the college Career page for the OL (see `database/career.py`).""" + + __tablename__ = "ncaaf_pass_block_career_members" + + player_id = Column(Integer, primary_key=True, autoincrement=False) + status = Column(String, nullable=False) # A `CareerStatus` value. + + # Serialization helpers used by main.py to keep API payload shape # identical to what frontend already expects (space-and-mixed-case keys # preserved so no client-side renaming is needed). Both read the one @@ -157,4 +234,15 @@ def pass_rush_row_to_dict(row: NFLPassRushStat) -> dict: def pass_block_row_to_dict(row: NFLPassBlockStat) -> dict: - return row_to_payload(row, PASS_BLOCK_DATA_COLUMNS) + return with_tps_proportion(row_to_payload(row, PASS_BLOCK_DATA_COLUMNS)) + + +def ncaaf_pass_rush_row_to_dict(row: NCAAFPassRushStat) -> dict: + return {**row_to_payload(row, PASS_RUSH_DATA_COLUMNS), "conference": row.conference} + + +def ncaaf_pass_block_row_to_dict(row: NCAAFPassBlockStat) -> dict: + return { + **with_tps_proportion(row_to_payload(row, PASS_BLOCK_DATA_COLUMNS)), + "conference": row.conference, + } diff --git a/database/midseason_trades.py b/database/midseason_trades.py new file mode 100644 index 0000000..697ac28 --- /dev/null +++ b/database/midseason_trades.py @@ -0,0 +1,74 @@ +"""Point a traded player at his new team mid-season, between weekly ingests. + +The NFL trade deadline is always in early November, so the season is simply the +current calendar year and needs no argument. Like `database/db_ingestion.py`, +this updates whichever database `DATABASE_URL` points at (the local SQLite file +by default, or Railway Postgres when the env var is set), so export it first. + +Usage: + export DATABASE_URL=... # omit to update the local SQLite file + python -c "from database.midseason_trades import update_traded_player_team as move; move('Jonathan Allen', 'MIN')" + +Several at once, each an independent (player, team) pair: + python -c "from database.midseason_trades import update_traded_players as moves; moves(('Jonathan Allen', 'MIN'), ('Za Darius Smith', 'PHI'))" +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import update + +from database.db_models import NFLPassBlockStat, NFLPassRushStat +from database.session import SessionLocal +from teams.nfl import TEAMS + +# A player sits in exactly one category, so updating both touches only his rows. +_STAT_MODELS = (NFLPassRushStat, NFLPassBlockStat) + + +def update_traded_player_team(player_name: str, new_team: str) -> None: + """Set a player's team for the current season; print and change nothing if off. + + The season is the current calendar year. A wrong team code, a wrong player + name, or a season not ingested yet all end the same way: a message is printed + and no row is changed. + """ + season = date.today().year + + if new_team not in TEAMS: + print(f"{new_team!r} is not a team code; nothing updated.") + return + + with SessionLocal() as sess: + updated_rows = 0 + for model in _STAT_MODELS: + result = sess.execute( + update(model) + .where(model.season == season, model.player == player_name) + .values(team_code=new_team) + ) + updated_rows += result.rowcount + + if updated_rows == 0: + print(f"No {season} player named {player_name!r}; nothing updated.") + return + + sess.commit() + + full_name = TEAMS[new_team][0] + print( + f"Moved {player_name!r} to {new_team} ({full_name}) for {season}: " + f"{updated_rows} row(s) updated." + ) + + +def update_traded_players(*trades: tuple[str, str]) -> None: + """Apply several trades in one call, each an independent (player, team) pair. + + A bad entry (unknown team, or no such player this season) is reported and + skipped while the rest still run, the same continue-on-error habit as the + preprocessing command line. + """ + for player_name, new_team in trades: + update_traded_player_team(player_name, new_team) diff --git a/frontend/config.js b/frontend/config.js index f74cbbe..55a1bc7 100644 --- a/frontend/config.js +++ b/frontend/config.js @@ -55,7 +55,7 @@ export const MERGE_QUOTA = 8; export const LINEMATE_POSITIONS = { pass_block: ["T", "G", "C"], pass_rush: ["ED", "DI"] }; export const LINEMATE_SIZE_REMINDER_AT = { pass_block: 5, pass_rush: 7 }; -// Mirrors config.py's DISPLAY_DECIMALS — keep in sync. UI-display rounding +// Mirrors config/metrics.py's DISPLAY_DECIMALS — keep in sync. UI-display rounding // only: plots (render.js's xVals/yVals, drawn straight from record[key]) // stay full-precision, since rounding coordinates would visibly shift point // positions. This only governs formatValue()'s rendered stat-table text. diff --git a/frontend/data.js b/frontend/data.js index 768b80c..abd0e6b 100644 --- a/frontend/data.js +++ b/frontend/data.js @@ -10,10 +10,30 @@ * assignment, same order, same timing as the original app.js. */ import { els } from "./dom.js"; +import { thresholdSpec, meetsThreshold, JOINED_NFL, PROSPECTS } from "./pinned-impact.js"; -export const LOGO_PATH = (team) => `logos/nfl/${team}.png`; +// The page's league (NFL or NCAAF), filled into index.html by main.py: the API +// prefix, the logo folder, how the Teams checklist is grouped and how a team is +// named in text. The NFL values are the fallback if the page was served raw. +const NFL_LEAGUE = { + key: "nfl", + name: "NFL", + apiPrefix: "/api/nfl", + logoDir: "nfl", + teamGroups: "static", + teamLabel: "code", +}; +export const LEAGUE = window.LEAGUE || NFL_LEAGUE; -export let metadata = null; // /api/metadata payload +// The Career page pools every college season of a player into one row, so it has +// no season (its one "season" is the word below), a Status Filter instead, and +// two threshold bars, one per status. A Season page is the plain case. +export const IS_CAREER = LEAGUE.mode === "career"; +export const CAREER_SEASON = "Career"; + +export const LOGO_PATH = (team) => `logos/${LEAGUE.logoDir}/${team}.png`; + +export let metadata = null; // /api/{league}/metadata payload export const sliceCache = new Map(); // key = `${category}:${season}:${position}` → records[] export let currentRecords = []; // records for the current slice (all threshold values) export let currentFiltered = []; // records >= threshold (what the chart shows) @@ -47,12 +67,60 @@ export let playerPoolCategory = null; export let appliedFilters = null; // 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/nfl/*.png +// filter changes never wait on the browser to re-resolve/decode /logos/{league}/*.png // again — layout.images and the scout card both just point at these. export const logoCache = {}; export function setMetadata(m) { metadata = m; + + if (IS_CAREER) { + // Shape the Career payload like a Season one, so the controls stay shared: one + // "season" and one flat conference map. The Career statuses and their two + // default bars stay as the API sent them. + ["pass_rush", "pass_block"].forEach((key) => { + metadata[key].seasons = [CAREER_SEASON]; + }); + if (metadata.team_conferences && !(CAREER_SEASON in metadata.team_conferences)) { + metadata.team_conferences = { [CAREER_SEASON]: metadata.team_conferences }; + } + } +} + +// The threshold in force, as a number (Season) or { joined, prospect } (Career). +// `appliedThreshold()` reads the last-applied state, `pendingThreshold()` the +// controls as they are right now. +export function appliedThreshold() { + return thresholdSpec(appliedFilters.threshold, appliedFilters.prospectThreshold); +} + +export function pendingThreshold() { + return thresholdSpec(els.thresholdNumber.value, IS_CAREER ? els.thresholdProspectNumber.value : undefined); +} + +// "300" for a bar given as a plain number. +export function thresholdPhrase(spec) { + return String(spec); +} + +// The threshold as clauses a card can print: one ("≥ 300 pass rush opportunities") on +// a Season page, one per status on Career ("≥ 450 pass rush opportunities (Joined NFL)", +// "≥ 350 pass rush opportunities (Prospects)"). `onlyStatus` keeps just that +// status's bar, unlabelled, for a card whose whole roster has that one status. +export function thresholdClauses(spec, fieldLabel, onlyStatus = null) { + if (typeof spec === "number") return [`≥ ${spec} ${fieldLabel}`]; + + if (onlyStatus === JOINED_NFL) return [`≥ ${spec.joined} ${fieldLabel}`]; + + if (onlyStatus === PROSPECTS) return [`≥ ${spec.prospect} ${fieldLabel}`]; + + return [`≥ ${spec.joined} ${fieldLabel} (${JOINED_NFL})`, `≥ ${spec.prospect} ${fieldLabel} (${PROSPECTS})`]; +} + +// Which statuses are lit on the Career page (empty on a Season page). A status +// that is off only dims its players; it never leaves the percentile pool. +export function litStatuses(statusesText) { + return new Set(statusesText ? statusesText.split(",") : []); } export function setCurrentSlice(records, category) { @@ -117,7 +185,7 @@ export function currentCategoryMeta() { // 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. +// carries in /api/metadata, so it follows whatever config/thresholds.py resolved. export function seasonSwitchChangesThresholdDefault(fromSeason, toSeason) { const defaults = currentCategoryMeta().default_thresholds; return defaults[fromSeason] !== defaults[toSeason]; @@ -125,7 +193,7 @@ export function seasonSwitchChangesThresholdDefault(fromSeason, 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 +// config/thresholds.py's resolve_default_threshold() routes the static-vs-dynamic // threshold default on, backed by metadata.current_year (see main.py's // /api/metadata) rather than a separate date.today().year call here, so // the frontend can never classify a season differently than the backend @@ -163,7 +231,8 @@ export function appliedCategoryMeta() { export async function fetchSlice(category, season) { const key = `${category}:${season}`; if (!sliceCache.has(key)) { - const url = `/api/${category}?season=${season}`; + // The Career route has no season: it sends every member. + const url = IS_CAREER ? `${LEAGUE.apiPrefix}/${category}` : `${LEAGUE.apiPrefix}/${category}?season=${season}`; const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url} → ${res.status}`); const data = await res.json(); @@ -179,8 +248,8 @@ export async function fetchSlice(category, season) { // position instead of being locked to appliedFilters.position. export function positionPool(position) { const cat = appliedCategoryMeta(); - const minThreshold = Number(appliedFilters.threshold); - return currentRecords.filter((r) => r.position === position && r[cat.threshold_field] >= minThreshold); + const threshold = appliedThreshold(); + return currentRecords.filter((r) => r.position === position && meetsThreshold(r, cat.threshold_field, threshold)); } export function median(values) { @@ -225,6 +294,13 @@ export function teamName(code) { return (t && t.full_name) || code; } +// How a team reads in text (rows, badges, table cells): the NFL shows its short +// code (BUF), while a college team's key is only a franchise id, so it shows the +// school's name. Keys and logo file names always stay the raw key. +export function teamLabel(team) { + return LEAGUE.teamLabel === "name" ? teamName(team) : team; +} + // Fallback swatch for the teams-dropdown checklist when a team's logo file // 404s — mirrors openScoutCard()'s logoImg.onerror treatment. export function teamSwatch(code) { diff --git a/frontend/dom.js b/frontend/dom.js index 81c7f3c..20b5db5 100644 --- a/frontend/dom.js +++ b/frontend/dom.js @@ -12,10 +12,28 @@ export const els = { xMetric: document.getElementById("x-metric-select"), yMetric: document.getElementById("y-metric-select"), heroInstallSlot: document.getElementById("hero-install-slot"), + leagueNav: document.querySelector(".league-nav"), + leagueNavToggle: document.getElementById("league-nav-toggle"), xAxisInfoSlot: document.getElementById("x-axis-info-slot"), yAxisInfoSlot: document.getElementById("y-axis-info-slot"), threshold: document.getElementById("threshold-slider"), thresholdNumber: document.getElementById("threshold-number"), + // Career page only: the second bar (Prospects) and the Status Filter. + thresholdProspect: document.getElementById("threshold-prospect-slider"), + thresholdProspectNumber: document.getElementById("threshold-prospect-number"), + thresholdControl: document.getElementById("threshold-control"), + thresholdProspectControl: document.getElementById("prospect-threshold-control"), + thresholdStatusLabel: document.getElementById("threshold-status-label"), + thresholdProspectFieldLabel: document.getElementById("threshold-prospect-field-label"), + seasonControl: document.querySelector(".control-season"), + statusControl: document.getElementById("status-control"), + statusBtn: document.getElementById("status-toggle-btn"), + statusSummary: document.getElementById("status-select-summary"), + statusDropdown: document.getElementById("status-dropdown"), + statusInfoSlot: document.getElementById("status-info-slot"), + statusEnabled: document.getElementById("status-enabled"), + statusJoined: document.getElementById("status-joined"), + statusProspect: document.getElementById("status-prospect"), applyBtn: document.getElementById("apply-filters"), savePngBtn: document.getElementById("save-png-btn"), thresholdFieldLabel: document.getElementById("threshold-field-label"), diff --git a/frontend/filters.js b/frontend/filters.js index 1ba42cd..ce5448a 100644 --- a/frontend/filters.js +++ b/frontend/filters.js @@ -31,8 +31,14 @@ import { logoSrc, teamSwatch, teamName, + teamLabel, + LEAGUE, allTeamCodes, findRecordByPlayer, + IS_CAREER, + CAREER_SEASON, + appliedThreshold, + pendingThreshold, } from "./data.js"; import { searchPlayersExcluding, qualifyingPlayerPool, fullPlayerPool } from "./search.js"; import { CONFERENCES, LINEMATE_POSITIONS } from "./config.js"; @@ -59,6 +65,9 @@ import { partitionByEligibility, removedPlayersNotice, resolveCarriedRecord, + meetsThreshold, + JOINED_NFL, + PROSPECTS, } from "./pinned-impact.js"; // True right after page load and right after a category switch — both cases @@ -104,6 +113,9 @@ export function currentFilterState() { xMetric: els.xMetric.value, yMetric: els.yMetric.value, threshold: els.thresholdNumber.value, + // Career only: the second bar and which statuses are lit. Left out on a Season + // page, so its state keeps its old shape. + ...(IS_CAREER ? { prospectThreshold: els.thresholdProspectNumber.value, statuses: litStatusesText() } : {}), // 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 @@ -113,6 +125,99 @@ export function currentFilterState() { }; } +// The lit Career statuses as a sorted comma string (comparable with `!==`, like teams). +// Empty while "Enable Status" is off: Status is then out of the highlight union. +export function litStatusesText() { + if (!els.statusEnabled.checked) return ""; + + const lit = []; + if (els.statusJoined.checked) lit.push(JOINED_NFL); + if (els.statusProspect.checked) lit.push(PROSPECTS); + return lit.sort().join(","); +} + +// One entry per threshold bar: the single bar of a Season page, or the Joined NFL +// and Prospects pair on the Career page. `status` is what the bar judges +// (undefined on a Season page, where it judges every player). +function thresholdBars() { + const bars = [{ slider: els.threshold, number: els.thresholdNumber, status: IS_CAREER ? JOINED_NFL : undefined }]; + if (IS_CAREER) { + bars.push({ slider: els.thresholdProspect, number: els.thresholdProspectNumber, status: PROSPECTS }); + } + return bars; +} + +// The configured default of one bar: the season's number, or on Career the one of the bar's status. +function defaultThresholdOf(cat, bar) { + const key = IS_CAREER ? bar.status : els.season.value; + return cat.default_thresholds[key] ?? 0; +} + +// Slider ceiling of one bar for a category's records at the pending position: the +// largest value of the bar's players, rounded up to ten (Career: of its own status). +function barMax(records, cat, bar) { + const values = records + .filter((r) => r.position === els.position.value && (bar.status === undefined || r.status === bar.status)) + .map((r) => r[cat.threshold_field]) + .filter((v) => v != null); + const maxVal = values.length ? Math.max(...values) : 100; + return Math.ceil(maxVal / 10) * 10; +} + +// The season value the controls hold: a number, or the word Career. +function seasonValue() { + return IS_CAREER ? CAREER_SEASON : Number(els.season.value); +} + +// The Status button's label, like the Teams summary. +export function updateStatusSummary() { + const joined = els.statusJoined.checked; + const prospect = els.statusProspect.checked; + if (!els.statusEnabled.checked) els.statusSummary.textContent = "Off"; + else if (joined && prospect) els.statusSummary.textContent = "All Status"; + else if (joined) els.statusSummary.textContent = JOINED_NFL; + else if (prospect) els.statusSummary.textContent = PROSPECTS; + else els.statusSummary.textContent = "No Status"; + + // The button's name is its visible label, which no longer says "Status". + els.statusBtn.setAttribute("aria-label", `Status: ${els.statusSummary.textContent}`); +} + +export function openStatusDropdown() { + els.statusDropdown.hidden = false; + els.statusBtn.setAttribute("aria-expanded", "true"); +} + +export function closeStatusDropdown() { + els.statusDropdown.hidden = true; + els.statusBtn.setAttribute("aria-expanded", "false"); +} + +// The two bars do not follow the Status Filter: both stay fully usable whatever is lit, +// since a status that is off only dims its players and its bar still counts in the pool. +// With "Enable Status" off the dropdown is shut and greyed; its ticks are kept. +export function syncStatusBars() { + if (!IS_CAREER) return; + updateStatusSummary(); + + const enabled = els.statusEnabled.checked; + els.statusBtn.disabled = !enabled; + if (!enabled) closeStatusDropdown(); +} + +// True when a pending bar differs from the applied one (either bar on Career). +function thresholdsChanged() { + return ( + els.thresholdNumber.value !== appliedFilters.threshold || + (IS_CAREER && els.thresholdProspectNumber.value !== appliedFilters.prospectThreshold) + ); +} + +// The state the pool checks judge, from the applied snapshot: its threshold as a number or pair. +function appliedPoolState() { + return { ...appliedFilters, threshold: appliedThreshold() }; +} + export function selectedTeamCodes() { return Array.from(els.teamsChecklist.querySelectorAll("input[type=checkbox]:checked")).map((cb) => cb.value); } @@ -129,17 +234,18 @@ export function selectedTeamCodes() { function resetTeamsIfAllSelected() { if (selectedTeamCodes().length !== allTeamCodes().length) return; els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => (cb.checked = false)); + markNoTeamsSelected(); updateTeamsSummary(); } -export function teamOptionRow(code) { +export function teamOptionRow(code, checked = true) { const label = document.createElement("label"); label.className = "team-option"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = code; - cb.checked = true; + cb.checked = checked; const logo = document.createElement("img"); logo.className = "team-option-logo"; @@ -149,38 +255,127 @@ export function teamOptionRow(code) { logo.onerror = () => logo.replaceWith(teamSwatch(code)); const text = document.createElement("span"); - text.textContent = code; + text.textContent = teamLabel(code); label.append(cb, logo, text); return label; } -export function populateTeamsChecklist() { +// The checklist's groups for a season: [{ title, sections: [{ title, codes }] }]. +// The NFL's conferences and divisions are fixed (config.js); a college +// conference changes with the season (realignment), so those groups come from +// the metadata's per-season membership, one section per conference, with the +// schools by name. A conference with no school that season has no group. +function teamGroups(season) { + if (LEAGUE.teamGroups === "static") { + return Object.entries(CONFERENCES).map(([conference, divisions]) => ({ + title: conference, + sections: Object.entries(divisions).map(([division, codes]) => ({ title: division, codes })), + })); + } + + const conferenceOfTeam = (metadata.team_conferences && metadata.team_conferences[season]) || {}; + return metadata.conference_order + .map((conference) => ({ + title: conference, + sections: [ + { + title: null, + codes: Object.keys(conferenceOfTeam) + .filter((code) => conferenceOfTeam[code] === conference) + .sort((a, b) => teamName(a).localeCompare(teamName(b))), + }, + ], + })) + .filter((group) => group.sections[0].codes.length > 0); +} + +// What the user chose is kept across rebuilds, because a college season lists +// only its own schools. knownTeams/uncheckedTeams hold each seen school's last +// state; selectNewByDefault is whether a school shown for the first time starts +// checked -- the "All Teams" vs "none" mode. It is flipped ONLY by an explicit +// all/none action (the Teams "Select all"/"None" buttons and the player-pick +// auto-clear, through markAllTeamsSelected()/markNoTeamsSelected()), never +// inferred from how many boxes happen to be ticked. Inferring it from the DOM +// used to turn on schools the user never touched: a season whose every visible +// school happened to be ticked read as "All Teams", so moving to a bigger season +// ticked its new schools too. +const uncheckedTeams = new Set(); +const knownTeams = new Set(); +let selectNewByDefault = true; + +// The Teams "None" button and the player-pick auto-clear: nothing is selected +// now, including schools this season does not list and ones not yet seen. +export function markNoTeamsSelected() { + selectNewByDefault = false; + knownTeams.forEach((key) => uncheckedTeams.add(key)); +} + +// The Teams "Select all" button: every school is selected, including ones not +// yet seen. +export function markAllTeamsSelected() { + selectNewByDefault = true; + uncheckedTeams.clear(); +} + +export function populateTeamsChecklist(season) { + // Capture the individual ticks the user made on the outgoing season's boxes + // (those fire no event the sets listen to). An explicit all/none action has + // already updated the sets through the markers above. + els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => { + knownTeams.add(cb.value); + if (cb.checked) uncheckedTeams.delete(cb.value); + else uncheckedTeams.add(cb.value); + }); els.teamsChecklist.innerHTML = ""; - Object.entries(CONFERENCES).forEach(([conference, divisions]) => { + const bySeason = LEAGUE.teamGroups === "by_season"; + els.teamsDropdown.classList.toggle("teams-dropdown--by-season", bySeason); + + teamGroups(season).forEach(({ title, sections }) => { const column = document.createElement("div"); column.className = "teams-column"; - const columnHeader = document.createElement("div"); + // A college conference header is a button that selects or clears its whole + // conference; the NFL's headers stay plain text. + const columnHeader = document.createElement(bySeason ? "button" : "div"); columnHeader.className = "teams-column-header"; - columnHeader.textContent = conference; + columnHeader.textContent = title; column.appendChild(columnHeader); - Object.entries(divisions).forEach(([division, codes]) => { + const groupBoxes = []; + sections.forEach((section) => { 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))); + if (section.title) { + const divisionHeader = document.createElement("div"); + divisionHeader.className = "teams-division-header"; + divisionHeader.textContent = section.title; + group.appendChild(divisionHeader); + } + + // Codes are already listed in order within each section above. + section.codes.forEach((code) => { + const key = String(code); + const checked = knownTeams.has(key) ? !uncheckedTeams.has(key) : selectNewByDefault; + const row = teamOptionRow(code, checked); + groupBoxes.push(row.querySelector("input")); + group.appendChild(row); + }); column.appendChild(group); }); + if (bySeason) { + columnHeader.type = "button"; + columnHeader.addEventListener("click", () => { + const allChecked = groupBoxes.every((cb) => cb.checked); + groupBoxes.forEach((cb) => (cb.checked = !allChecked)); + updateTeamsSummary(); + updatePendingState(); + }); + } + els.teamsChecklist.appendChild(column); }); @@ -286,8 +481,8 @@ export function renderPlayersDropdown(matches) { toggle.className = "below-threshold-toggle"; toggle.textContent = `Below Threshold Matches (${belowThresholdMatches.length})`; toggle.addEventListener("click", () => - openBelowThresholdPopup(belowThresholdMatches, metadata[playerPoolCategory], (value) => { - quickSetPlayersThreshold(value); + openBelowThresholdPopup(belowThresholdMatches, metadata[playerPoolCategory], (value, record) => { + quickSetPlayersThreshold(value, record); runPlayersSearch(); }) ); @@ -318,7 +513,7 @@ export function renderPlayersDropdown(matches) { logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); const code = document.createElement("span"); - code.textContent = record.team; + code.textContent = teamLabel(record.team); team.append(logo, code); opt.append(name, team); @@ -358,9 +553,9 @@ export function runPlayersSearch() { // already clear the bar, so it can never overlap with the normal results. if (playerPoolCategory) { const cat = metadata[playerPoolCategory]; - const minThreshold = Number(els.thresholdNumber.value); + const threshold = pendingThreshold(); const fullMatches = searchPlayers(query, fullPlayerPool(), BELOW_THRESHOLD_TOP_K); - belowThresholdMatches = fullMatches.filter((r) => r[cat.threshold_field] < minThreshold); + belowThresholdMatches = fullMatches.filter((r) => !meetsThreshold(r, cat.threshold_field, threshold)); } else { belowThresholdMatches = []; } @@ -407,7 +602,7 @@ export function openBelowThresholdPopup(matches, cat, onSetThreshold) { logo.loading = "lazy"; logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); const code = document.createElement("span"); - code.textContent = record.team; + code.textContent = teamLabel(record.team); team.append(logo, code); info.append(name, team); @@ -424,7 +619,7 @@ export function openBelowThresholdPopup(matches, cat, onSetThreshold) { // Query text is left untouched (nothing in this flow writes to the // triggering box's own input) — onSetThreshold's own rerun surfaces // the just-qualified player the same way retyping the query would. - onSetThreshold(record[cat.threshold_field]); + onSetThreshold(record[cat.threshold_field], record); closeBelowThresholdPopup(); }); @@ -450,17 +645,19 @@ export function closeBelowThresholdPopup() { // override) rather than reimplementing that logic per caller. Neither // caller's own appliedFilters/pool-refresh handling lives here — that's each // one's own job, since they differ (stay pending vs. commit now). -function syncThresholdControls(value) { - const min = Number(els.threshold.min); - const max = Number(els.threshold.max); - const step = Number(els.threshold.step) || 1; +function syncThresholdControls(value, record) { + // On Career the bar that moves is the one of the player's own status. + const bar = thresholdBars().find((b) => b.status === (record && record.status)) || thresholdBars()[0]; + const min = Number(bar.slider.min); + const max = Number(bar.slider.max); + const step = Number(bar.slider.step) || 1; const clamped = Math.min(Math.max(value, min), max); - els.thresholdNumber.value = clamped; + bar.number.value = clamped; // Slider snaps to the nearest step just to keep the handle in sync - // visually — filtering itself reads thresholdNumber.value (the exact + // visually — filtering itself reads the number input's value (the exact // value), same split as the manual number-input handler in main.js. - els.threshold.value = min + Math.round((clamped - min) / step) * step; + bar.slider.value = min + Math.round((clamped - min) / step) * step; setResetThresholdOnNextRange(false); } @@ -496,8 +693,8 @@ export function setThresholdTo(value) { // pending edit sitting unapplied in the drawer right now) and refreshes // every already-open card in place instead of invalidating the Workspace // the way a normal Apply's threshold change does. -export function applyThresholdImmediately(value) { - syncThresholdControls(value); +export function applyThresholdImmediately(value, record) { + syncThresholdControls(value, record); commitPendingThreshold(); } @@ -510,7 +707,11 @@ function commitPendingThreshold() { // currentFilterState() would have produced — that's what keeps // filtersArePending() from lighting the Apply button for a threshold that // in fact just got committed. - setAppliedFilters({ ...appliedFilters, threshold: els.thresholdNumber.value }); + setAppliedFilters({ + ...appliedFilters, + threshold: els.thresholdNumber.value, + ...(IS_CAREER ? { prospectThreshold: els.thresholdProspectNumber.value } : {}), + }); prunePlayerSelections(); render(); reconcileCarriedCards(); @@ -530,8 +731,8 @@ function commitPendingThreshold() { // field untouched. Even then it must pass evaluatePinnedImpact(); "reset" // (a pinned player would fall under the bar) also falls back to staging, so // the normal Apply flow shows its usual confirm. -function quickSetPlayersThreshold(value) { - syncThresholdControls(value); +function quickSetPlayersThreshold(value, record) { + syncThresholdControls(value, record); prunePlayerSelections(); const poolMatchesApplied = els.category.value === appliedFilters.category && @@ -539,7 +740,7 @@ function quickSetPlayersThreshold(value) { els.position.value === appliedFilters.position; if (poolMatchesApplied && playerPoolCategory === appliedFilters.category) { const impact = evaluatePinnedImpact( - appliedFilters, + appliedPoolState(), pendingPoolState(), distinctWorkspacePlayers(), playerPoolRecords, @@ -565,7 +766,7 @@ function pendingPoolState() { category: els.category.value, season: els.season.value, position: els.position.value, - threshold: Number(els.thresholdNumber.value), + threshold: pendingThreshold(), }; } @@ -577,11 +778,11 @@ function pendingPoolState() { // guaranteed to resolve — evaluatePinnedImpact() checked them first. function reconcileCarriedCards() { const field = appliedCategoryMeta().threshold_field; - const threshold = Number(appliedFilters.threshold); + const threshold = appliedThreshold(); // Pinned players are judged at the applied position only. const resolve = (name) => { const r = currentRecords.find((x) => x.player === name && x.position === appliedFilters.position); - return r && r[field] != null && r[field] >= threshold ? r : null; + return r && meetsThreshold(r, field, threshold) ? r : null; }; // A Player Card opened from a Linemate Card's "Card" button, and a Linemate // Card anchored on such a player, can belong to a teammate at another @@ -646,7 +847,7 @@ export function prunePlayerSelections({ notify = true } = {}) { Array.from(selectedPlayers.keys()), hasPool ? playerPoolRecords : [], els.position.value, - Number(els.thresholdNumber.value), + pendingThreshold(), hasPool ? metadata[playerPoolCategory].threshold_field : null ); if (!removed.length) return []; @@ -735,11 +936,12 @@ export function populateCategoryDependentControls() { } els.thresholdFieldLabel.textContent = thresholdFieldLabel(cat); + els.thresholdProspectFieldLabel.textContent = thresholdFieldLabel(cat); } export async function loadCurrentSlice() { const category = els.category.value; - const season = Number(els.season.value); + const season = seasonValue(); // setCurrentSlice() replaces the original's direct `currentRecords =`/ // `currentSliceCategory =` assignments — data.js owns that state now, and // ES modules don't let an importer reassign an imported `let` (see @@ -762,7 +964,7 @@ export async function loadCurrentSlice() { // own comment for why. export async function updatePlayerPool() { const category = els.category.value; - const season = Number(els.season.value); + const season = seasonValue(); // setPlayerPool() — see the comment in loadCurrentSlice() above. setPlayerPool(await fetchSlice(category, season), category); updatePendingThresholdMax(); @@ -809,87 +1011,75 @@ export async function updatePlayerPool() { function updatePendingThresholdMax() { if (!playerPoolCategory) return; const cat = metadata[playerPoolCategory]; - const positionRecords = playerPoolRecords.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; - - if (resetThresholdOnNextRange) { - const defaultValue = cat.default_thresholds[els.season.value] ?? 0; - els.threshold.max = max; - els.thresholdNumber.max = max; - els.threshold.value = Math.min(Math.max(defaultValue, 0), max); - els.thresholdNumber.value = els.threshold.value; - return; - } - // Read the number box's own current value *before* touching .max below — - // a native 's value auto-clamps to a lowered max the - // instant it's assigned, so checking els.threshold.value afterward would - // silently compare against an already-corrected number and skip - // clamping thresholdNumber (caught via manual testing: the slider ended - // up right, but the number box was left stuck at the old, too-high - // value). thresholdNumber has no such auto-clamp, so it's the only - // reliable "what was actually set before this ran" source here. - const currentValue = Number(els.thresholdNumber.value); - if (currentValue > max) { - const defaultValue = cat.default_thresholds[els.season.value] ?? 0; - const fallback = Math.min(Math.max(defaultValue, 0), max); - els.threshold.max = max; - els.thresholdNumber.max = max; - els.threshold.value = fallback; - els.thresholdNumber.value = fallback; - } + // Each bar is judged alone (a Season page has one, the Career page two). + thresholdBars().forEach((bar) => { + const max = barMax(playerPoolRecords, cat, bar); + + if (resetThresholdOnNextRange) { + const defaultValue = defaultThresholdOf(cat, bar); + bar.slider.max = max; + bar.number.max = max; + bar.slider.value = Math.min(Math.max(defaultValue, 0), max); + bar.number.value = bar.slider.value; + return; + } + + // Read the number box's own current value *before* touching .max below — + // a native 's value auto-clamps to a lowered max the + // instant it's assigned, so checking the slider's value afterward would + // silently compare against an already-corrected number and skip clamping + // the number box. The number box has no such auto-clamp, so it's the only + // reliable "what was actually set before this ran" source here. + const currentValue = Number(bar.number.value); + if (currentValue > max) { + const defaultValue = defaultThresholdOf(cat, bar); + const fallback = Math.min(Math.max(defaultValue, 0), max); + bar.slider.max = max; + bar.number.max = max; + bar.slider.value = fallback; + bar.number.value = fallback; + } + }); } export 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.step = 5; - els.thresholdNumber.min = 0; - - // .max is only reassigned inside the two branches below, not - // unconditionally up front — same "don't move the bar unless the value - // actually needs to" rule as updatePendingThresholdMax()'s live - // counterpart (see its comment for the full reasoning). A no-op Apply - // (e.g. one historical season swapped for another, both sharing - // DEFAULT_THRESHOLDS' one static value) now leaves the slider's fill - // percentage exactly where it was instead of jumping to match - // whatever slightly different real max the new slice happens to have. - if (resetThresholdOnNextRange) { - const defaultValue = cat.default_thresholds[els.season.value] ?? 0; - els.threshold.max = max; - els.thresholdNumber.max = max; - els.threshold.value = Math.min(Math.max(defaultValue, 0), max); - 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 - // "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 - // back to this season's configured default rather than the new slice's - // raw max (see updatePendingThresholdMax()'s matching comment — same - // bug, same fix, just the Apply-time copy of it). - const defaultValue = cat.default_thresholds[els.season.value] ?? 0; - els.threshold.max = max; - els.thresholdNumber.max = max; - els.threshold.value = Math.min(Math.max(defaultValue, 0), max); - } - // else: the current value still fits under the new max, and nothing - // forced a default reset — leave .max/.thresholdNumber.max exactly as - // they are so the bar doesn't move for a change that isn't there. + const resetting = resetThresholdOnNextRange; + + thresholdBars().forEach((bar) => { + // currentRecords holds every position in the category (see fetchSlice) — + // barMax() scopes to the pending position (and, on Career, to the bar's + // status), otherwise switching to a smaller-pool position would inherit a + // max sized for a bigger one. + const max = barMax(currentRecords, cat, bar); + + bar.slider.min = 0; + bar.slider.step = 5; + bar.number.min = 0; + + // .max is only reassigned inside the two branches below, not + // unconditionally up front: the bar must not move unless the value + // actually needs to (see updatePendingThresholdMax()). + if (resetting) { + const defaultValue = defaultThresholdOf(cat, bar); + bar.slider.max = max; + bar.number.max = max; + bar.slider.value = Math.min(Math.max(defaultValue, 0), max); + } else if (Number(bar.slider.value) > max) { + // The user's value no longer fits: fall back to the configured default + // rather than the new slice's raw max. + const defaultValue = defaultThresholdOf(cat, bar); + bar.slider.max = max; + bar.number.max = max; + bar.slider.value = Math.min(Math.max(defaultValue, 0), max); + } + // else: the current value still fits and nothing forced a reset, so the bar stays. - els.thresholdNumber.value = els.threshold.value; + bar.number.value = bar.slider.value; + }); + + if (resetting) resetThresholdOnNextRange = false; } // Fired immediately on a Category switch only (main.js's attachEvents()) — @@ -906,7 +1096,7 @@ export function updateThresholdRange() { // it could pin the slider at the far-right edge for however long the fetch // took, then visibly snap back once corrected). Always snaps to the new // category's configured default for whatever season is currently selected -// (see config.py's resolve_default_threshold — a season may carry a static +// (see config/thresholds.py's resolve_default_threshold — a season may carry a static // or dynamic default) and never tries to preserve whatever the user had // set beforehand. The accurate slider max (which needs the new category's // fetched data) still gets corrected as soon as it's available via @@ -917,17 +1107,22 @@ export function updateThresholdRange() { // other, same as every other place both controls change at once. export function resetThresholdToCategoryDefault() { const cat = currentCategoryMeta(); - const defaultValue = cat.default_thresholds[els.season.value] ?? 0; - // The outgoing category's slider max may be smaller than the incoming - // default (e.g. a narrow DL pool's max sitting below OL's 300 default) — - // extend it so the browser doesn't silently clamp the value we're about - // to set. loadCurrentSlice() overwrites this with the real max at Apply. - if (Number(els.threshold.max) < defaultValue) { - els.threshold.max = defaultValue; - els.thresholdNumber.max = defaultValue; - } - els.threshold.value = defaultValue; - els.thresholdNumber.value = defaultValue; + + thresholdBars().forEach((bar) => { + const defaultValue = defaultThresholdOf(cat, bar); + + // The outgoing category's slider max may be smaller than the incoming + // default (e.g. a narrow DL pool's max sitting below OL's default) — + // extend it so the browser doesn't silently clamp the value we're about + // to set. loadCurrentSlice() overwrites this with the real max at Apply. + if (Number(bar.slider.max) < defaultValue) { + bar.slider.max = defaultValue; + bar.number.max = defaultValue; + } + + bar.slider.value = defaultValue; + bar.number.value = defaultValue; + }); } export function closeFiltersDrawer() { @@ -956,7 +1151,7 @@ export async function applyFilters() { if ( els.category.value === appliedFilters.category && els.position.value === appliedFilters.position && - (els.season.value !== appliedFilters.season || els.thresholdNumber.value !== appliedFilters.threshold) + (els.season.value !== appliedFilters.season || thresholdsChanged()) ) { await updatePlayerPool(); } @@ -965,7 +1160,7 @@ export async function applyFilters() { // (see pinned-impact.js and CLAUDE.md's "Pool-change invalidation"). Axes / // Teams / Players never participate — none affect positionPool(). const impact = evaluatePinnedImpact( - appliedFilters, + appliedPoolState(), pendingPoolState(), pinned, playerPoolRecords, diff --git a/frontend/images/metric_formulas/pbe.svg b/frontend/images/metric_formulas/pbe.svg index f0da1d6..d1cad90 100644 --- a/frontend/images/metric_formulas/pbe.svg +++ b/frontend/images/metric_formulas/pbe.svg @@ -1,16 +1,16 @@ - + - 2026-09-29T17:04:34.642751 + 2026-10-31T12:49:29.115723 image/svg+xml - Matplotlib v3.10.6, https://matplotlib.org/ + Matplotlib v3.10.9, https://matplotlib.org/ @@ -22,15 +22,15 @@ - + - - + + @@ -598,42 +616,53 @@ z - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/images/metric_formulas/prp.svg b/frontend/images/metric_formulas/prp.svg index 6a43dcb..709a2b1 100644 --- a/frontend/images/metric_formulas/prp.svg +++ b/frontend/images/metric_formulas/prp.svg @@ -1,16 +1,16 @@ - + - 2026-09-29T17:04:34.629652 + 2026-10-31T12:49:29.098371 image/svg+xml - Matplotlib v3.10.6, https://matplotlib.org/ + Matplotlib v3.10.9, https://matplotlib.org/ @@ -22,15 +22,15 @@ - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/images/team_logos/ncaaf/102.png b/frontend/images/team_logos/ncaaf/102.png index 3740ece..b0e3c5b 100644 Binary files a/frontend/images/team_logos/ncaaf/102.png and b/frontend/images/team_logos/ncaaf/102.png differ diff --git a/frontend/images/team_logos/ncaaf/109.png b/frontend/images/team_logos/ncaaf/109.png index 928f237..f7655f1 100644 Binary files a/frontend/images/team_logos/ncaaf/109.png and b/frontend/images/team_logos/ncaaf/109.png differ diff --git a/frontend/images/team_logos/ncaaf/110.png b/frontend/images/team_logos/ncaaf/110.png index b79d6ae..7c45e33 100644 Binary files a/frontend/images/team_logos/ncaaf/110.png and b/frontend/images/team_logos/ncaaf/110.png differ diff --git a/frontend/images/team_logos/ncaaf/111.png b/frontend/images/team_logos/ncaaf/111.png index 5d91b75..a4899d0 100644 Binary files a/frontend/images/team_logos/ncaaf/111.png and b/frontend/images/team_logos/ncaaf/111.png differ diff --git a/frontend/images/team_logos/ncaaf/115.png b/frontend/images/team_logos/ncaaf/115.png index ffc8380..559c488 100644 Binary files a/frontend/images/team_logos/ncaaf/115.png and b/frontend/images/team_logos/ncaaf/115.png differ diff --git a/frontend/images/team_logos/ncaaf/117.png b/frontend/images/team_logos/ncaaf/117.png index fdaf247..b7d6003 100644 Binary files a/frontend/images/team_logos/ncaaf/117.png and b/frontend/images/team_logos/ncaaf/117.png differ diff --git a/frontend/images/team_logos/ncaaf/118.png b/frontend/images/team_logos/ncaaf/118.png index 3836ab9..bf1c7b2 100644 Binary files a/frontend/images/team_logos/ncaaf/118.png and b/frontend/images/team_logos/ncaaf/118.png differ diff --git a/frontend/images/team_logos/ncaaf/120.png b/frontend/images/team_logos/ncaaf/120.png index 350bc81..cbd2113 100644 Binary files a/frontend/images/team_logos/ncaaf/120.png and b/frontend/images/team_logos/ncaaf/120.png differ diff --git a/frontend/images/team_logos/ncaaf/121.png b/frontend/images/team_logos/ncaaf/121.png index f747c06..0d24e01 100644 Binary files a/frontend/images/team_logos/ncaaf/121.png and b/frontend/images/team_logos/ncaaf/121.png differ diff --git a/frontend/images/team_logos/ncaaf/122.png b/frontend/images/team_logos/ncaaf/122.png index af45491..3473cac 100644 Binary files a/frontend/images/team_logos/ncaaf/122.png and b/frontend/images/team_logos/ncaaf/122.png differ diff --git a/frontend/images/team_logos/ncaaf/128.png b/frontend/images/team_logos/ncaaf/128.png index 8669cba..ac2e186 100644 Binary files a/frontend/images/team_logos/ncaaf/128.png and b/frontend/images/team_logos/ncaaf/128.png differ diff --git a/frontend/images/team_logos/ncaaf/130.png b/frontend/images/team_logos/ncaaf/130.png index a929117..71c0720 100644 Binary files a/frontend/images/team_logos/ncaaf/130.png and b/frontend/images/team_logos/ncaaf/130.png differ diff --git a/frontend/images/team_logos/ncaaf/134.png b/frontend/images/team_logos/ncaaf/134.png index 3c0dea3..7406194 100644 Binary files a/frontend/images/team_logos/ncaaf/134.png and b/frontend/images/team_logos/ncaaf/134.png differ diff --git a/frontend/images/team_logos/ncaaf/138.png b/frontend/images/team_logos/ncaaf/138.png index 0180d42..3c5481a 100644 Binary files a/frontend/images/team_logos/ncaaf/138.png and b/frontend/images/team_logos/ncaaf/138.png differ diff --git a/frontend/images/team_logos/ncaaf/143.png b/frontend/images/team_logos/ncaaf/143.png index 5b20fe3..12aaeb4 100644 Binary files a/frontend/images/team_logos/ncaaf/143.png and b/frontend/images/team_logos/ncaaf/143.png differ diff --git a/frontend/images/team_logos/ncaaf/144.png b/frontend/images/team_logos/ncaaf/144.png index da47f29..1434fa8 100644 Binary files a/frontend/images/team_logos/ncaaf/144.png and b/frontend/images/team_logos/ncaaf/144.png differ diff --git a/frontend/images/team_logos/ncaaf/160.png b/frontend/images/team_logos/ncaaf/160.png index f8637f7..5be4a32 100644 Binary files a/frontend/images/team_logos/ncaaf/160.png and b/frontend/images/team_logos/ncaaf/160.png differ diff --git a/frontend/images/team_logos/ncaaf/163.png b/frontend/images/team_logos/ncaaf/163.png index 89da0a8..4b527bc 100644 Binary files a/frontend/images/team_logos/ncaaf/163.png and b/frontend/images/team_logos/ncaaf/163.png differ diff --git a/frontend/images/team_logos/ncaaf/167.png b/frontend/images/team_logos/ncaaf/167.png index 15b0912..9d6e8cd 100644 Binary files a/frontend/images/team_logos/ncaaf/167.png and b/frontend/images/team_logos/ncaaf/167.png differ diff --git a/frontend/images/team_logos/ncaaf/169.png b/frontend/images/team_logos/ncaaf/169.png index 86d6675..c3ba362 100644 Binary files a/frontend/images/team_logos/ncaaf/169.png and b/frontend/images/team_logos/ncaaf/169.png differ diff --git a/frontend/images/team_logos/ncaaf/173.png b/frontend/images/team_logos/ncaaf/173.png index 56de921..9ac2edc 100644 Binary files a/frontend/images/team_logos/ncaaf/173.png and b/frontend/images/team_logos/ncaaf/173.png differ diff --git a/frontend/images/team_logos/ncaaf/176.png b/frontend/images/team_logos/ncaaf/176.png index 9df88fd..472d6a8 100644 Binary files a/frontend/images/team_logos/ncaaf/176.png and b/frontend/images/team_logos/ncaaf/176.png differ diff --git a/frontend/images/team_logos/ncaaf/182.png b/frontend/images/team_logos/ncaaf/182.png index 961d27f..73598b0 100644 Binary files a/frontend/images/team_logos/ncaaf/182.png and b/frontend/images/team_logos/ncaaf/182.png differ diff --git a/frontend/images/team_logos/ncaaf/187.png b/frontend/images/team_logos/ncaaf/187.png index 6089ae6..516e78c 100644 Binary files a/frontend/images/team_logos/ncaaf/187.png and b/frontend/images/team_logos/ncaaf/187.png differ diff --git a/frontend/images/team_logos/ncaaf/192.png b/frontend/images/team_logos/ncaaf/192.png index ea15843..741f568 100644 Binary files a/frontend/images/team_logos/ncaaf/192.png and b/frontend/images/team_logos/ncaaf/192.png differ diff --git a/frontend/images/team_logos/ncaaf/193.png b/frontend/images/team_logos/ncaaf/193.png index 0bd4f61..95a6687 100644 Binary files a/frontend/images/team_logos/ncaaf/193.png and b/frontend/images/team_logos/ncaaf/193.png differ diff --git a/frontend/images/team_logos/ncaaf/198.png b/frontend/images/team_logos/ncaaf/198.png index 6054a31..f15777b 100644 Binary files a/frontend/images/team_logos/ncaaf/198.png and b/frontend/images/team_logos/ncaaf/198.png differ diff --git a/frontend/images/team_logos/ncaaf/199.png b/frontend/images/team_logos/ncaaf/199.png index 859a6ef..2ae58d1 100644 Binary files a/frontend/images/team_logos/ncaaf/199.png and b/frontend/images/team_logos/ncaaf/199.png differ diff --git a/frontend/images/team_logos/ncaaf/201.png b/frontend/images/team_logos/ncaaf/201.png index da4e400..e09954d 100644 Binary files a/frontend/images/team_logos/ncaaf/201.png and b/frontend/images/team_logos/ncaaf/201.png differ diff --git a/frontend/images/team_logos/ncaaf/210.png b/frontend/images/team_logos/ncaaf/210.png index 105b636..55d0181 100644 Binary files a/frontend/images/team_logos/ncaaf/210.png and b/frontend/images/team_logos/ncaaf/210.png differ diff --git a/frontend/images/team_logos/ncaaf/211.png b/frontend/images/team_logos/ncaaf/211.png index 4b6094a..f00ead9 100644 Binary files a/frontend/images/team_logos/ncaaf/211.png and b/frontend/images/team_logos/ncaaf/211.png differ diff --git a/frontend/images/team_logos/ncaaf/215.png b/frontend/images/team_logos/ncaaf/215.png index 0a383fe..f9e98bc 100644 Binary files a/frontend/images/team_logos/ncaaf/215.png and b/frontend/images/team_logos/ncaaf/215.png differ diff --git a/frontend/images/team_logos/ncaaf/216.png b/frontend/images/team_logos/ncaaf/216.png index 5cd5401..a16664a 100644 Binary files a/frontend/images/team_logos/ncaaf/216.png and b/frontend/images/team_logos/ncaaf/216.png differ diff --git a/frontend/images/team_logos/ncaaf/220.png b/frontend/images/team_logos/ncaaf/220.png index fb4e941..e3e1250 100644 Binary files a/frontend/images/team_logos/ncaaf/220.png and b/frontend/images/team_logos/ncaaf/220.png differ diff --git a/frontend/images/team_logos/ncaaf/221.png b/frontend/images/team_logos/ncaaf/221.png index 1e17d14..6134ca0 100644 Binary files a/frontend/images/team_logos/ncaaf/221.png and b/frontend/images/team_logos/ncaaf/221.png differ diff --git a/frontend/images/team_logos/ncaaf/222.png b/frontend/images/team_logos/ncaaf/222.png index 3929caf..0fb15ab 100644 Binary files a/frontend/images/team_logos/ncaaf/222.png and b/frontend/images/team_logos/ncaaf/222.png differ diff --git a/frontend/images/team_logos/ncaaf/223.png b/frontend/images/team_logos/ncaaf/223.png index 0f60283..6a6a421 100644 Binary files a/frontend/images/team_logos/ncaaf/223.png and b/frontend/images/team_logos/ncaaf/223.png differ diff --git a/frontend/images/team_logos/ncaaf/225.png b/frontend/images/team_logos/ncaaf/225.png index c7b2a2e..6626001 100644 Binary files a/frontend/images/team_logos/ncaaf/225.png and b/frontend/images/team_logos/ncaaf/225.png differ diff --git a/frontend/images/team_logos/ncaaf/226.png b/frontend/images/team_logos/ncaaf/226.png index 7f8bb14..765e68d 100644 Binary files a/frontend/images/team_logos/ncaaf/226.png and b/frontend/images/team_logos/ncaaf/226.png differ diff --git a/frontend/images/team_logos/ncaaf/227.png b/frontend/images/team_logos/ncaaf/227.png index 0f2d943..4cad6ad 100644 Binary files a/frontend/images/team_logos/ncaaf/227.png and b/frontend/images/team_logos/ncaaf/227.png differ diff --git a/frontend/images/team_logos/ncaaf/229.png b/frontend/images/team_logos/ncaaf/229.png index 9c7946f..e108925 100644 Binary files a/frontend/images/team_logos/ncaaf/229.png and b/frontend/images/team_logos/ncaaf/229.png differ diff --git a/frontend/images/team_logos/ncaaf/238.png b/frontend/images/team_logos/ncaaf/238.png index f3c88f9..0a24f7b 100644 Binary files a/frontend/images/team_logos/ncaaf/238.png and b/frontend/images/team_logos/ncaaf/238.png differ diff --git a/frontend/images/team_logos/ncaaf/248.png b/frontend/images/team_logos/ncaaf/248.png index 5d3a70e..a067aa9 100644 Binary files a/frontend/images/team_logos/ncaaf/248.png and b/frontend/images/team_logos/ncaaf/248.png differ diff --git a/frontend/images/team_logos/ncaaf/254.png b/frontend/images/team_logos/ncaaf/254.png index 9a4636e..c798c63 100644 Binary files a/frontend/images/team_logos/ncaaf/254.png and b/frontend/images/team_logos/ncaaf/254.png differ diff --git a/frontend/images/team_logos/ncaaf/256.png b/frontend/images/team_logos/ncaaf/256.png index 06dda2b..c64b265 100644 Binary files a/frontend/images/team_logos/ncaaf/256.png and b/frontend/images/team_logos/ncaaf/256.png differ diff --git a/frontend/images/team_logos/ncaaf/258.png b/frontend/images/team_logos/ncaaf/258.png index 443530e..080211d 100644 Binary files a/frontend/images/team_logos/ncaaf/258.png and b/frontend/images/team_logos/ncaaf/258.png differ diff --git a/frontend/images/team_logos/ncaaf/259.png b/frontend/images/team_logos/ncaaf/259.png index e4e41aa..5e9a46b 100644 Binary files a/frontend/images/team_logos/ncaaf/259.png and b/frontend/images/team_logos/ncaaf/259.png differ diff --git a/frontend/images/team_logos/ncaaf/260.png b/frontend/images/team_logos/ncaaf/260.png index 46e84eb..bf41ed1 100644 Binary files a/frontend/images/team_logos/ncaaf/260.png and b/frontend/images/team_logos/ncaaf/260.png differ diff --git a/frontend/images/team_logos/ncaaf/261.png b/frontend/images/team_logos/ncaaf/261.png index 8487798..786e472 100644 Binary files a/frontend/images/team_logos/ncaaf/261.png and b/frontend/images/team_logos/ncaaf/261.png differ diff --git a/frontend/images/team_logos/ncaaf/262.png b/frontend/images/team_logos/ncaaf/262.png index d1ebbd0..14ad348 100644 Binary files a/frontend/images/team_logos/ncaaf/262.png and b/frontend/images/team_logos/ncaaf/262.png differ diff --git a/frontend/images/team_logos/ncaaf/264.png b/frontend/images/team_logos/ncaaf/264.png index d484aa3..e4aaaad 100644 Binary files a/frontend/images/team_logos/ncaaf/264.png and b/frontend/images/team_logos/ncaaf/264.png differ diff --git a/frontend/images/team_logos/ncaaf/265.png b/frontend/images/team_logos/ncaaf/265.png index f955b20..b058ea1 100644 Binary files a/frontend/images/team_logos/ncaaf/265.png and b/frontend/images/team_logos/ncaaf/265.png differ diff --git a/frontend/images/team_logos/ncaaf/266.png b/frontend/images/team_logos/ncaaf/266.png index 4e42ca3..f2a6266 100644 Binary files a/frontend/images/team_logos/ncaaf/266.png and b/frontend/images/team_logos/ncaaf/266.png differ diff --git a/frontend/images/team_logos/ncaaf/268.png b/frontend/images/team_logos/ncaaf/268.png index fda6f8b..1c9004a 100644 Binary files a/frontend/images/team_logos/ncaaf/268.png and b/frontend/images/team_logos/ncaaf/268.png differ diff --git a/frontend/images/team_logos/ncaaf/273.png b/frontend/images/team_logos/ncaaf/273.png index 923d108..5c39544 100644 Binary files a/frontend/images/team_logos/ncaaf/273.png and b/frontend/images/team_logos/ncaaf/273.png differ diff --git a/frontend/images/team_logos/ncaaf/278.png b/frontend/images/team_logos/ncaaf/278.png index fe6d990..00b0c09 100644 Binary files a/frontend/images/team_logos/ncaaf/278.png and b/frontend/images/team_logos/ncaaf/278.png differ diff --git a/frontend/images/team_logos/ncaaf/279.png b/frontend/images/team_logos/ncaaf/279.png index 5ef0343..7aa7b48 100644 Binary files a/frontend/images/team_logos/ncaaf/279.png and b/frontend/images/team_logos/ncaaf/279.png differ diff --git a/frontend/images/team_logos/ncaaf/284.png b/frontend/images/team_logos/ncaaf/284.png index 11e2380..9d22a4c 100644 Binary files a/frontend/images/team_logos/ncaaf/284.png and b/frontend/images/team_logos/ncaaf/284.png differ diff --git a/frontend/images/team_logos/ncaaf/287.png b/frontend/images/team_logos/ncaaf/287.png index 8c9335d..f5e7b90 100644 Binary files a/frontend/images/team_logos/ncaaf/287.png and b/frontend/images/team_logos/ncaaf/287.png differ diff --git a/frontend/images/team_logos/ncaaf/289.png b/frontend/images/team_logos/ncaaf/289.png index 082a8ba..312db61 100644 Binary files a/frontend/images/team_logos/ncaaf/289.png and b/frontend/images/team_logos/ncaaf/289.png differ diff --git a/frontend/images/team_logos/ncaaf/300.png b/frontend/images/team_logos/ncaaf/300.png index 0d932af..ae86266 100644 Binary files a/frontend/images/team_logos/ncaaf/300.png and b/frontend/images/team_logos/ncaaf/300.png differ diff --git a/frontend/images/team_logos/ncaaf/304.png b/frontend/images/team_logos/ncaaf/304.png index 2ce17ed..ba87aef 100644 Binary files a/frontend/images/team_logos/ncaaf/304.png and b/frontend/images/team_logos/ncaaf/304.png differ diff --git a/frontend/images/team_logos/ncaaf/305.png b/frontend/images/team_logos/ncaaf/305.png index 2ae296a..f27cfd4 100644 Binary files a/frontend/images/team_logos/ncaaf/305.png and b/frontend/images/team_logos/ncaaf/305.png differ diff --git a/frontend/images/team_logos/ncaaf/312.png b/frontend/images/team_logos/ncaaf/312.png index 3259a98..a2f1209 100644 Binary files a/frontend/images/team_logos/ncaaf/312.png and b/frontend/images/team_logos/ncaaf/312.png differ diff --git a/frontend/images/team_logos/ncaaf/314.png b/frontend/images/team_logos/ncaaf/314.png index a3ed112..6d4e537 100644 Binary files a/frontend/images/team_logos/ncaaf/314.png and b/frontend/images/team_logos/ncaaf/314.png differ diff --git a/frontend/images/team_logos/ncaaf/315.png b/frontend/images/team_logos/ncaaf/315.png index 44eed75..bc09dd9 100644 Binary files a/frontend/images/team_logos/ncaaf/315.png and b/frontend/images/team_logos/ncaaf/315.png differ diff --git a/frontend/images/team_logos/ncaaf/316.png b/frontend/images/team_logos/ncaaf/316.png index 23674e0..b003e5c 100644 Binary files a/frontend/images/team_logos/ncaaf/316.png and b/frontend/images/team_logos/ncaaf/316.png differ diff --git a/frontend/images/team_logos/ncaaf/323.png b/frontend/images/team_logos/ncaaf/323.png index 8f04459..758d9a4 100644 Binary files a/frontend/images/team_logos/ncaaf/323.png and b/frontend/images/team_logos/ncaaf/323.png differ diff --git a/frontend/images/team_logos/ncaaf/324.png b/frontend/images/team_logos/ncaaf/324.png index e6c7d45..2426360 100644 Binary files a/frontend/images/team_logos/ncaaf/324.png and b/frontend/images/team_logos/ncaaf/324.png differ diff --git a/frontend/images/team_logos/ncaaf/326.png b/frontend/images/team_logos/ncaaf/326.png index 3e7966e..1c75359 100644 Binary files a/frontend/images/team_logos/ncaaf/326.png and b/frontend/images/team_logos/ncaaf/326.png differ diff --git a/frontend/images/team_logos/ncaaf/329.png b/frontend/images/team_logos/ncaaf/329.png index 1a30c8b..7bca493 100644 Binary files a/frontend/images/team_logos/ncaaf/329.png and b/frontend/images/team_logos/ncaaf/329.png differ diff --git a/frontend/images/team_logos/ncaaf/335.png b/frontend/images/team_logos/ncaaf/335.png index 8013aa0..acb0354 100644 Binary files a/frontend/images/team_logos/ncaaf/335.png and b/frontend/images/team_logos/ncaaf/335.png differ diff --git a/frontend/images/team_logos/ncaaf/337.png b/frontend/images/team_logos/ncaaf/337.png index 629c82e..e255abd 100644 Binary files a/frontend/images/team_logos/ncaaf/337.png and b/frontend/images/team_logos/ncaaf/337.png differ diff --git a/frontend/images/team_logos/ncaaf/339.png b/frontend/images/team_logos/ncaaf/339.png index f4e5488..0bc950b 100644 Binary files a/frontend/images/team_logos/ncaaf/339.png and b/frontend/images/team_logos/ncaaf/339.png differ diff --git a/frontend/images/team_logos/ncaaf/340.png b/frontend/images/team_logos/ncaaf/340.png index 8db2b84..39f0f79 100644 Binary files a/frontend/images/team_logos/ncaaf/340.png and b/frontend/images/team_logos/ncaaf/340.png differ diff --git a/frontend/images/team_logos/ncaaf/341.png b/frontend/images/team_logos/ncaaf/341.png index 2d4a4ef..181e49f 100644 Binary files a/frontend/images/team_logos/ncaaf/341.png and b/frontend/images/team_logos/ncaaf/341.png differ diff --git a/frontend/images/team_logos/ncaaf/343.png b/frontend/images/team_logos/ncaaf/343.png index 05d7f45..48cf88a 100644 Binary files a/frontend/images/team_logos/ncaaf/343.png and b/frontend/images/team_logos/ncaaf/343.png differ diff --git a/frontend/images/team_logos/ncaaf/347.png b/frontend/images/team_logos/ncaaf/347.png index 74145d4..9d18375 100644 Binary files a/frontend/images/team_logos/ncaaf/347.png and b/frontend/images/team_logos/ncaaf/347.png differ diff --git a/frontend/images/team_logos/ncaaf/349.png b/frontend/images/team_logos/ncaaf/349.png index 0827e59..03e2de1 100644 Binary files a/frontend/images/team_logos/ncaaf/349.png and b/frontend/images/team_logos/ncaaf/349.png differ diff --git a/frontend/index.html b/frontend/index.html index e5e8bdc..56a0f99 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -70,8 +70,12 @@ ); })(); - LinesShines · 鋒光 - + {{PAGE_TITLE}} + + + @@ -99,6 +103,7 @@
+ {{LEAGUE_NAV}}

LinesShines · 鋒光

-

🏈NFL OL & DL PFF Premium Stats Interactive Exploration

+

{{EYEBROW}}

LinesShines · 鋒光

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

@@ -149,6 +155,57 @@

LinesShines · 鋒光

+ + +
@@ -277,14 +334,35 @@

LinesShines · 鋒光

row below it. --> -
- +
+
+ + + diff --git a/frontend/info-popover.js b/frontend/info-popover.js index 4a6ae67..f4098cc 100644 --- a/frontend/info-popover.js +++ b/frontend/info-popover.js @@ -68,7 +68,8 @@ function openInfoPopover(triggerEl, content, { formula, alignRight } = {}) { // than concatenated into `text`/`imgAlt` so it renders as its own visually // separated paragraph instead of a run-on sentence. // A `bullets` array (short strings) renders as a bulleted list, one point per item, with - // a gap between points, for popups that explain several things side by side. + // a gap between points, for popups that explain several things side by side. A bare "OR" + // item renders as a separator without a bullet. const resolved = typeof content === "function" ? content() : content; const parts = typeof resolved === "string" ? { text: resolved } : resolved || {}; if (parts.imgSrc) { @@ -100,6 +101,10 @@ function openInfoPopover(triggerEl, content, { formula, alignRight } = {}) { for (const text of parts.bullets) { const item = document.createElement("li"); item.textContent = text; + + // A bare "OR" is a separator between two points, not a point of its own. + if (text === "OR") item.className = "info-popover-bullets-or"; + list.appendChild(item); } popup.appendChild(list); diff --git a/frontend/linemate-card.js b/frontend/linemate-card.js index 56f3ae6..7cbe314 100644 --- a/frontend/linemate-card.js +++ b/frontend/linemate-card.js @@ -13,6 +13,7 @@ import { appliedCategoryMeta, appliedFilters, currentRecords, + IS_CAREER, positionPool, rankAndPercentile, median, @@ -20,8 +21,12 @@ import { logoSrc, teamColor, teamName, + teamLabel, metadata, + appliedThreshold, + thresholdClauses, } from "./data.js"; +import { meetsThreshold, JOINED_NFL } from "./pinned-impact.js"; import { LINEMATE_POSITIONS, LINEMATE_SIZE_REMINDER_AT } from "./config.js"; import { ordinal, @@ -114,17 +119,30 @@ export function attachAppTooltip(el, text) { // then sorts by snap count (the category's threshold_field, the only volume // stat available per player) descending. No size limit: the threshold is the // only gate, and every qualifying player is on the card. +// The card is "Linemates" on a Season page and "NFL Alumni" on the Career page. Its name and +// who its roster is are the only two things that differ between the two (see +// DECISIONS.md, 2026-10-31); everything else is shared. Read at call time so it never depends +// on the order data.js is evaluated in. +export function rosterNoun() { + return IS_CAREER + ? { card: "NFL Alumni", one: "NFL alumnus", many: "NFL alumni", file: "NFL_Alumni" } + : { card: "Linemates", one: "linemate", many: "linemates", file: "Linemates" }; +} + export function computeLinemateRoster(anchorRecord) { const cat = appliedCategoryMeta(); const positions = LINEMATE_POSITIONS[appliedFilters.category]; - const threshold = Number(appliedFilters.threshold); + const threshold = appliedThreshold(); const qualifying = currentRecords.filter( (r) => positions.includes(r.position) && r.team === anchorRecord.team && r.player !== anchorRecord.player && - r[cat.threshold_field] >= threshold + // Career: the roster is "NFL Alumni", so only same-school, same-line members who + // reached the NFL. On a Season page there is no status and this is always true. + (!IS_CAREER || r.status === JOINED_NFL) && + meetsThreshold(r, cat.threshold_field, threshold) ); return qualifying.slice().sort((a, b) => b[cat.threshold_field] - a[cat.threshold_field]); @@ -281,8 +299,9 @@ function applyStickyLinemateColumns(table) { function linemateSortValue(t, label, cat) { if (label === "Player") return (t.abbr_name || t.player).toLowerCase(); if (label === "Position") return t.position; + if (label === "Status") return t.status ?? null; if (label === "Games") return t.games ?? null; - if (label === cat.threshold_field) return t[cat.threshold_field] ?? null; + if (label === cat.threshold_field || label === `TPS ${cat.threshold_field}`) return t[label] ?? null; const meta = cat.metrics[label]; if (meta) { const pool = positionPool(t.position); @@ -352,10 +371,12 @@ function makeRosterCell(t, column, cat) { if (openPlayerCard) openPlayerCard(t); }); td.appendChild(cardBtn); + } else if (column.kind === "status") { + td.textContent = t.status ?? "—"; } else if (column.kind === "games") { td.textContent = t.games ?? "—"; } else if (column.kind === "snaps") { - td.textContent = t[cat.threshold_field] ?? "—"; + td.textContent = t[column.key] ?? "—"; } else { const key = column.key; const meta = cat.metrics[key]; @@ -393,7 +414,14 @@ const READ_IDENTITY_COLUMNS = [ function renderLinemateReadTable(tableEl, players, cat) { const metricKeys = Object.keys(cat.metrics); const bonusKeys = metricKeys.filter((key) => cat.metrics[key].bonus); - const model = buildPlayerTableModel(players, cat.threshold_field, metricKeys, bonusKeys, READ_IDENTITY_COLUMNS); + const model = buildPlayerTableModel( + players, + cat.threshold_field, + metricKeys, + bonusKeys, + READ_IDENTITY_COLUMNS, + IS_CAREER + ); const layout = readLayout(model); const labelCell = (column) => { @@ -458,7 +486,16 @@ export function renderLinemateRoster(entry, roster, tableEl) { const thead = document.createElement("thead"); const headRow = document.createElement("tr"); - ["Player", PLAYER_CARD_COLUMN, "Position", "Games", cat.threshold_field, ...metricKeys].forEach((label) => { + [ + "Player", + PLAYER_CARD_COLUMN, + "Position", + ...(IS_CAREER ? ["Status"] : []), + "Games", + cat.threshold_field, + `TPS ${cat.threshold_field}`, + ...metricKeys, + ].forEach((label) => { // The button column has nothing to sort, so its header is plain text. if (label === PLAYER_CARD_COLUMN) { const th = document.createElement("th"); @@ -477,8 +514,10 @@ export function renderLinemateRoster(entry, roster, tableEl) { { key: "Player", kind: "name" }, { key: PLAYER_CARD_COLUMN, kind: "card" }, { key: "Position", kind: "position" }, + ...(IS_CAREER ? [{ key: "Status", kind: "status" }] : []), { key: "Games", kind: "games" }, { key: cat.threshold_field, kind: "snaps" }, + { key: `TPS ${cat.threshold_field}`, kind: "snaps" }, ...metricKeys.map((key) => ({ key, kind: "metric" })), ]; const tbody = document.createElement("tbody"); @@ -549,21 +588,28 @@ export function renderLinemateCardBody(entry) { // comment for why roster (threshold-relevant) and this stay separate. const displayRoster = linemateDisplayRoster(entry); + const noun = rosterNoun(); titleEl.textContent = entry.includeAnchor - ? `${entry.anchorRecord.player} & His Linemates` - : `${entry.anchorRecord.player} Linemates`; // season now lives on .linemate-card-meta, set once at open + ? `${entry.anchorRecord.player} & His ${noun.card}` + : `${entry.anchorRecord.player} ${noun.card}`; // season/Career lives on .linemate-card-meta, set once at open // The roster-size/threshold sentence sits on its own line above the "All // numbers are..." disclaimer, rather than behind a hover-only info icon. // Always describes the teammates-only pool (roster, not displayRoster) // regardless of the anchor toggle — this is the threshold talking, not a // literal count of the table's current rows. const rosterNoteEl = cardEl.querySelector(".linemate-card-roster-note"); - rosterNoteEl.textContent = `${roster.length} linemate${roster.length === 1 ? "" : "s"} with ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`; + // Every NFL alumnus is Joined NFL, so only that status's bar applies to the roster. + const rosterThreshold = thresholdClauses( + appliedThreshold(), + thresholdFieldLabel(cat), + IS_CAREER ? JOINED_NFL : null + ).join(" / "); + rosterNoteEl.textContent = `${roster.length} ${roster.length === 1 ? noun.one : noun.many} with ${rosterThreshold}.`; // With no roster cap, a very low threshold puts many players on the card; // past the old cap's size the card says a higher threshold keeps it compact. const reminderEl = cardEl.querySelector(".linemate-card-size-reminder"); - reminderEl.textContent = `${roster.length} linemates qualify at this threshold. A higher threshold keeps the card compact.`; + reminderEl.textContent = `${roster.length} ${noun.many} qualify at this threshold. A higher threshold keeps the card compact.`; reminderEl.hidden = roster.length <= LINEMATE_SIZE_REMINDER_AT[appliedFilters.category]; const isRead = entry.mode === TABLE_MODE_READ; cardEl.classList.toggle("is-read", isRead); @@ -577,8 +623,10 @@ export function renderLinemateCardBody(entry) { updateLinemateReadOverflow(cardEl); // 3M + RSWA summary, computed over every qualifying linemate — plus the - // anchor himself when the toggle is on. - renderLinemateSummary(entry, displayRoster, summaryTable); + // anchor himself when the toggle is on. The NFL Alumni card has none: alumni never + // played together, so a line summary of them would describe a line that never existed. + cardEl.querySelector(".linemate-summary-wrap").hidden = IS_CAREER; + if (!IS_CAREER) renderLinemateSummary(entry, displayRoster, summaryTable); } // Value a Line Summary row sorts by for a given column label — "Metric" @@ -674,13 +722,13 @@ function setLinemateCardIdentity(cardEl, anchorRecord) { const badge = cardEl.querySelector(".scout-badge"); const color = teamColor(anchorRecord.team); logoImg.src = logoSrc(anchorRecord.team); - logoImg.alt = `${anchorRecord.team} logo`; + logoImg.alt = `${teamLabel(anchorRecord.team)} logo`; logoImg.hidden = false; badge.hidden = true; logoImg.onerror = () => { logoImg.hidden = true; badge.hidden = false; - badge.textContent = anchorRecord.team; + badge.textContent = teamLabel(anchorRecord.team); badge.style.background = color; }; cardEl.querySelector(".linemate-card-meta").textContent = `${teamName(anchorRecord.team)} · ${appliedFilters.season}`; @@ -695,11 +743,15 @@ export function retargetLinemateCard(entry, anchorRecord) { setLinemateCardIdentity(entry.el, anchorRecord); } -const MODE_SWITCH_HELP = [ - "Read: players as columns and metrics as rows. No sorting in this mode.", - "Sort: players as rows and metrics as columns. Click a column header to order players by that metric.", - "Only the Individual table switches; the Line Summary stays as it is.", -]; +// The third line is about the Line Summary, which the NFL Alumni card does not have. +function modeSwitchHelp() { + const help = [ + "Read: players as columns and metrics as rows. No sorting in this mode.", + "Sort: players as rows and metrics as columns. Click a column header to order players by that metric.", + ]; + if (!IS_CAREER) help.push("Only the Individual table switches; the Line Summary stays as it is."); + return help; +} export function openLinemateCard(anchorRecord) { const cardEl = els.linemateCardTemplate.content.firstElementChild.cloneNode(true); @@ -750,11 +802,13 @@ export function openLinemateCard(anchorRecord) { // The Read | Sort switch on the "Individual" heading, and the (i) saying what it covers. const modeSwitch = cardEl.querySelector(".merge-mode-switch"); + const modeHelp = modeSwitchHelp(); modeSwitch .querySelector(".merge-mode-info") - .appendChild( - createInfoPopover({ bullets: MODE_SWITCH_HELP }, { ariaLabel: MODE_SWITCH_HELP.join(" "), label: "ⓘ" }) - ); + .appendChild(createInfoPopover({ bullets: modeHelp }, { ariaLabel: modeHelp.join(" "), label: "ⓘ" })); + + // Without a Line Summary to tell it apart from, "Individual" has nothing to be individual to. + if (IS_CAREER) cardEl.querySelector(".linemate-table-title > span").textContent = "Players"; modeSwitch.querySelectorAll(".merge-mode-btn").forEach((btn) => btn.addEventListener("click", () => { if (entry.mode === btn.dataset.mode) return; @@ -794,7 +848,7 @@ export function openLinemateCard(anchorRecord) { // intact (e.g. "D. Hall") — same convention already shown on the card's // own title, just applied to the downloaded filename too. () => - `LinesShines_${sanitizeForFilename(anchorRecord.abbr_name || anchorRecord.player)}_Linemates_${appliedFilters.season}`, + `LinesShines_${sanitizeForFilename(anchorRecord.abbr_name || anchorRecord.player)}_${rosterNoun().file}_${appliedFilters.season}`, // The export is always the Read layout, whichever mode is on screen: the Sort layout is too // wide to share. Re-renders the off-screen CLONE only (the card on screen is never touched), // carries the card's current sort order along, and returns true so the export re-fits the clone diff --git a/frontend/main.js b/frontend/main.js index c889974..bb5dac0 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -15,6 +15,8 @@ import { seasonSwitchChangesThresholdDefault, currentCategoryMeta, metadata, + LEAGUE, + IS_CAREER, } from "./data.js"; import { selectedPlayers, @@ -23,6 +25,8 @@ import { populateCategoryDependentControls, resetThresholdToCategoryDefault, populateTeamsChecklist, + markAllTeamsSelected, + markNoTeamsSelected, updateTeamsSummary, openTeamsDropdown, closeTeamsDropdown, @@ -38,6 +42,9 @@ import { loadCurrentSlice, closeFiltersDrawer, applyFilters, + syncStatusBars, + openStatusDropdown, + closeStatusDropdown, } from "./filters.js"; import { render, setLogoRelayoutGuard } from "./render.js"; import { exportChartPngWithFooter, sanitizeForFilename } from "./chart-export.js"; @@ -75,9 +82,26 @@ import { // end of every season "change" event itself. let previousSeasonValue = null; +// The Career page has no Season: it shows the Status Filter in its place, a second +// threshold bar (Prospects), and names the first bar after Joined NFL. +function setUpCareerControls() { + if (!IS_CAREER) return; + els.seasonControl.hidden = true; + els.statusControl.hidden = false; + els.thresholdProspectControl.hidden = false; + els.thresholdStatusLabel.hidden = false; + // Joined NFL starts switched off: grey its bar and set the button's label. + syncStatusBars(); + // Teams, Players and Status combine as a union, so with every team lit nothing + // would be dimmed and Status would show nothing. Career starts with no team picked. + markNoTeamsSelected(); +} + async function loadMetadata() { - const res = await fetch("/api/metadata"); - if (!res.ok) throw new Error(`GET /api/metadata → ${res.status}`); + setUpCareerControls(); + const metadataUrl = `${LEAGUE.apiPrefix}/metadata`; + const res = await fetch(metadataUrl); + if (!res.ok) throw new Error(`GET ${metadataUrl} → ${res.status}`); // setMetadata()/setPlayerPool()/setAppliedFilters() replace the original's // direct assignments below — data.js owns that state now, see its header // comment for why. Same assignments, same order, same timing. @@ -85,7 +109,7 @@ async function loadMetadata() { populateCategoryDependentControls(); previousSeasonValue = els.season.value; - populateTeamsChecklist(); + populateTeamsChecklist(els.season.value); attachEvents(); await loadCurrentSlice(); setPlayerPool(currentRecords, currentSliceCategory); @@ -113,7 +137,7 @@ async function loadMetadata() { // // A "TPS "-prefixed metric additionally gets the shared TPS_NOTE appended // as a second paragraph (`extraText`) — that text lives once, server-side -// (config.py's TPS_NOTE, exposed at metadata.tps_note), not duplicated into +// (config/metrics.py's TPS_NOTE, exposed at metadata.tps_note), not duplicated into // every TPS metric's own note, so the "is this a TPS metric" check is done // here, off the metric's own name, rather than baked into per-metric data. function axisFormulaContent(selectEl) { @@ -130,7 +154,12 @@ function axisFormulaContent(selectEl) { function attachEvents() { els.playersInfoSlot.appendChild( createInfoPopover( - "Teams and Players combine as a union: a player is highlighted if either his team is chosen, or his name is selected.", + { + text: IS_CAREER + ? "Teams, Players and Status combine as a union — highlights a player if his:" + : "Teams and Players combine as a union — highlights a player if his:", + bullets: ["Team is chosen.", "OR", "Name is selected.", ...(IS_CAREER ? ["OR", "Status is lit."] : [])], + }, { ariaLabel: "Players filter info", label: "ⓘ" } ) ); @@ -173,6 +202,41 @@ function attachEvents() { ) ); + if (IS_CAREER) { + els.statusInfoSlot.appendChild( + createInfoPopover( + { + bullets: [ + "Prospects: still playing in the latest college season.", + "Joined NFL: played NCAAF and appeared in NFL — prospects' benchmark.", + "Enable Status: off, and Status leaves the highlights — only Teams and Players light players.", + ], + }, + { ariaLabel: "Status filter info", label: "ⓘ" } + ) + ); + + els.statusBtn.addEventListener("click", () => { + if (els.statusDropdown.hidden) openStatusDropdown(); + else closeStatusDropdown(); + }); + + // Same e.isTrusted guard as the Teams dropdown: the PNG export's synthetic + // anchor click must not shut it. + document.addEventListener("click", (e) => { + if (!e.isTrusted) return; + if (els.statusDropdown.hidden) return; + if (!els.statusControl.contains(e.target)) closeStatusDropdown(); + }); + + [els.statusEnabled, els.statusJoined, els.statusProspect].forEach((box) => + box.addEventListener("change", () => { + syncStatusBars(); + updatePendingState(); + }) + ); + } + els.teamsInfoSlot.appendChild( createInfoPopover( "Select teams to highlight their players in the plot. Everyone else stays in the comparison pool, so percentiles don't change.", @@ -216,6 +280,7 @@ function attachEvents() { // compares against what's actually on screen now, not a stale value // from before this category switch. previousSeasonValue = els.season.value; + populateTeamsChecklist(els.season.value); updatePendingState(); updatePlayerPool(); }); @@ -254,6 +319,8 @@ function attachEvents() { setResetThresholdOnNextRange(true); } previousSeasonValue = els.season.value; + // The NCAAF checklist groups schools by that season's conferences. + populateTeamsChecklist(els.season.value); updatePendingState(); updatePlayerPool(); }); @@ -275,12 +342,14 @@ function attachEvents() { els.teamsSelectAll.addEventListener("click", () => { els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => (cb.checked = true)); + markAllTeamsSelected(); updateTeamsSummary(); updatePendingState(); }); els.teamsSelectNone.addEventListener("click", () => { els.teamsChecklist.querySelectorAll("input[type=checkbox]").forEach((cb) => (cb.checked = false)); + markNoTeamsSelected(); updateTeamsSummary(); updatePendingState(); }); @@ -300,6 +369,28 @@ function attachEvents() { if (!els.teamsControl.contains(e.target)) closeTeamsDropdown(); }); + // League navigation: on narrow screens the three pages sit behind a hamburger + // button (CSS shows it below 860px). It closes on Escape and on a click outside, + // with the same e.isTrusted guard as the dropdowns here, so the PNG export's + // synthetic click cannot shut it. + const setLeagueNavOpen = (open) => { + els.leagueNav.classList.toggle("is-open", open); + els.leagueNavToggle.setAttribute("aria-expanded", String(open)); + }; + + els.leagueNavToggle.addEventListener("click", () => { + setLeagueNavOpen(!els.leagueNav.classList.contains("is-open")); + }); + + document.addEventListener("click", (e) => { + if (!e.isTrusted) return; + if (!els.leagueNav.contains(e.target)) setLeagueNavOpen(false); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") setLeagueNavOpen(false); + }); + // Players: collapsed behind a button + panel, same mechanism as Teams — // the chip list stays hidden until opened so it never has to fit inside // the collapsed button's width (see the .control-players comment in @@ -368,55 +459,61 @@ function attachEvents() { // clicks Apply (or presses Enter in the number field). Re-rendering on // every pixel of drag / every keystroke was the whole problem on dense // positions. - els.threshold.addEventListener("input", () => { - els.thresholdNumber.value = els.threshold.value; - // The user just gave an explicit threshold — don't let a pending - // category switch stomp it back to that category's default at Apply - // time (see the resetThresholdOnNextRange comment above its declaration). - setResetThresholdOnNextRange(false); - // Selected Players are deliberately NOT re-checked here: while the slider - // is still being dragged the value is not final. The "change" listener - // below does it once the slider is released. - updatePendingState(); - }); - - // Fires once, on release (or after a keyboard step): the threshold is now a - // committed value, so selected players who no longer qualify are dropped - // and the user is told who (see prunePlayerSelections()). - els.threshold.addEventListener("change", () => { - prunePlayerSelections(); - updatePendingState(); - }); - - els.thresholdNumber.addEventListener("input", () => { - const min = Number(els.threshold.min); - const max = Number(els.threshold.max); - const step = Number(els.threshold.step) || 1; - - let raw = Number(els.thresholdNumber.value); - if (Number.isNaN(raw)) raw = min; - raw = Math.min(Math.max(raw, min), max); - els.thresholdNumber.value = raw; - - // Slider snaps to the nearest step just to keep the handle in sync - // visually — filtering below still uses the exact typed number. - els.threshold.value = min + Math.round((raw - min) / step) * step; - setResetThresholdOnNextRange(false); - // Not re-checking selected Players per keystroke — see the slider's - // "input" handler above; the "change" listener below does it on commit. - updatePendingState(); - }); - - // Fires when the typed value is committed (Enter or leaving the field). - els.thresholdNumber.addEventListener("change", () => { - prunePlayerSelections(); - updatePendingState(); - }); - - els.thresholdNumber.addEventListener("keydown", (e) => { - if (e.key !== "Enter") return; - e.preventDefault(); - applyFilters(); + // The Career page has a second bar (Prospects) wired exactly like the first. + [ + [els.threshold, els.thresholdNumber], + [els.thresholdProspect, els.thresholdProspectNumber], + ].forEach(([slider, number]) => { + slider.addEventListener("input", () => { + number.value = slider.value; + // The user just gave an explicit threshold — don't let a pending + // category switch stomp it back to that category's default at Apply + // time (see the resetThresholdOnNextRange comment above its declaration). + setResetThresholdOnNextRange(false); + // Selected Players are deliberately NOT re-checked here: while the slider + // is still being dragged the value is not final. The "change" listener + // below does it once the slider is released. + updatePendingState(); + }); + + // Fires once, on release (or after a keyboard step): the threshold is now a + // committed value, so selected players who no longer qualify are dropped + // and the user is told who (see prunePlayerSelections()). + slider.addEventListener("change", () => { + prunePlayerSelections(); + updatePendingState(); + }); + + number.addEventListener("input", () => { + const min = Number(slider.min); + const max = Number(slider.max); + const step = Number(slider.step) || 1; + + let raw = Number(number.value); + if (Number.isNaN(raw)) raw = min; + raw = Math.min(Math.max(raw, min), max); + number.value = raw; + + // Slider snaps to the nearest step just to keep the handle in sync + // visually — filtering below still uses the exact typed number. + slider.value = min + Math.round((raw - min) / step) * step; + setResetThresholdOnNextRange(false); + // Not re-checking selected Players per keystroke — see the slider's + // "input" handler above; the "change" listener below does it on commit. + updatePendingState(); + }); + + // Fires when the typed value is committed (Enter or leaving the field). + number.addEventListener("change", () => { + prunePlayerSelections(); + updatePendingState(); + }); + + number.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + applyFilters(); + }); }); els.applyBtn.addEventListener("click", applyFilters); @@ -619,6 +716,6 @@ function attachEvents() { loadMetadata().catch((err) => { console.error(err); els.chart.innerHTML = - `

Could not load /api/metadata — ` + + `

Could not load ${LEAGUE.apiPrefix}/metadata — ` + `is the FastAPI server running? (${err.message})

`; }); diff --git a/frontend/merge-card.js b/frontend/merge-card.js index 78a58e0..0bb5fbe 100644 --- a/frontend/merge-card.js +++ b/frontend/merge-card.js @@ -50,7 +50,12 @@ import { findRecordByPlayer, thresholdFieldLabel, metadata, + teamLabel, + appliedThreshold, + thresholdClauses, + IS_CAREER, } from "./data.js"; +import { meetsThreshold } from "./pinned-impact.js"; import { searchPlayersExcluding, pcsSearchPool, fullPcsSearchPool } from "./search.js"; import { MERGE_CARD_MAX_MEMBERS, MERGE_QUOTA } from "./config.js"; import { openBelowThresholdPopup, applyThresholdImmediately, BELOW_THRESHOLD_TOP_K } from "./filters.js"; @@ -67,7 +72,7 @@ import { updateScoutEmptyHint, withTrailingPeriod, } from "./cards-base.js"; -import { toggleLinemateCard, closeLinemateCard, attachAppTooltip } from "./linemate-card.js"; +import { toggleLinemateCard, closeLinemateCard, attachAppTooltip, rosterNoun } from "./linemate-card.js"; import { renderPlayerCardsSpace } from "./workspace.js"; import { attachCardSave, sanitizeForFilename } from "./card-export.js"; import { categoryAppendixNotes } from "./metric-notes.js"; @@ -224,8 +229,8 @@ export function renderCreateDropdown(matches) { toggle.className = "below-threshold-toggle"; toggle.textContent = `Below Threshold Matches (${createBelowThresholdMatches.length})`; toggle.addEventListener("click", () => - openBelowThresholdPopup(createBelowThresholdMatches, appliedCategoryMeta(), (value) => { - applyThresholdImmediately(value); + openBelowThresholdPopup(createBelowThresholdMatches, appliedCategoryMeta(), (value, record) => { + applyThresholdImmediately(value, record); runCreateSearch(); }) ); @@ -252,7 +257,7 @@ export function renderCreateDropdown(matches) { logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); const code = document.createElement("span"); - code.textContent = record.team; + code.textContent = teamLabel(record.team); team.append(logo, code); opt.append(name, team); @@ -278,7 +283,7 @@ export function runCreateSearch() { // player instead). const cat = appliedCategoryMeta(); const fullMatches = searchPlayersExcluding(query, fullPcsSearchPool(), new Set(), BELOW_THRESHOLD_TOP_K); - createBelowThresholdMatches = fullMatches.filter((r) => r[cat.threshold_field] < Number(appliedFilters.threshold)); + createBelowThresholdMatches = fullMatches.filter((r) => !meetsThreshold(r, cat.threshold_field, appliedThreshold())); // No exclusion set — an already-picked player is caught (and explained) in // addCreateMember() instead, same "blocked, with a prompt" convention the @@ -373,7 +378,7 @@ export function makeTeamCell(record) { logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); const code = document.createElement("span"); - code.textContent = record.team; + code.textContent = teamLabel(record.team); wrap.append(logo, code); td.appendChild(wrap); @@ -389,7 +394,7 @@ export function makeLinemateCell(record) { btn.type = "button"; btn.className = "merge-row-linemate-toggle"; btn.innerHTML = ''; - btn.setAttribute("aria-label", `Linemates for ${record.player}`); + btn.setAttribute("aria-label", `${rosterNoun().card} for ${record.player}`); btn.addEventListener("click", () => toggleLinemateCard(record)); td.appendChild(btn); return td; @@ -401,9 +406,10 @@ export function makeLinemateCell(record) { // (never the raw stat), same reasoning as Linemate Card's own sort value fn. function mergeSortValue({ record, pool }, label, cat) { if (label === "Player") return (record.abbr_name || record.player).toLowerCase(); - if (label === "Team") return record.team; + if (label === "Team") return String(teamLabel(record.team)).toLowerCase(); + if (label === "Status") return record.status ?? null; if (label === "Games") return record.games ?? null; - if (label === cat.threshold_field) return record[cat.threshold_field] ?? null; + if (label === cat.threshold_field || label === `TPS ${cat.threshold_field}`) return record[label] ?? null; const meta = cat.metrics[label]; if (meta) { const rank = rankAndPercentile(pool, label, meta.higher_is_better, record[label]); @@ -444,10 +450,12 @@ function makeDataCell({ player: { record, pool }, column }, cat) { if (column.kind === "name") { td.className = "merge-table-name"; td.textContent = record.abbr_name || record.player; + } else if (column.kind === "status") { + td.textContent = record.status ?? "—"; } else if (column.kind === "games") { td.textContent = record.games ?? "—"; } else if (column.kind === "snaps") { - td.textContent = record[cat.threshold_field] ?? "—"; + td.textContent = record[column.key] ?? "—"; } else { const key = column.key; const meta = cat.metrics[key]; @@ -480,7 +488,7 @@ function renderSortTable(table, layout, cat, sortState, rerender) { layout.head.forEach((column) => { if (column.kind === "linemates") { const th = document.createElement("th"); - th.textContent = column.key; + th.textContent = rosterNoun().card; headRow.appendChild(th); } else { headRow.appendChild(makeSortableHeader(column.key, column.key, sortState, rerender)); @@ -587,7 +595,7 @@ export function renderMergeCardBody(cardEl, memberRecords, sortState, mode = TAB const sharedPosition = pools.every((p) => p.record.position === pools[0].record.position) ? pools[0].record.position : null; - const thresholdClause = `with ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}`; + const thresholdClause = `with ${thresholdClauses(appliedThreshold(), thresholdFieldLabel(cat)).join(" / ")}`; poolEl.textContent = sharedPosition ? `Percentiles calculated among ${pools[0].pool.length} ${sharedPosition} ${thresholdClause}.` : `Percentiles calculated among ${pools @@ -607,7 +615,7 @@ export function renderMergeCardBody(cardEl, memberRecords, sortState, mode = TAB const sortedPools = sortRows(pools, sortState, (p) => mergeSortValue(p, sortState.key, cat)); const metricKeys = Object.keys(cat.metrics); const bonusKeys = metricKeys.filter((key) => cat.metrics[key].bonus); - const model = buildPlayerTableModel(sortedPools, cat.threshold_field, metricKeys, bonusKeys); + const model = buildPlayerTableModel(sortedPools, cat.threshold_field, metricKeys, bonusKeys, undefined, IS_CAREER); table.innerHTML = ""; table.classList.toggle("merge-table--read", isRead); @@ -947,8 +955,8 @@ export function renderMergeEditDropdown(matches) { toggle.className = "below-threshold-toggle"; toggle.textContent = `Below Threshold Matches (${editBelowThresholdMatches.length})`; toggle.addEventListener("click", () => - openBelowThresholdPopup(editBelowThresholdMatches, appliedCategoryMeta(), (value) => { - applyThresholdImmediately(value); + openBelowThresholdPopup(editBelowThresholdMatches, appliedCategoryMeta(), (value, record) => { + applyThresholdImmediately(value, record); runMergeEditSearch(); }) ); @@ -975,7 +983,7 @@ export function renderMergeEditDropdown(matches) { logo.onerror = () => logo.replaceWith(teamSwatch(record.team)); const code = document.createElement("span"); - code.textContent = record.team; + code.textContent = teamLabel(record.team); team.append(logo, code); opt.append(name, team); @@ -1000,7 +1008,7 @@ export function runMergeEditSearch() { // Same two-independent-passes shape as runPlayersSearch() (filters.js). const cat = appliedCategoryMeta(); const fullMatches = searchPlayersExcluding(query, fullPcsSearchPool(), new Set(), BELOW_THRESHOLD_TOP_K); - editBelowThresholdMatches = fullMatches.filter((r) => r[cat.threshold_field] < Number(appliedFilters.threshold)); + editBelowThresholdMatches = fullMatches.filter((r) => !meetsThreshold(r, cat.threshold_field, appliedThreshold())); // No exclusion set — an already-on-this-card pick is caught (and // explained) defensively in addMergeMember() rather than hidden from the diff --git a/frontend/metric-notes.js b/frontend/metric-notes.js index 8c9d961..cee4b3d 100644 --- a/frontend/metric-notes.js +++ b/frontend/metric-notes.js @@ -13,13 +13,13 @@ * ever covers metrics that actually have a defined formula/note. * * Deduped by note TEXT, not by metric key: a metric and its "TPS " - * counterpart intentionally share the same note (config.py's + * counterpart intentionally share the same note (config/metrics.py's * HAVOC_RATE_NOTE/PRP_NOTE/PBE_NOTE convention, same one * frontend/main.js's axisFormulaContent() already relies on for the axis * popovers), so this collapses them to one line for free rather than * needing a separate "is this a TPS duplicate" check. * - * `tpsNote` (config.py's TPS_NOTE, exposed at metadata.tps_note) is a + * `tpsNote` (config/metrics.py's TPS_NOTE, exposed at metadata.tps_note) is a * shared concept, not a per-metric formula — appended once, last, whenever * ANY of the given metricKeys is a "TPS "-prefixed variant, never * duplicated per TPS metric and never included when none of the export's diff --git a/frontend/pinned-impact.js b/frontend/pinned-impact.js index a14b811..1436139 100644 --- a/frontend/pinned-impact.js +++ b/frontend/pinned-impact.js @@ -15,6 +15,9 @@ * * applied / pending: { category, season, position, threshold } — `pending.threshold` * must be the FINAL threshold (already clamped / defaulted for the new season). + * A threshold is a number on a Season page, or { joined, prospect } on the + * Career page, where a player is judged against the bar of his own status + * (see thresholdSpec()). * pinnedPlayers: iterable of player strings (Single Cards ∪ Merge Card members). * playerPoolRecords: records of the pending category + season (every position). * thresholdField: the category's threshold column ("PR Opp" / "Non Spike PB Snaps"). @@ -35,6 +38,47 @@ */ export const COMPARISON_MIN_MEMBERS = 2; +export const JOINED_NFL = "Joined NFL"; +export const PROSPECTS = "Prospects"; + +// A threshold is a plain number on a Season page. The Career page has two bars, +// one per status, so there it is { joined, prospect }. `prospectThreshold` is +// undefined on a Season page, which keeps the number. +export function thresholdSpec(threshold, prospectThreshold) { + if (prospectThreshold == null) return Number(threshold); + return { joined: Number(threshold), prospect: Number(prospectThreshold) }; +} + +// A spec may arrive as a number, a numeric string or an object. +function normalizeSpec(spec) { + return spec !== null && typeof spec === "object" ? spec : Number(spec); +} + +// The bar this record is judged against: the single number, or the one of the +// record's own status. +export function thresholdOf(record, spec) { + const normalized = normalizeSpec(spec); + if (typeof normalized === "number") return normalized; + return record.status === PROSPECTS ? normalized.prospect : normalized.joined; +} + +// The one inclusive rule behind every "clears the threshold" check: the record +// has a value in `field` and it is at least its own bar. +export function meetsThreshold(record, field, spec) { + const value = record[field]; + return value != null && value >= thresholdOf(record, spec); +} + +// True when `next` is stricter than `previous` for anyone: a higher number, or on +// the Career page either bar higher. +export function thresholdRaised(next, previous) { + const a = normalizeSpec(next); + const b = normalizeSpec(previous); + if (typeof a === "number" && typeof b === "number") return a > b; + if (typeof a === "number" || typeof b === "number") return true; + return a.joined > b.joined || a.prospect > b.prospect; +} + const NO_COMPARISONS = { shrunk: [], dissolved: [] }; // Who stays and who goes: a player is eligible when they exist in `records` @@ -53,8 +97,7 @@ export function partitionByEligibility(names, records, position, threshold, thre const removed = []; Array.from(names).forEach((name) => { const record = byPlayer.get(name); - const value = record ? record[thresholdField] : null; - if (value == null || value < threshold) removed.push(name); + if (!record || !meetsThreshold(record, thresholdField, threshold)) removed.push(name); else kept.push(name); }); return { kept, removed }; @@ -70,11 +113,7 @@ export function partitionByEligibility(names, records, position, threshold, thre // or null when the card should close. export function resolveCarriedRecord(records, name, preferredPosition, linePositions, threshold, thresholdField) { const matches = (records || []).filter( - (r) => - r.player === name && - linePositions.includes(r.position) && - r[thresholdField] != null && - r[thresholdField] >= threshold + (r) => r.player === name && linePositions.includes(r.position) && meetsThreshold(r, thresholdField, threshold) ); return matches.find((r) => r.position === preferredPosition) || matches[0] || null; } @@ -109,11 +148,12 @@ export function evaluatePinnedImpact( return { outcome: "reset", kept: [], removed: pinned, comparisons: NO_COMPARISONS }; } - const seasonChanged = Number(applied.season) !== Number(pending.season); - const pendingThreshold = Number(pending.threshold); + // String(), not Number(): the Career page's one "season" is the word Career. + const seasonChanged = String(applied.season) !== String(pending.season); + const pendingThreshold = normalizeSpec(pending.threshold); // Same season and a looser (or equal) bar can't remove anyone. - if (!seasonChanged && pendingThreshold <= Number(applied.threshold)) { + if (!seasonChanged && !thresholdRaised(pendingThreshold, applied.threshold)) { return { outcome: "unaffected", kept: pinned, removed: [], comparisons: NO_COMPARISONS }; } diff --git a/frontend/player-table-model.js b/frontend/player-table-model.js index 9d1d27d..6d28c8a 100644 --- a/frontend/player-table-model.js +++ b/frontend/player-table-model.js @@ -5,15 +5,15 @@ * * A model is { players, columns }. `players` are the card's members in the order * they should appear (already sorted by the card's current sort state); each - * entry is opaque to this file. `columns` are Player, Team, Linemates, Games, - * the threshold field, then one per metric, each tagged with a `kind`. + * entry is opaque to this file. `columns` are Player, Team, Linemates, Status (Career only), Games, + * the threshold field and its TPS counterpart, then one per metric, each tagged with a `kind`. * * Both layouts hold exactly the same cells ({ player, column }), laid out as * { head, body }: * Sort — one header row of columns, one body row per player. The original layout. * Read — the same cells turned on their side: one column per player, one row * per column. The player's identity cells (name, team, Linemates button) - * form the head block; Games, the threshold field and the metrics are + * form the head block; Status, Games, the threshold field, its TPS counterpart and the metrics are * the body rows. Easier to read, and the default. */ @@ -35,12 +35,17 @@ export function buildPlayerTableModel( thresholdField, metricKeys, bonusKeys = [], - identityColumns = MERGE_IDENTITY_COLUMNS + identityColumns = MERGE_IDENTITY_COLUMNS, + includeStatus = false ) { const columns = [ ...identityColumns.map((column) => ({ ...column, identity: true })), + // Career only: whether the player is a Joined NFL or a Prospect. + ...(includeStatus ? [{ key: "Status", kind: "status" }] : []), { key: "Games", kind: "games" }, { key: thresholdField, kind: "snaps" }, + // Its true-pass-set counterpart ("TPS PR Opp"), right under it. + { key: `TPS ${thresholdField}`, kind: "snaps" }, ...metricKeys.map((key) => ({ key, kind: "metric", bonus: bonusKeys.includes(key) })), ]; return { players, columns }; @@ -61,14 +66,14 @@ export function sortLayout(model) { // that column's cell for every player in the model's order. // // `body` is the flat list of rows; `sections` is the same rows grouped for display — -// Metadata (Games, the threshold field), Required Metrics, and Bonus Metrics, which +// Metadata (Status on Career, Games, the threshold field and its TPS counterpart), Required Metrics, and Bonus Metrics, which // is left out when the category has none (pass block). export function readLayout(model) { const row = (column) => ({ column, cells: model.players.map((player) => ({ player, column })) }); const body = model.columns.filter((column) => !column.identity).map(row); const section = (title, keep) => ({ title, rows: body.filter((r) => keep(r.column)) }); const sections = [ - section("Metadata", (c) => c.kind === "games" || c.kind === "snaps"), + section("Metadata", (c) => c.kind === "status" || c.kind === "games" || c.kind === "snaps"), section("Required Metrics", (c) => c.kind === "metric" && !c.bonus), section("Bonus Metrics", (c) => c.kind === "metric" && c.bonus), ].filter((s) => s.rows.length > 0); diff --git a/frontend/render.js b/frontend/render.js index d6e64c8..36c890e 100644 --- a/frontend/render.js +++ b/frontend/render.js @@ -22,7 +22,13 @@ import { targetLogoPx, thresholdFieldLabel, metadata, + LEAGUE, + IS_CAREER, + appliedThreshold, + thresholdPhrase, + litStatuses, } from "./data.js"; +import { meetsThreshold, JOINED_NFL, PROSPECTS } from "./pinned-impact.js"; import { DIM_OPACITY, LABEL_ALPHA } from "./config.js"; import { viewFloatingCard } from "./scout-card.js"; import { collectMetricNotes } from "./metric-notes.js"; @@ -221,18 +227,30 @@ export function applyLabelEmphasis(chartDiv, spotlightNames) { }); } -// Plain "N players with at least X ." line. Teams/Players only dim -// (see the isDimmed comment in render()), so the on-screen point count never -// changes with them and the subtitle doesn't mention the highlight at all. -export function highlightSubtitle(cat, records, minThreshold) { +// A count line. On a Season page it is a plain "N players with at least X ." On the +// Career page it is two lines — Prospects first, then Joined NFL — because the two +// share one pool but each clears its own bar, so each status gets its own count and bar. +// Teams/Players only dim (see the isDimmed comment in render()), so the on-screen point count +// never changes with them and the subtitle doesn't mention the highlight at all. +export function highlightSubtitle(cat, records, threshold) { const fieldLabel = thresholdFieldLabel(cat); - return `${records.length} players with at least ${minThreshold} ${fieldLabel}.`; + + if (IS_CAREER) { + const countFor = (status) => records.filter((r) => r.status === status).length; + + const prospectLine = `${countFor(PROSPECTS)} ${PROSPECTS} with at least ${thresholdPhrase(threshold.prospect)} ${fieldLabel}.`; + const joinedLine = `${countFor(JOINED_NFL)} ${JOINED_NFL} with at least ${thresholdPhrase(threshold.joined)} ${fieldLabel}.`; + + return `${prospectLine}
${joinedLine}`; + } + + return `${records.length} players with at least ${thresholdPhrase(threshold)} ${fieldLabel}.`; } export function render() { const cat = appliedCategoryMeta(); - const minThreshold = Number(appliedFilters.threshold); + const threshold = appliedThreshold(); const selectedTeams = new Set(appliedFilters.teams ? appliedFilters.teams.split(",") : []); const selectedPlayerKeys = new Set(appliedFilters.players ? appliedFilters.players.split(",") : []); @@ -246,14 +264,28 @@ export function render() { // reassign an imported `let`. Every `currentFiltered` read below this // point sees the update immediately — imported bindings are live. setCurrentFiltered( - currentRecords.filter((r) => r.position === appliedFilters.position && r[cat.threshold_field] >= minThreshold) + currentRecords.filter( + (r) => r.position === appliedFilters.position && meetsThreshold(r, cat.threshold_field, threshold) + ) ); // 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))); + // String(): the NCAAF team key is a number, the checklist values are strings. + // Career only: the Status Filter joins the same union. A player is lit when his + // team is chosen, or he is picked, or his status is lit; a status that is off + // lights nobody by itself and never takes a player out of the pool. + const litStatusSet = litStatuses(appliedFilters.statuses); + const isDimmed = currentFiltered.map( + (r) => + !( + selectedTeams.has(String(r.team)) || + selectedPlayerKeys.has(r.player) || + (IS_CAREER && litStatusSet.has(r.status)) + ) + ); if (currentFiltered.length < 2) { els.emptyState.hidden = false; @@ -419,8 +451,8 @@ export function render() { // 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, minThreshold); + const titleText = `${appliedFilters.season} ${LEAGUE.name} ${positionLabel} ${xKey} & ${yKey}`; + const subtitleText = highlightSubtitle(cat, currentFiltered, threshold); const layout = { paper_bgcolor: "transparent", diff --git a/frontend/scout-card.js b/frontend/scout-card.js index 5e10937..8ee8f50 100644 --- a/frontend/scout-card.js +++ b/frontend/scout-card.js @@ -9,9 +9,13 @@ import { rankAndPercentile, teamColor, teamName, + teamLabel, logoSrc, thresholdFieldLabel, metadata, + appliedThreshold, + thresholdClauses, + IS_CAREER, } from "./data.js"; import { ordinal, @@ -25,7 +29,7 @@ import { endScoutDrag, updateScoutEmptyHint, } from "./cards-base.js"; -import { toggleLinemateCard, setPlayerCardOpener } from "./linemate-card.js"; +import { toggleLinemateCard, setPlayerCardOpener, rosterNoun } from "./linemate-card.js"; import { attachCardSave, sanitizeForFilename } from "./card-export.js"; import { categoryAppendixNotes } from "./metric-notes.js"; import { DISPLAY_DECIMALS } from "./config.js"; @@ -83,7 +87,9 @@ export function renderScoutCardStats(cardEl, record) { poolEl.textContent = `Percentiles calculated among ${pool.length} ${record.position}.`; const thresholdEl = cardEl.querySelector(".scout-card-threshold"); - thresholdEl.textContent = `Threshold: ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`; + // One line per status on Career (the note's CSS keeps the line break), one line otherwise. + const thresholdLines = thresholdClauses(appliedThreshold(), thresholdFieldLabel(cat)).map((clause) => `${clause}.`); + thresholdEl.textContent = `Threshold: ${thresholdLines.join("\n")}`; // 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 @@ -132,8 +138,12 @@ export function renderScoutCardStats(cardEl, record) { // 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. const metadataGrid = addSection("Metadata"); + if (IS_CAREER) addRow(metadataGrid, "Status", record.status); + addRow(metadataGrid, "Games", record.games); addRow(metadataGrid, cat.threshold_field, record[cat.threshold_field]); + // The true-pass-set share of those snaps, right under it. + addRow(metadataGrid, `TPS ${cat.threshold_field}`, record[`TPS ${cat.threshold_field}`]); // `bonus` metrics (Pass Cov Percent, DL only) get their own section after // the required ones; a category without any (pass block) gets no Bonus heading. @@ -178,13 +188,13 @@ function setScoutCardIdentity(cardEl, record) { const badge = cardEl.querySelector(".scout-badge"); const color = teamColor(record.team); logoImg.src = logoSrc(record.team); - logoImg.alt = `${record.team} logo`; + logoImg.alt = `${teamLabel(record.team)} logo`; logoImg.hidden = false; badge.hidden = true; logoImg.onerror = () => { logoImg.hidden = true; badge.hidden = false; - badge.textContent = record.team; + badge.textContent = teamLabel(record.team); badge.style.background = color; }; cardEl.querySelector(".scout-meta").textContent = @@ -233,6 +243,13 @@ export function openScoutCard(record) { // entry.record, not the closed-over `record`: retargetScoutCard() swaps it // when a season change carries this card to another season's row. linemateBtn.addEventListener("click", () => toggleLinemateCard(entry.record)); + + if (IS_CAREER) { + // On the Career page the button and the card it opens are "NFL Alumni" (see linemate-card.js). + const noun = rosterNoun().card; + linemateBtn.setAttribute("aria-label", `Show ${noun}`); + linemateBtn.lastChild.textContent = ` ${noun}`; + } // abbr_name shortens the first name to an initial, keeping the last name // intact (e.g. "D. Hall") — same convention already shown on every card // title/table cell, just applied to the downloaded filename too. diff --git a/frontend/search.js b/frontend/search.js index 7f2bfc6..72980f9 100644 --- a/frontend/search.js +++ b/frontend/search.js @@ -20,7 +20,9 @@ import { positionPool, appliedFilters, currentRecords, + pendingThreshold, } from "./data.js"; +import { meetsThreshold } from "./pinned-impact.js"; export const NAME_SUFFIXES = new Set(["Jr.", "Jr", "II", "III", "IV", "V", "Sr.", "Sr"]); @@ -171,8 +173,10 @@ export function scorePlayerAgainstQuery(queryTokens, playerRecord) { export 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); + const threshold = pendingThreshold(); + return playerPoolRecords.filter( + (r) => r.position === els.position.value && meetsThreshold(r, cat.threshold_field, threshold) + ); } // Same pool as qualifyingPlayerPool() but without the threshold cut — the diff --git a/frontend/style.css b/frontend/style.css index fe22a12..95e1c90 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -81,8 +81,9 @@ body { z-index: 1; } +/* The top 64px hold the page switch (left) and the Install and GitHub buttons (right). */ .hero { - padding: 28px 24px 20px; + padding: 64px 24px 20px; } .hero-inner { @@ -183,6 +184,131 @@ body { } } +/* Page switch (NFL Season / NCAAF Season / ...): plain links, one URL each, the + current page highlighted. Inline on wide screens; below 860px the links hide + behind a hamburger button that opens a list with the full names (ESPN-style). + Top-left, mirroring the GitHub and Install buttons on the right. */ +.league-nav { + position: absolute; + top: 16px; + left: 20px; + z-index: 3; +} +.league-nav-toggle { + display: none; + align-items: center; + gap: 8px; + height: 36px; + box-sizing: border-box; + padding: 0 12px; + border: 2px solid #f0ede4; + border-radius: 4px; + background: #000; + color: var(--chalk); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + cursor: pointer; + opacity: 0.85; +} +.league-nav-toggle:hover, +.league-nav-toggle:focus-visible, +.league-nav-toggle[aria-expanded="true"] { + opacity: 1; + border-color: var(--flag-gold); +} +/* The three bars of the hamburger. */ +.league-nav-bars, +.league-nav-bars::before, +.league-nav-bars::after { + display: block; + width: 16px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.league-nav-bars { + position: relative; +} +.league-nav-bars::before, +.league-nav-bars::after { + content: ""; + position: absolute; + left: 0; +} +.league-nav-bars::before { + top: -5px; +} +.league-nav-bars::after { + top: 5px; +} +.league-nav-list { + display: flex; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.league-nav-link { + display: inline-flex; + align-items: center; + height: 36px; + box-sizing: border-box; + padding: 0 12px; + border: 2px solid #f0ede4; + border-radius: 4px; + background: #000; + color: var(--chalk); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + text-decoration: none; + opacity: 0.8; + transition: + color 0.15s ease, + opacity 0.15s ease, + border-color 0.15s ease; +} +.league-nav-link:hover, +.league-nav-link:focus-visible { + opacity: 1; + border-color: var(--flag-gold); +} +.league-nav-link.is-current { + opacity: 1; + color: #0f2119; + background: var(--flag-gold); + border-color: var(--flag-gold); +} +/* The installed desktop app puts its window buttons in the top-left corner. */ +@media (display-mode: standalone) and (hover: hover) and (pointer: fine) { + .league-nav { + left: 110px; + } +} +@media (max-width: 860px) { + .league-nav-toggle { + display: inline-flex; + } + .league-nav-list { + display: none; + position: absolute; + top: calc(100% + 6px); + left: 0; + flex-direction: column; + gap: 6px; + min-width: 170px; + padding: 8px; + background: var(--turf-800); + border: 1px solid var(--turf-600); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + } + .league-nav.is-open .league-nav-list { + display: flex; + } +} + .eyebrow { font-family: var(--font-mono); font-size: 12px; @@ -397,14 +523,16 @@ body { gap: 10px; } -#threshold-slider { +#threshold-slider, +#threshold-prospect-slider { flex: 1 1 auto; width: 100%; accent-color: var(--flag-gold); height: 28px; } -#threshold-number { +#threshold-number, +#threshold-prospect-number { flex: none; width: 70px; background: var(--turf-950); @@ -419,11 +547,14 @@ body { /* Chrome/Safari spinner arrows clash with the turf theme — hide them, the range slider next to it already covers coarse adjustment. */ #threshold-number::-webkit-inner-spin-button, -#threshold-number::-webkit-outer-spin-button { +#threshold-number::-webkit-outer-spin-button, +#threshold-prospect-number::-webkit-inner-spin-button, +#threshold-prospect-number::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } -#threshold-number { +#threshold-number, +#threshold-prospect-number { -moz-appearance: textfield; } @@ -473,12 +604,45 @@ body { padding-right: 4px; } +/* --- Career page: Status Filter and the second threshold bar. --- + The Status Filter takes Season's place in the first row. It is a dropdown of two checkboxes + like Teams (both lit by default); a status that is off only dims its players. + Both bars are always usable: they do not follow the Status Filter. */ +.control-status { + /* Wider than its neighbours: the label row holds the Enable Status switch and the info + icon, and the button holds "Prospects". */ + --w: 190; + min-width: auto; +} +.status-enable-toggle { + gap: 6px; + font-size: 12px; +} +/* A native [hidden] loses to .control's display, so say it again here. */ +.control[hidden] { + display: none; +} +/* Two short rows, so the dropdown is only as wide as its button. */ +.status-dropdown { + width: max-content; + min-width: 100%; +} +.status-dropdown .teams-checklist { + display: flex; + flex-direction: column; + gap: 4px; +} +.status-dropdown .team-option { + white-space: nowrap; +} + /* --- Teams multi-selection. --- Built as a custom button + checkbox-list dropdown rather than a native