┌─ ASTRO ────────────────────────────────────────────────────────────────────┐
│ --- │
│ import "./SpecDistributionChart.scss"; │
│ import type { StatsSpecCountBucket } from "../../../lib/types"; │
│ import { DUNGEON_SLUG_TO_ID } from "../../../lib/dungeon-thresholds"; │
│ import HelpHint from "../../HelpHint/HelpHint.astro"; │
│ │
│ interface Props { │
│ buckets: Record<string, StatsSpecCountBucket>; │
│ // unique scope id so multiple charts on a page can each find their own data isl │
│ and │
│ scope: string; │
│ } │
│ │
│ const { buckets, scope } = Astro.props; │
│ │
│ // Dungeon dropdown options. "all" is the default (overall data); each dungeon │
│ // id maps to a per-dungeon slice via bucket.by_dungeon[id]. │
│ const DUNGEON_OPTIONS: Array<{ key: string; label: string }> = [ │
│ { key: "all", label: "All Dungeons" }, │
│ { key: "2", label: "Temple of the Jade Serpent" }, │
│ { key: "56", label: "Stormstout Brewery" }, │
│ { key: "57", label: "Gate of the Setting Sun" }, │
│ { key: "58", label: "Shado-Pan Monastery" }, │
│ { key: "59", label: "Siege of Niuzao Temple" }, │
│ { key: "60", label: "Mogu'shan Palace" }, │
│ { key: "76", label: "Scholomance" }, │
│ { key: "77", label: "Scarlet Halls" }, │
│ { key: "78", label: "Scarlet Monastery" }, │
│ ]; │
│ │
│ // build-time sanity check against the canonical slug?id map │
│ for (const o of DUNGEON_OPTIONS) { │
│ if (o.key === "all") continue; │
│ if (!Object.values(DUNGEON_SLUG_TO_ID).includes(parseInt(o.key, 10))) { │
│ throw new Error(`Unknown dungeon id ${o.key} in SpecDistributionChart`); │
│ } │
│ } │
│ │
│ const BUCKET_TABS: Array<{ key: string; label: string }> = [ │
│ { key: "all_runs", label: "All Runs" }, │
│ { key: "gold_runs", label: "Gold" }, │
│ { key: "platinum_runs", label: "Platinum" }, │
│ { key: "title_runs", label: "Title" }, │
│ { key: "top_50_runs", label: "Top 50" }, │
│ ]; │
│ │
│ const ROLE_TABS: Array<{ key: string; label: string }> = [ │
│ { key: "all", label: "All Roles" }, │
│ { key: "tank", label: "Tank" }, │
│ { key: "healer", label: "Healer" }, │
│ { key: "dps", label: "DPS" }, │
│ ]; │
│ │
│ // "Picks" counts every spec slot (includes spec stacking, can exceed run count). │
│ // "Runs" counts distinct runs containing the spec (capped at the bucket's │
│ // total_runs). Naming borrows the MOBA pick-rate convention so it's familiar. │
│ const METRIC_OPTIONS: Array<{ key: string; label: string }> = [ │
│ { key: "picks", label: "Picks" }, │
│ { key: "runs", label: "Runs" }, │
│ ]; │
│ │
│ // data-buckets carries all 5 buckets so client can switch without refetching │
│ const dataJson = JSON.stringify(buckets); │
│ --- │
│ │
│ <figure │
│ class="spec-distribution-chart" │
│ data-scope={scope} │
│ data-buckets={dataJson} │
│ > │
│ <div class="spec-distribution-chart__controls"> │
│ <nav │
│ class="spec-distribution-chart__tabs" │
│ role="tablist" │
│ aria-label="Run filter" │
│ > │
│ { │
│ BUCKET_TABS.map((b, i) => ( │
│ <button │
│ type="button" │
│ class:list={["spec-distribution-chart__tab", { active: i === 0 }]} │
│ role="tab" │
│ aria-selected={i === 0 ? "true" : "false"} │
│ data-bucket-key={b.key} │
│ > │
│ {b.label} │
│ </button> │
│ )) │
│ } │
│ </nav> │
│ <div class="spec-distribution-chart__selects"> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-metric-${scope}`}> │
│ Metric │
│ <HelpHint title="Picks vs Runs"> │
│ <p> │
│ <strong>Picks</strong> counts every spec slot. A run with two of the │
│ same spec contributes <strong>2 picks</strong>. │
│ </p> │
│ <p> │
│ <strong>Runs</strong> counts distinct runs containing the spec. A ru │
│ n │
│ with two of the same spec still counts as <strong>1 run</strong> │
│ </p> │
│ </HelpHint> │
│ </label> │
│ <select id={`spec-dist-metric-${scope}`} data-metric-select> │
│ {METRIC_OPTIONS.map((m) => <option value={m.key}>{m.label}</option>)} │
│ </select> │
│ </div> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-role-${scope}`}>Role</label> │
│ <select id={`spec-dist-role-${scope}`} data-role-select> │
│ {ROLE_TABS.map((r) => <option value={r.key}>{r.label}</option>)} │
│ </select> │
│ </div> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-dungeon-${scope}`}>Dungeon</label> │
│ <select id={`spec-dist-dungeon-${scope}`} data-dungeon-select> │
│ {DUNGEON_OPTIONS.map((d) => <option value={d.key}>{d.label}</option>)} │
│ </select> │
│ </div> │
│ </div> │
│ </div> │
│ <div class="spec-distribution-chart__canvas"> │
│ {/* Filled by SpecDistributionChart.ts on mount; kept empty server-side. */} │
│ </div> │
│ </figure> │
│ │
│ <script> │
│ import { initSpecDistributionCharts } from "./SpecDistributionChart.ts"; │
│ initSpecDistributionCharts(); │
│ document.addEventListener("astro:page-load", () => │
│ initSpecDistributionCharts(), │
│ ); │
│ </script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ASTRO ──────────────────────────────┐
│ --- │
│ import "./SpecDistributionChart.scss"; │
│ import type { StatsSpecCountBucket } from ". │
│ ./../../lib/types"; │
│ import { DUNGEON_SLUG_TO_ID } from "../../.. │
│ /lib/dungeon-thresholds"; │
│ import HelpHint from "../../HelpHint/HelpHin │
│ t.astro"; │
│ │
│ interface Props { │
│ buckets: Record<string, StatsSpecCountBuck │
│ et>; │
│ // unique scope id so multiple charts on a │
│ page can each find their own data island │
│ scope: string; │
│ } │
│ │
│ const { buckets, scope } = Astro.props; │
│ │
│ // Dungeon dropdown options. "all" is the de │
│ fault (overall data); each dungeon │
│ // id maps to a per-dungeon slice via bucket │
│ .by_dungeon[id]. │
│ const DUNGEON_OPTIONS: Array<{ key: string; │
│ label: string }> = [ │
│ { key: "all", label: "All Dungeons" }, │
│ { key: "2", label: "Temple of the Jade Ser │
│ pent" }, │
│ { key: "56", label: "Stormstout Brewery" } │
│ , │
│ { key: "57", label: "Gate of the Setting S │
│ un" }, │
│ { key: "58", label: "Shado-Pan Monastery" │
│ }, │
│ { key: "59", label: "Siege of Niuzao Templ │
│ e" }, │
│ { key: "60", label: "Mogu'shan Palace" }, │
│ { key: "76", label: "Scholomance" }, │
│ { key: "77", label: "Scarlet Halls" }, │
│ { key: "78", label: "Scarlet Monastery" }, │
│ ]; │
│ │
│ // build-time sanity check against the canon │
│ ical slug?id map │
│ for (const o of DUNGEON_OPTIONS) { │
│ if (o.key === "all") continue; │
│ if (!Object.values(DUNGEON_SLUG_TO_ID).inc │
│ ludes(parseInt(o.key, 10))) { │
│ throw new Error(`Unknown dungeon id ${o. │
│ key} in SpecDistributionChart`); │
│ } │
│ } │
│ │
│ const BUCKET_TABS: Array<{ key: string; labe │
│ l: string }> = [ │
│ { key: "all_runs", label: "All Runs" }, │
│ { key: "gold_runs", label: "Gold" }, │
│ { key: "platinum_runs", label: "Platinum" │
│ }, │
│ { key: "title_runs", label: "Title" }, │
│ { key: "top_50_runs", label: "Top 50" }, │
│ ]; │
│ │
│ const ROLE_TABS: Array<{ key: string; label: │
│ string }> = [ │
│ { key: "all", label: "All Roles" }, │
│ { key: "tank", label: "Tank" }, │
│ { key: "healer", label: "Healer" }, │
│ { key: "dps", label: "DPS" }, │
│ ]; │
│ │
│ // "Picks" counts every spec slot (includes │
│ spec stacking, can exceed run count). │
│ // "Runs" counts distinct runs containing th │
│ e spec (capped at the bucket's │
│ // total_runs). Naming borrows the MOBA pick │
│ -rate convention so it's familiar. │
│ const METRIC_OPTIONS: Array<{ key: string; l │
│ abel: string }> = [ │
│ { key: "picks", label: "Picks" }, │
│ { key: "runs", label: "Runs" }, │
│ ]; │
│ │
│ // data-buckets carries all 5 buckets so cli │
│ ent can switch without refetching │
│ const dataJson = JSON.stringify(buckets); │
│ --- │
│ │
│ <figure │
│ class="spec-distribution-chart" │
│ data-scope={scope} │
│ data-buckets={dataJson} │
│ > │
│ <div class="spec-distribution-chart__contr │
│ ols"> │
│ <nav │
│ class="spec-distribution-chart__tabs" │
│ role="tablist" │
│ aria-label="Run filter" │
│ > │
│ { │
│ BUCKET_TABS.map((b, i) => ( │
│ <button │
│ type="button" │
│ class:list={["spec-distribution- │
│ chart__tab", { active: i === 0 }]} │
│ role="tab" │
│ aria-selected={i === 0 ? "true" │
│ : "false"} │
│ data-bucket-key={b.key} │
│ > │
│ {b.label} │
│ </button> │
│ )) │
│ } │
│ </nav> │
│ <div class="spec-distribution-chart__sel │
│ ects"> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-metric-${scop │
│ e}`}> │
│ Metric │
│ <HelpHint title="Picks vs Runs"> │
│ <p> │
│ <strong>Picks</strong> counts │
│ every spec slot. A run with two of the │
│ same spec contributes <strong> │
│ 2 picks</strong>. │
│ </p> │
│ <p> │
│ <strong>Runs</strong> counts d │
│ istinct runs containing the spec. A run │
│ with two of the same spec stil │
│ l counts as <strong>1 run</strong> │
│ </p> │
│ </HelpHint> │
│ </label> │
│ <select id={`spec-dist-metric-${scop │
│ e}`} data-metric-select> │
│ {METRIC_OPTIONS.map((m) => <option │
│ value={m.key}>{m.label}</option>)} │
│ </select> │
│ </div> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-role-${scope} │
│ `}>Role</label> │
│ <select id={`spec-dist-role-${scope} │
│ `} data-role-select> │
│ {ROLE_TABS.map((r) => <option valu │
│ e={r.key}>{r.label}</option>)} │
│ </select> │
│ </div> │
│ <div class="filter-group"> │
│ <label for={`spec-dist-dungeon-${sco │
│ pe}`}>Dungeon</label> │
│ <select id={`spec-dist-dungeon-${sco │
│ pe}`} data-dungeon-select> │
│ {DUNGEON_OPTIONS.map((d) => <optio │
│ n value={d.key}>{d.label}</option>)} │
│ </select> │
│ </div> │
│ </div> │
│ </div> │
│ <div class="spec-distribution-chart__canva │
│ s"> │
│ {/* Filled by SpecDistributionChart.ts o │
│ n mount; kept empty server-side. */} │
│ </div> │
│ </figure> │
│ │
│ <script> │
│ import { initSpecDistributionCharts } from │
│ "./SpecDistributionChart.ts"; │
│ initSpecDistributionCharts(); │
│ document.addEventListener("astro:page-load │
│ ", () => │
│ initSpecDistributionCharts(), │
│ ); │
│ </script> │
└──────────────────────────────────────────────┘