OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
 
master @ 468 LINES
 
[ HISTORY ]  [ UP ]
 

// 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 },
  );
}

// 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 },
  );
}
 
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET