┌─ TS ───────────────────────────────────────────────────────────────────────┐
│ // Vertical-bar spec-distribution chart. Server emits the data + tab buttons, │
│ // this script renders the SVG and wires up the bucket switcher + hover tooltips. │
│ │
│ import { getSpecIcon } from "../../../lib/wow-constants"; │
│ │
│ interface SpecEntry { │
│ spec_id: number; │
│ class_name: string; │
│ spec_name: string; │
│ // Total spec slot occurrences (a run with two of the spec contributes 2). │
│ count: number; │
│ // Distinct runs containing this spec (a run with two of the spec contributes 1) │
│ . │
│ runs_with_spec: number; │
│ } │
│ │
│ interface DungeonSpecBucket { │
│ total_runs: number; │
│ entries: SpecEntry[]; │
│ } │
│ │
│ interface SpecBucket { │
│ total_runs: number; │
│ entries: SpecEntry[]; │
│ by_dungeon: Record<string, DungeonSpecBucket>; │
│ } │
│ │
│ // "picks" = total spec slot occurrences (a stacked spec contributes once per slot │
│ ). │
│ // "runs" = distinct runs containing the spec. │
│ type Metric = "picks" | "runs"; │
│ │
│ const SVG_NS = "http://www.w3.org/2000/svg"; │
│ const XLINK_NS = "http://www.w3.org/1999/xlink"; │
│ │
│ // Canonical class+spec ordering (alpha by class, role-grouped within: tank, heale │
│ r, dps). │
│ // `gapAfter` is recomputed per-render based on which specs survive filtering, so │
│ it's │
│ // derived rather than stored here. │
│ type Role = "tank" | "healer" | "dps"; │
│ type Spec = { specId: number; className: string; specName: string; role: Role }; │
│ │
│ const SPECS: Spec[] = [ │
│ // Death Knight │
│ { specId: 250, className: "Death Knight", specName: "Blood", role: "tank" }, │
│ { specId: 251, className: "Death Knight", specName: "Frost", role: "dps" }, │
│ { specId: 252, className: "Death Knight", specName: "Unholy", role: "dps" }, │
│ // Druid │
│ { specId: 104, className: "Druid", specName: "Guardian", role: "tank" }, │
│ { specId: 105, className: "Druid", specName: "Restoration", role: "healer" }, │
│ { specId: 102, className: "Druid", specName: "Balance", role: "dps" }, │
│ { specId: 103, className: "Druid", specName: "Feral", role: "dps" }, │
│ // Hunter │
│ { specId: 253, className: "Hunter", specName: "Beast Mastery", role: "dps" }, │
│ { specId: 254, className: "Hunter", specName: "Marksmanship", role: "dps" }, │
│ { specId: 255, className: "Hunter", specName: "Survival", role: "dps" }, │
│ // Mage │
│ { specId: 62, className: "Mage", specName: "Arcane", role: "dps" }, │
│ { specId: 63, className: "Mage", specName: "Fire", role: "dps" }, │
│ { specId: 64, className: "Mage", specName: "Frost", role: "dps" }, │
│ // Monk │
│ { specId: 268, className: "Monk", specName: "Brewmaster", role: "tank" }, │
│ { specId: 270, className: "Monk", specName: "Mistweaver", role: "healer" }, │
│ { specId: 269, className: "Monk", specName: "Windwalker", role: "dps" }, │
│ // Paladin │
│ { specId: 66, className: "Paladin", specName: "Protection", role: "tank" }, │
│ { specId: 65, className: "Paladin", specName: "Holy", role: "healer" }, │
│ { specId: 70, className: "Paladin", specName: "Retribution", role: "dps" }, │
│ // Priest │
│ { specId: 256, className: "Priest", specName: "Discipline", role: "healer" }, │
│ { specId: 257, className: "Priest", specName: "Holy", role: "healer" }, │
│ { specId: 258, className: "Priest", specName: "Shadow", role: "dps" }, │
│ // Rogue │
│ { specId: 259, className: "Rogue", specName: "Assassination", role: "dps" }, │
│ { specId: 260, className: "Rogue", specName: "Combat", role: "dps" }, │
│ { specId: 261, className: "Rogue", specName: "Subtlety", role: "dps" }, │
│ // Shaman │
│ { specId: 264, className: "Shaman", specName: "Restoration", role: "healer" }, │
│ { specId: 262, className: "Shaman", specName: "Elemental", role: "dps" }, │
│ { specId: 263, className: "Shaman", specName: "Enhancement", role: "dps" }, │
│ // Warlock │
│ { specId: 265, className: "Warlock", specName: "Affliction", role: "dps" }, │
│ { specId: 266, className: "Warlock", specName: "Demonology", role: "dps" }, │
│ { specId: 267, className: "Warlock", specName: "Destruction", role: "dps" }, │
│ // Warrior │
│ { specId: 73, className: "Warrior", specName: "Protection", role: "tank" }, │
│ { specId: 71, className: "Warrior", specName: "Arms", role: "dps" }, │
│ { specId: 72, className: "Warrior", specName: "Fury", role: "dps" }, │
│ ]; │
│ │
│ function classSlug(name: string): string { │
│ return name.toLowerCase().replace(/\s+/g, "-"); │
│ } │
│ │
│ function parseBuckets(el: HTMLElement): Record<string, SpecBucket> { │
│ const raw = el.getAttribute("data-buckets"); │
│ if (!raw) return {}; │
│ try { │
│ return JSON.parse(raw); │
│ } catch { │
│ return {}; │
│ } │
│ } │
│ │
│ function activeBucketKey(container: HTMLElement): string { │
│ const tab = container.querySelector<HTMLButtonElement>( │
│ ".spec-distribution-chart__tab.active", │
│ ); │
│ return tab?.dataset.bucketKey ?? "all_runs"; │
│ } │
│ │
│ function activeRoleKey(container: HTMLElement): string { │
│ const select = │
│ container.querySelector<HTMLSelectElement>("[data-role-select]"); │
│ return select?.value ?? "all"; │
│ } │
│ │
│ function activeMetric(container: HTMLElement): Metric { │
│ const select = container.querySelector<HTMLSelectElement>( │
│ "[data-metric-select]", │
│ ); │
│ return select?.value === "runs" ? "runs" : "picks"; │
│ } │
│ │
│ function activeDungeonKey(container: HTMLElement): string { │
│ const select = container.querySelector<HTMLSelectElement>( │
│ "[data-dungeon-select]", │
│ ); │
│ return select?.value ?? "all"; │
│ } │
│ │
│ // single accessor for the metric value so bar height + tooltip stay in sync │
│ function entryValue(e: SpecEntry, metric: Metric): number { │
│ return metric === "runs" ? e.runs_with_spec : e.count; │
│ } │
│ │
│ function renderChart(container: HTMLElement) { │
│ const canvas = container.querySelector( │
│ ".spec-distribution-chart__canvas", │
│ ) as HTMLElement | null; │
│ if (!canvas) return; │
│ │
│ const buckets = parseBuckets(container); │
│ const bucketKey = activeBucketKey(container); │
│ const roleKey = activeRoleKey(container); │
│ const metric = activeMetric(container); │
│ const dungeonKey = activeDungeonKey(container); │
│ const fullBucket = buckets[bucketKey] ?? { │
│ total_runs: 0, │
│ entries: [], │
│ by_dungeon: {}, │
│ }; │
│ // dungeon picked: slice to its sub-bucket so total_runs and entries share scope │
│ const bucket: { total_runs: number; entries: SpecEntry[] } = │
│ dungeonKey === "all" │
│ ? { total_runs: fullBucket.total_runs, entries: fullBucket.entries } │
│ : (fullBucket.by_dungeon?.[dungeonKey] ?? { total_runs: 0, entries: [] }); │
│ const entries = bucket.entries; │
│ │
│ // build lookup spec_id -> entry for the active bucket │
│ const entryBySpec = new Map<number, SpecEntry>(); │
│ for (const e of entries) { │
│ entryBySpec.set(e.spec_id, e); │
│ } │
│ const valueOf = (specId: number): number => { │
│ const e = entryBySpec.get(specId); │
│ return e ? entryValue(e, metric) : 0; │
│ }; │
│ │
│ // role filter + zero-hide (specs with 0 count drop out entirely) │
│ const visible = SPECS.filter((s) => { │
│ if (roleKey !== "all" && s.role !== roleKey) return false; │
│ return valueOf(s.specId) > 0; │
│ }); │
│ │
│ // recompute class-group breaks on the visible set │
│ const visibleWithGap: Array<Spec & { gapAfter: boolean }> = visible.map( │
│ (s, i) => ({ │
│ ...s, │
│ gapAfter: │
│ i < visible.length - 1 && visible[i + 1].className !== s.className, │
│ }), │
│ ); │
│ │
│ if (visibleWithGap.length === 0) { │
│ canvas.innerHTML = │
│ '<p class="spec-distribution-chart__empty">No data for this filter.</p>'; │
│ return; │
│ } │
│ │
│ // fit container width but keep bars at a readable minimum; │
│ // overflow scrolls horizontally │
│ const containerW = canvas.clientWidth || container.clientWidth || 800; │
│ const minBarW = 14; │
│ const intraGap = 2; │
│ const interGap = 14; │
│ │
│ const naturalW = (() => { │
│ let w = 0; │
│ for (let i = 0; i < visibleWithGap.length; i++) { │
│ w += minBarW; │
│ if (i < visibleWithGap.length - 1) { │
│ w += visibleWithGap[i].gapAfter ? interGap : intraGap; │
│ } │
│ } │
│ return w; │
│ })(); │
│ │
│ const padding = { top: 16, right: 12, bottom: 56, left: 48 }; │
│ // expand barW if container has slack │
│ const innerWAvail = Math.max( │
│ containerW - padding.left - padding.right, │
│ naturalW, │
│ ); │
│ const slackPerBar = (innerWAvail - naturalW) / visibleWithGap.length; │
│ const barW = Math.max(minBarW, minBarW + slackPerBar); │
│ │
│ // re-compute slot positions with the chosen barW │
│ const slotX: number[] = []; │
│ let cursor = padding.left; │
│ for (let i = 0; i < visibleWithGap.length; i++) { │
│ slotX.push(cursor); │
│ cursor += barW; │
│ if (i < visibleWithGap.length - 1) { │
│ cursor += visibleWithGap[i].gapAfter ? interGap : intraGap; │
│ } │
│ } │
│ const innerW = cursor - padding.left; │
│ const totalW = innerW + padding.left + padding.right; │
│ const height = 280; │
│ const innerH = height - padding.top - padding.bottom; │
│ │
│ const maxCount = Math.max(1, ...visibleWithGap.map((s) => valueOf(s.specId))); │
│ // tooltip share %: │
│ // picks: fraction of all spec slots in the visible filter │
│ // runs: fraction of distinct runs in the bucket containing the spec │
│ const shareDenominator = │
│ metric === "runs" │
│ ? bucket.total_runs │
│ : visibleWithGap.reduce((acc, s) => acc + valueOf(s.specId), 0); │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_NS, "svg"); │
│ svg.setAttribute("viewBox", `0 0 ${totalW} ${height}`); │
│ svg.setAttribute("width", String(totalW)); │
│ svg.setAttribute("height", String(height)); │
│ svg.setAttribute("class", "spec-distribution-chart__svg"); │
│ │
│ // Y-axis ticks (5: 0/25/50/75/100) │
│ const yTicks = [0, 0.25, 0.5, 0.75, 1].map((frac) => ({ │
│ y: padding.top + innerH - frac * innerH, │
│ value: Math.round(maxCount * frac), │
│ })); │
│ for (const tick of yTicks) { │
│ const line = document.createElementNS(SVG_NS, "line"); │
│ line.setAttribute("x1", String(padding.left)); │
│ line.setAttribute("x2", String(padding.left + innerW)); │
│ line.setAttribute("y1", String(tick.y)); │
│ line.setAttribute("y2", String(tick.y)); │
│ line.setAttribute("class", "spec-distribution-chart__gridline"); │
│ svg.appendChild(line); │
│ │
│ const label = document.createElementNS(SVG_NS, "text"); │
│ label.setAttribute("x", String(padding.left - 8)); │
│ label.setAttribute("y", String(tick.y + 4)); │
│ label.setAttribute("text-anchor", "end"); │
│ label.setAttribute("class", "spec-distribution-chart__axis-label"); │
│ label.textContent = tick.value.toLocaleString("en-US"); │
│ svg.appendChild(label); │
│ } │
│ │
│ // initialize top/left so the hidden tooltip doesn't inflate scrollHeight │
│ // (otherwise overflow-y:auto would show a phantom vertical scrollbar) │
│ const tooltip = document.createElement("div"); │
│ tooltip.className = "spec-distribution-chart__tooltip"; │
│ tooltip.style.opacity = "0"; │
│ tooltip.style.top = "0"; │
│ tooltip.style.left = "0"; │
│ canvas.style.position = "relative"; │
│ │
│ const iconSize = Math.min(barW + 4, 24); │
│ const iconY = padding.top + innerH + 8; │
│ │
│ // appended before bars so it paints underneath them │
│ const highlight = document.createElementNS(SVG_NS, "rect"); │
│ highlight.setAttribute("y", String(padding.top)); │
│ highlight.setAttribute("height", String(innerH)); │
│ highlight.setAttribute("width", String(barW)); │
│ highlight.setAttribute("class", "spec-distribution-chart__highlight"); │
│ highlight.setAttribute("opacity", "0"); │
│ svg.appendChild(highlight); │
│ │
│ // Pre-compute per-bar metadata for the overlay's nearest-column lookup. │
│ const cols: Array<{ │
│ spec: Spec; │
│ value: number; │
│ x: number; │
│ y: number; │
│ cx: number; │
│ }> = []; │
│ │
│ // Bars + icons │
│ for (let i = 0; i < visibleWithGap.length; i++) { │
│ const s = visibleWithGap[i]; │
│ const value = valueOf(s.specId); │
│ const barH = (value / maxCount) * innerH; │
│ const x = slotX[i]; │
│ const y = padding.top + innerH - barH; │
│ cols.push({ spec: s, value, x, y, cx: x + barW / 2 }); │
│ │
│ // Bar │
│ const rect = document.createElementNS(SVG_NS, "rect"); │
│ rect.setAttribute("x", String(x)); │
│ rect.setAttribute("y", String(y)); │
│ rect.setAttribute("width", String(barW)); │
│ rect.setAttribute("height", String(Math.max(barH, 0.5))); │
│ rect.setAttribute( │
│ "class", │
│ `spec-distribution-chart__bar bar-${classSlug(s.className)}`, │
│ ); │
│ svg.appendChild(rect); │
│ │
│ // Spec icon │
│ const iconURL = getSpecIcon(s.className, s.specName); │
│ if (iconURL) { │
│ const image = document.createElementNS(SVG_NS, "image"); │
│ image.setAttribute("x", String(x + (barW - iconSize) / 2)); │
│ image.setAttribute("y", String(iconY)); │
│ image.setAttribute("width", String(iconSize)); │
│ image.setAttribute("height", String(iconSize)); │
│ image.setAttributeNS(XLINK_NS, "xlink:href", iconURL); │
│ image.setAttribute("href", iconURL); │
│ image.setAttribute("class", "spec-distribution-chart__icon"); │
│ // native title fallback for non-hovering users / screen readers │
│ const title = document.createElementNS(SVG_NS, "title"); │
│ title.textContent = `${s.specName} ${s.className}`; │
│ image.appendChild(title); │
│ svg.appendChild(image); │
│ } │
│ } │
│ │
│ // overlay captures pointer events; we pick the nearest column center so │
│ // tiny bars are easy to hover │
│ const overlay = document.createElementNS(SVG_NS, "rect"); │
│ overlay.setAttribute("x", String(padding.left)); │
│ overlay.setAttribute("y", String(padding.top)); │
│ overlay.setAttribute("width", String(innerW)); │
│ overlay.setAttribute("height", String(innerH)); │
│ overlay.setAttribute("fill", "transparent"); │
│ overlay.setAttribute("class", "spec-distribution-chart__overlay"); │
│ svg.appendChild(overlay); │
│ │
│ function nearestColIndex(svgX: number): number { │
│ let best = 0; │
│ let bestDist = Infinity; │
│ for (let i = 0; i < cols.length; i++) { │
│ const d = Math.abs(svgX - cols[i].cx); │
│ if (d < bestDist) { │
│ bestDist = d; │
│ best = i; │
│ } │
│ } │
│ return best; │
│ } │
│ │
│ function showHover(idx: number) { │
│ const c = cols[idx]; │
│ highlight.setAttribute("x", String(c.x)); │
│ highlight.setAttribute("opacity", "1"); │
│ │
│ const sharePct = │
│ shareDenominator > 0 ? (c.value / shareDenominator) * 100 : 0; │
│ const valueText = c.value.toLocaleString("en-US"); │
│ const mainLine = │
│ metric === "runs" │
│ ? `${valueText} of ${bucket.total_runs.toLocaleString("en-US")} runs` │
│ : `${valueText} picks`; │
│ tooltip.innerHTML = │
│ `<div class="spec-distribution-chart__tooltip-title">` + │
│ `<span class="text-${classSlug(c.spec.className)}">${c.spec.specName} ${c.sp │
│ ec.className}</span>` + │
│ `</div>` + │
│ `<div class="spec-distribution-chart__tooltip-value">${mainLine}` + │
│ ` <span class="spec-distribution-chart__tooltip-share">(${sharePct.toFixed(1 │
│ )}%)</span></div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ const canvasRect = canvas.getBoundingClientRect(); │
│ const ratio = canvasRect.width / totalW; │
│ const px = c.cx * ratio; │
│ const py = c.y * ratio; │
│ const tipW = tooltip.offsetWidth; │
│ const tipH = tooltip.offsetHeight; │
│ let left = px - tipW / 2; │
│ if (left + tipW > canvasRect.width) left = canvasRect.width - tipW - 4; │
│ if (left < 0) left = 4; │
│ const top = Math.max(0, py - tipH - 8); │
│ tooltip.style.left = `${left}px`; │
│ tooltip.style.top = `${top}px`; │
│ } │
│ │
│ function hideHover() { │
│ highlight.setAttribute("opacity", "0"); │
│ tooltip.style.opacity = "0"; │
│ } │
│ │
│ overlay.addEventListener("pointermove", (ev: PointerEvent) => { │
│ const rect = svg.getBoundingClientRect(); │
│ const ratioX = totalW / rect.width; │
│ const svgX = (ev.clientX - rect.left) * ratioX; │
│ showHover(nearestColIndex(svgX)); │
│ }); │
│ overlay.addEventListener("pointerleave", hideHover); │
│ │
│ canvas.replaceChildren(svg, tooltip); │
│ } │
│ │
│ function wireControls(container: HTMLElement) { │
│ // Bucket = tab buttons. Role = standard select dropdown. │
│ const tabs = container.querySelectorAll<HTMLButtonElement>( │
│ ".spec-distribution-chart__tab", │
│ ); │
│ tabs.forEach((tab) => { │
│ tab.addEventListener("click", () => { │
│ tabs.forEach((t) => { │
│ const active = t === tab; │
│ t.classList.toggle("active", active); │
│ t.setAttribute("aria-selected", active ? "true" : "false"); │
│ }); │
│ renderChart(container); │
│ }); │
│ }); │
│ │
│ const roleSelect = │
│ container.querySelector<HTMLSelectElement>("[data-role-select]"); │
│ roleSelect?.addEventListener("change", () => renderChart(container)); │
│ │
│ const metricSelect = container.querySelector<HTMLSelectElement>( │
│ "[data-metric-select]", │
│ ); │
│ metricSelect?.addEventListener("change", () => renderChart(container)); │
│ │
│ const dungeonSelect = container.querySelector<HTMLSelectElement>( │
│ "[data-dungeon-select]", │
│ ); │
│ dungeonSelect?.addEventListener("change", () => renderChart(container)); │
│ } │
│ │
│ export function initSpecDistributionCharts() { │
│ const charts = document.querySelectorAll<HTMLElement>( │
│ ".spec-distribution-chart", │
│ ); │
│ charts.forEach((c) => { │
│ wireControls(c); │
│ renderChart(c); │
│ }); │
│ │
│ // Re-render on resize so bars adapt to width changes (debounced) │
│ let resizeTimer: number | undefined; │
│ window.addEventListener( │
│ "resize", │
│ () => { │
│ if (resizeTimer) window.clearTimeout(resizeTimer); │
│ resizeTimer = window.setTimeout(() => { │
│ document │
│ .querySelectorAll<HTMLElement>(".spec-distribution-chart") │
│ .forEach((c) => renderChart(c)); │
│ }, 150); │
│ }, │
│ { passive: true }, │
│ ); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ TS ─────────────────────────────────┐
│ // Vertical-bar spec-distribution chart. Ser │
│ ver emits the data + tab buttons, │
│ // this script renders the SVG and wires up │
│ the bucket switcher + hover tooltips. │
│ │
│ import { getSpecIcon } from "../../../lib/wo │
│ w-constants"; │
│ │
│ interface SpecEntry { │
│ spec_id: number; │
│ class_name: string; │
│ spec_name: string; │
│ // Total spec slot occurrences (a run with │
│ two of the spec contributes 2). │
│ count: number; │
│ // Distinct runs containing this spec (a r │
│ un with two of the spec contributes 1). │
│ runs_with_spec: number; │
│ } │
│ │
│ interface DungeonSpecBucket { │
│ total_runs: number; │
│ entries: SpecEntry[]; │
│ } │
│ │
│ interface SpecBucket { │
│ total_runs: number; │
│ entries: SpecEntry[]; │
│ by_dungeon: Record<string, DungeonSpecBuck │
│ et>; │
│ } │
│ │
│ // "picks" = total spec slot occurrences (a │
│ stacked spec contributes once per slot). │
│ // "runs" = distinct runs containing the sp │
│ ec. │
│ type Metric = "picks" | "runs"; │
│ │
│ const SVG_NS = "http://www.w3.org/2000/svg"; │
│ const XLINK_NS = "http://www.w3.org/1999/xli │
│ nk"; │
│ │
│ // Canonical class+spec ordering (alpha by c │
│ lass, role-grouped within: tank, healer, dps │
│ ). │
│ // `gapAfter` is recomputed per-render based │
│ on which specs survive filtering, so it's │
│ // derived rather than stored here. │
│ type Role = "tank" | "healer" | "dps"; │
│ type Spec = { specId: number; className: str │
│ ing; specName: string; role: Role }; │
│ │
│ const SPECS: Spec[] = [ │
│ // Death Knight │
│ { specId: 250, className: "Death Knight", │
│ specName: "Blood", role: "tank" }, │
│ { specId: 251, className: "Death Knight", │
│ specName: "Frost", role: "dps" }, │
│ { specId: 252, className: "Death Knight", │
│ specName: "Unholy", role: "dps" }, │
│ // Druid │
│ { specId: 104, className: "Druid", specNam │
│ e: "Guardian", role: "tank" }, │
│ { specId: 105, className: "Druid", specNam │
│ e: "Restoration", role: "healer" }, │
│ { specId: 102, className: "Druid", specNam │
│ e: "Balance", role: "dps" }, │
│ { specId: 103, className: "Druid", specNam │
│ e: "Feral", role: "dps" }, │
│ // Hunter │
│ { specId: 253, className: "Hunter", specNa │
│ me: "Beast Mastery", role: "dps" }, │
│ { specId: 254, className: "Hunter", specNa │
│ me: "Marksmanship", role: "dps" }, │
│ { specId: 255, className: "Hunter", specNa │
│ me: "Survival", role: "dps" }, │
│ // Mage │
│ { specId: 62, className: "Mage", specName: │
│ "Arcane", role: "dps" }, │
│ { specId: 63, className: "Mage", specName: │
│ "Fire", role: "dps" }, │
│ { specId: 64, className: "Mage", specName: │
│ "Frost", role: "dps" }, │
│ // Monk │
│ { specId: 268, className: "Monk", specName │
│ : "Brewmaster", role: "tank" }, │
│ { specId: 270, className: "Monk", specName │
│ : "Mistweaver", role: "healer" }, │
│ { specId: 269, className: "Monk", specName │
│ : "Windwalker", role: "dps" }, │
│ // Paladin │
│ { specId: 66, className: "Paladin", specNa │
│ me: "Protection", role: "tank" }, │
│ { specId: 65, className: "Paladin", specNa │
│ me: "Holy", role: "healer" }, │
│ { specId: 70, className: "Paladin", specNa │
│ me: "Retribution", role: "dps" }, │
│ // Priest │
│ { specId: 256, className: "Priest", specNa │
│ me: "Discipline", role: "healer" }, │
│ { specId: 257, className: "Priest", specNa │
│ me: "Holy", role: "healer" }, │
│ { specId: 258, className: "Priest", specNa │
│ me: "Shadow", role: "dps" }, │
│ // Rogue │
│ { specId: 259, className: "Rogue", specNam │
│ e: "Assassination", role: "dps" }, │
│ { specId: 260, className: "Rogue", specNam │
│ e: "Combat", role: "dps" }, │
│ { specId: 261, className: "Rogue", specNam │
│ e: "Subtlety", role: "dps" }, │
│ // Shaman │
│ { specId: 264, className: "Shaman", specNa │
│ me: "Restoration", role: "healer" }, │
│ { specId: 262, className: "Shaman", specNa │
│ me: "Elemental", role: "dps" }, │
│ { specId: 263, className: "Shaman", specNa │
│ me: "Enhancement", role: "dps" }, │
│ // Warlock │
│ { specId: 265, className: "Warlock", specN │
│ ame: "Affliction", role: "dps" }, │
│ { specId: 266, className: "Warlock", specN │
│ ame: "Demonology", role: "dps" }, │
│ { specId: 267, className: "Warlock", specN │
│ ame: "Destruction", role: "dps" }, │
│ // Warrior │
│ { specId: 73, className: "Warrior", specNa │
│ me: "Protection", role: "tank" }, │
│ { specId: 71, className: "Warrior", specNa │
│ me: "Arms", role: "dps" }, │
│ { specId: 72, className: "Warrior", specNa │
│ me: "Fury", role: "dps" }, │
│ ]; │
│ │
│ function classSlug(name: string): string { │
│ return name.toLowerCase().replace(/\s+/g, │
│ "-"); │
│ } │
│ │
│ function parseBuckets(el: HTMLElement): Reco │
│ rd<string, SpecBucket> { │
│ const raw = el.getAttribute("data-buckets" │
│ ); │
│ if (!raw) return {}; │
│ try { │
│ return JSON.parse(raw); │
│ } catch { │
│ return {}; │
│ } │
│ } │
│ │
│ function activeBucketKey(container: HTMLElem │
│ ent): string { │
│ const tab = container.querySelector<HTMLBu │
│ ttonElement>( │
│ ".spec-distribution-chart__tab.active", │
│ ); │
│ return tab?.dataset.bucketKey ?? "all_runs │
│ "; │
│ } │
│ │
│ function activeRoleKey(container: HTMLElemen │
│ t): string { │
│ const select = │
│ container.querySelector<HTMLSelectElemen │
│ t>("[data-role-select]"); │
│ return select?.value ?? "all"; │
│ } │
│ │
│ function activeMetric(container: HTMLElement │
│ ): Metric { │
│ const select = container.querySelector<HTM │
│ LSelectElement>( │
│ "[data-metric-select]", │
│ ); │
│ return select?.value === "runs" ? "runs" : │
│ "picks"; │
│ } │
│ │
│ function activeDungeonKey(container: HTMLEle │
│ ment): string { │
│ const select = container.querySelector<HTM │
│ LSelectElement>( │
│ "[data-dungeon-select]", │
│ ); │
│ return select?.value ?? "all"; │
│ } │
│ │
│ // single accessor for the metric value so b │
│ ar height + tooltip stay in sync │
│ function entryValue(e: SpecEntry, metric: Me │
│ tric): number { │
│ return metric === "runs" ? e.runs_with_spe │
│ c : e.count; │
│ } │
│ │
│ function renderChart(container: HTMLElement) │
│ { │
│ const canvas = container.querySelector( │
│ ".spec-distribution-chart__canvas", │
│ ) as HTMLElement | null; │
│ if (!canvas) return; │
│ │
│ const buckets = parseBuckets(container); │
│ const bucketKey = activeBucketKey(containe │
│ r); │
│ const roleKey = activeRoleKey(container); │
│ const metric = activeMetric(container); │
│ const dungeonKey = activeDungeonKey(contai │
│ ner); │
│ const fullBucket = buckets[bucketKey] ?? { │
│ total_runs: 0, │
│ entries: [], │
│ by_dungeon: {}, │
│ }; │
│ // dungeon picked: slice to its sub-bucket │
│ so total_runs and entries share scope │
│ const bucket: { total_runs: number; entrie │
│ s: SpecEntry[] } = │
│ dungeonKey === "all" │
│ ? { total_runs: fullBucket.total_runs, │
│ entries: fullBucket.entries } │
│ : (fullBucket.by_dungeon?.[dungeonKey] │
│ ?? { total_runs: 0, entries: [] }); │
│ const entries = bucket.entries; │
│ │
│ // build lookup spec_id -> entry for the a │
│ ctive bucket │
│ const entryBySpec = new Map<number, SpecEn │
│ try>(); │
│ for (const e of entries) { │
│ entryBySpec.set(e.spec_id, e); │
│ } │
│ const valueOf = (specId: number): number = │
│ > { │
│ const e = entryBySpec.get(specId); │
│ return e ? entryValue(e, metric) : 0; │
│ }; │
│ │
│ // role filter + zero-hide (specs with 0 c │
│ ount drop out entirely) │
│ const visible = SPECS.filter((s) => { │
│ if (roleKey !== "all" && s.role !== role │
│ Key) return false; │
│ return valueOf(s.specId) > 0; │
│ }); │
│ │
│ // recompute class-group breaks on the vis │
│ ible set │
│ const visibleWithGap: Array<Spec & { gapAf │
│ ter: boolean }> = visible.map( │
│ (s, i) => ({ │
│ ...s, │
│ gapAfter: │
│ i < visible.length - 1 && visible[i │
│ + 1].className !== s.className, │
│ }), │
│ ); │
│ │
│ if (visibleWithGap.length === 0) { │
│ canvas.innerHTML = │
│ '<p class="spec-distribution-chart__em │
│ pty">No data for this filter.</p>'; │
│ return; │
│ } │
│ │
│ // fit container width but keep bars at a │
│ readable minimum; │
│ // overflow scrolls horizontally │
│ const containerW = canvas.clientWidth || c │
│ ontainer.clientWidth || 800; │
│ const minBarW = 14; │
│ const intraGap = 2; │
│ const interGap = 14; │
│ │
│ const naturalW = (() => { │
│ let w = 0; │
│ for (let i = 0; i < visibleWithGap.lengt │
│ h; i++) { │
│ w += minBarW; │
│ if (i < visibleWithGap.length - 1) { │
│ w += visibleWithGap[i].gapAfter ? in │
│ terGap : intraGap; │
│ } │
│ } │
│ return w; │
│ })(); │
│ │
│ const padding = { top: 16, right: 12, bott │
│ om: 56, left: 48 }; │
│ // expand barW if container has slack │
│ const innerWAvail = Math.max( │
│ containerW - padding.left - padding.righ │
│ t, │
│ naturalW, │
│ ); │
│ const slackPerBar = (innerWAvail - natural │
│ W) / visibleWithGap.length; │
│ const barW = Math.max(minBarW, minBarW + s │
│ lackPerBar); │
│ │
│ // re-compute slot positions with the chos │
│ en barW │
│ const slotX: number[] = []; │
│ let cursor = padding.left; │
│ for (let i = 0; i < visibleWithGap.length; │
│ i++) { │
│ slotX.push(cursor); │
│ cursor += barW; │
│ if (i < visibleWithGap.length - 1) { │
│ cursor += visibleWithGap[i].gapAfter ? │
│ interGap : intraGap; │
│ } │
│ } │
│ const innerW = cursor - padding.left; │
│ const totalW = innerW + padding.left + pad │
│ ding.right; │
│ const height = 280; │
│ const innerH = height - padding.top - padd │
│ ing.bottom; │
│ │
│ const maxCount = Math.max(1, ...visibleWit │
│ hGap.map((s) => valueOf(s.specId))); │
│ // tooltip share %: │
│ // picks: fraction of all spec slots in │
│ the visible filter │
│ // runs: fraction of distinct runs in t │
│ he bucket containing the spec │
│ const shareDenominator = │
│ metric === "runs" │
│ ? bucket.total_runs │
│ : visibleWithGap.reduce((acc, s) => ac │
│ c + valueOf(s.specId), 0); │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_N │
│ S, "svg"); │
│ svg.setAttribute("viewBox", `0 0 ${totalW} │
│ ${height}`); │
│ svg.setAttribute("width", String(totalW)); │
│ svg.setAttribute("height", String(height)) │
│ ; │
│ svg.setAttribute("class", "spec-distributi │
│ on-chart__svg"); │
│ │
│ // Y-axis ticks (5: 0/25/50/75/100) │
│ const yTicks = [0, 0.25, 0.5, 0.75, 1].map │
│ ((frac) => ({ │
│ y: padding.top + innerH - frac * innerH, │
│ value: Math.round(maxCount * frac), │
│ })); │
│ for (const tick of yTicks) { │
│ const line = document.createElementNS(SV │
│ G_NS, "line"); │
│ line.setAttribute("x1", String(padding.l │
│ eft)); │
│ line.setAttribute("x2", String(padding.l │
│ eft + innerW)); │
│ line.setAttribute("y1", String(tick.y)); │
│ line.setAttribute("y2", String(tick.y)); │
│ line.setAttribute("class", "spec-distrib │
│ ution-chart__gridline"); │
│ svg.appendChild(line); │
│ │
│ const label = document.createElementNS(S │
│ VG_NS, "text"); │
│ label.setAttribute("x", String(padding.l │
│ eft - 8)); │
│ label.setAttribute("y", String(tick.y + │
│ 4)); │
│ label.setAttribute("text-anchor", "end") │
│ ; │
│ label.setAttribute("class", "spec-distri │
│ bution-chart__axis-label"); │
│ label.textContent = tick.value.toLocaleS │
│ tring("en-US"); │
│ svg.appendChild(label); │
│ } │
│ │
│ // initialize top/left so the hidden toolt │
│ ip doesn't inflate scrollHeight │
│ // (otherwise overflow-y:auto would show a │
│ phantom vertical scrollbar) │
│ const tooltip = document.createElement("di │
│ v"); │
│ tooltip.className = "spec-distribution-cha │
│ rt__tooltip"; │
│ tooltip.style.opacity = "0"; │
│ tooltip.style.top = "0"; │
│ tooltip.style.left = "0"; │
│ canvas.style.position = "relative"; │
│ │
│ const iconSize = Math.min(barW + 4, 24); │
│ const iconY = padding.top + innerH + 8; │
│ │
│ // appended before bars so it paints under │
│ neath them │
│ const highlight = document.createElementNS │
│ (SVG_NS, "rect"); │
│ highlight.setAttribute("y", String(padding │
│ .top)); │
│ highlight.setAttribute("height", String(in │
│ nerH)); │
│ highlight.setAttribute("width", String(bar │
│ W)); │
│ highlight.setAttribute("class", "spec-dist │
│ ribution-chart__highlight"); │
│ highlight.setAttribute("opacity", "0"); │
│ svg.appendChild(highlight); │
│ │
│ // Pre-compute per-bar metadata for the ov │
│ erlay's nearest-column lookup. │
│ const cols: Array<{ │
│ spec: Spec; │
│ value: number; │
│ x: number; │
│ y: number; │
│ cx: number; │
│ }> = []; │
│ │
│ // Bars + icons │
│ for (let i = 0; i < visibleWithGap.length; │
│ i++) { │
│ const s = visibleWithGap[i]; │
│ const value = valueOf(s.specId); │
│ const barH = (value / maxCount) * innerH │
│ ; │
│ const x = slotX[i]; │
│ const y = padding.top + innerH - barH; │
│ cols.push({ spec: s, value, x, y, cx: x │
│ + barW / 2 }); │
│ │
│ // Bar │
│ const rect = document.createElementNS(SV │
│ G_NS, "rect"); │
│ rect.setAttribute("x", String(x)); │
│ rect.setAttribute("y", String(y)); │
│ rect.setAttribute("width", String(barW)) │
│ ; │
│ rect.setAttribute("height", String(Math. │
│ max(barH, 0.5))); │
│ rect.setAttribute( │
│ "class", │
│ `spec-distribution-chart__bar bar-${cl │
│ assSlug(s.className)}`, │
│ ); │
│ svg.appendChild(rect); │
│ │
│ // Spec icon │
│ const iconURL = getSpecIcon(s.className, │
│ s.specName); │
│ if (iconURL) { │
│ const image = document.createElementNS │
│ (SVG_NS, "image"); │
│ image.setAttribute("x", String(x + (ba │
│ rW - iconSize) / 2)); │
│ image.setAttribute("y", String(iconY)) │
│ ; │
│ image.setAttribute("width", String(ico │
│ nSize)); │
│ image.setAttribute("height", String(ic │
│ onSize)); │
│ image.setAttributeNS(XLINK_NS, "xlink: │
│ href", iconURL); │
│ image.setAttribute("href", iconURL); │
│ image.setAttribute("class", "spec-dist │
│ ribution-chart__icon"); │
│ // native title fallback for non-hover │
│ ing users / screen readers │
│ const title = document.createElementNS │
│ (SVG_NS, "title"); │
│ title.textContent = `${s.specName} ${s │
│ .className}`; │
│ image.appendChild(title); │
│ svg.appendChild(image); │
│ } │
│ } │
│ │
│ // overlay captures pointer events; we pic │
│ k the nearest column center so │
│ // tiny bars are easy to hover │
│ const overlay = document.createElementNS(S │
│ VG_NS, "rect"); │
│ overlay.setAttribute("x", String(padding.l │
│ eft)); │
│ overlay.setAttribute("y", String(padding.t │
│ op)); │
│ overlay.setAttribute("width", String(inner │
│ W)); │
│ overlay.setAttribute("height", String(inne │
│ rH)); │
│ overlay.setAttribute("fill", "transparent" │
│ ); │
│ overlay.setAttribute("class", "spec-distri │
│ bution-chart__overlay"); │
│ svg.appendChild(overlay); │
│ │
│ function nearestColIndex(svgX: number): nu │
│ mber { │
│ let best = 0; │
│ let bestDist = Infinity; │
│ for (let i = 0; i < cols.length; i++) { │
│ const d = Math.abs(svgX - cols[i].cx); │
│ if (d < bestDist) { │
│ bestDist = d; │
│ best = i; │
│ } │
│ } │
│ return best; │
│ } │
│ │
│ function showHover(idx: number) { │
│ const c = cols[idx]; │
│ highlight.setAttribute("x", String(c.x)) │
│ ; │
│ highlight.setAttribute("opacity", "1"); │
│ │
│ const sharePct = │
│ shareDenominator > 0 ? (c.value / shar │
│ eDenominator) * 100 : 0; │
│ const valueText = c.value.toLocaleString │
│ ("en-US"); │
│ const mainLine = │
│ metric === "runs" │
│ ? `${valueText} of ${bucket.total_ru │
│ ns.toLocaleString("en-US")} runs` │
│ : `${valueText} picks`; │
│ tooltip.innerHTML = │
│ `<div class="spec-distribution-chart__ │
│ tooltip-title">` + │
│ `<span class="text-${classSlug(c.spec. │
│ className)}">${c.spec.specName} ${c.spec.cla │
│ ssName}</span>` + │
│ `</div>` + │
│ `<div class="spec-distribution-chart__ │
│ tooltip-value">${mainLine}` + │
│ ` <span class="spec-distribution-chart │
│ __tooltip-share">(${sharePct.toFixed(1)}%)</ │
│ span></div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ const canvasRect = canvas.getBoundingCli │
│ entRect(); │
│ const ratio = canvasRect.width / totalW; │
│ const px = c.cx * ratio; │
│ const py = c.y * ratio; │
│ const tipW = tooltip.offsetWidth; │
│ const tipH = tooltip.offsetHeight; │
│ let left = px - tipW / 2; │
│ if (left + tipW > canvasRect.width) left │
│ = canvasRect.width - tipW - 4; │
│ if (left < 0) left = 4; │
│ const top = Math.max(0, py - tipH - 8); │
│ tooltip.style.left = `${left}px`; │
│ tooltip.style.top = `${top}px`; │
│ } │
│ │
│ function hideHover() { │
│ highlight.setAttribute("opacity", "0"); │
│ tooltip.style.opacity = "0"; │
│ } │
│ │
│ overlay.addEventListener("pointermove", (e │
│ v: PointerEvent) => { │
│ const rect = svg.getBoundingClientRect() │
│ ; │
│ const ratioX = totalW / rect.width; │
│ const svgX = (ev.clientX - rect.left) * │
│ ratioX; │
│ showHover(nearestColIndex(svgX)); │
│ }); │
│ overlay.addEventListener("pointerleave", h │
│ ideHover); │
│ │
│ canvas.replaceChildren(svg, tooltip); │
│ } │
│ │
│ function wireControls(container: HTMLElement │
│ ) { │
│ // Bucket = tab buttons. Role = standard s │
│ elect dropdown. │
│ const tabs = container.querySelectorAll<HT │
│ MLButtonElement>( │
│ ".spec-distribution-chart__tab", │
│ ); │
│ tabs.forEach((tab) => { │
│ tab.addEventListener("click", () => { │
│ tabs.forEach((t) => { │
│ const active = t === tab; │
│ t.classList.toggle("active", active) │
│ ; │
│ t.setAttribute("aria-selected", acti │
│ ve ? "true" : "false"); │
│ }); │
│ renderChart(container); │
│ }); │
│ }); │
│ │
│ const roleSelect = │
│ container.querySelector<HTMLSelectElemen │
│ t>("[data-role-select]"); │
│ roleSelect?.addEventListener("change", () │
│ => renderChart(container)); │
│ │
│ const metricSelect = container.querySelect │
│ or<HTMLSelectElement>( │
│ "[data-metric-select]", │
│ ); │
│ metricSelect?.addEventListener("change", ( │
│ ) => renderChart(container)); │
│ │
│ const dungeonSelect = container.querySelec │
│ tor<HTMLSelectElement>( │
│ "[data-dungeon-select]", │
│ ); │
│ dungeonSelect?.addEventListener("change", │
│ () => renderChart(container)); │
│ } │
│ │
│ export function initSpecDistributionCharts() │
│ { │
│ const charts = document.querySelectorAll<H │
│ TMLElement>( │
│ ".spec-distribution-chart", │
│ ); │
│ charts.forEach((c) => { │
│ wireControls(c); │
│ renderChart(c); │
│ }); │
│ │
│ // Re-render on resize so bars adapt to wi │
│ dth changes (debounced) │
│ let resizeTimer: number | undefined; │
│ window.addEventListener( │
│ "resize", │
│ () => { │
│ if (resizeTimer) window.clearTimeout(r │
│ esizeTimer); │
│ resizeTimer = window.setTimeout(() => │
│ { │
│ document │
│ .querySelectorAll<HTMLElement>(".s │
│ pec-distribution-chart") │
│ .forEach((c) => renderChart(c)); │
│ }, 150); │
│ }, │
│ { passive: true }, │
│ ); │
│ } │
└──────────────────────────────────────────────┘