OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      1b0ee53c7601
DATE      2026-05-03
SUBJECT   web: dungeon distribution chart on stats page
FILES     4 CHANGED
HASH      1b0ee53c7601
DATE      2026-05-03
SUBJECT   web: dungeon distribution chart on
          stats page
FILES     4 CHANGED
 

diff --git a/web/src/components/Stats/DungeonDistributionChart/DungeonDistribution
Chart.astro b/web/src/components/Stats/DungeonDistributionChart/DungeonDistributio
nChart.astro
new file mode 100644
index 0000000..f4d9ad8
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistributionChart/DungeonDistributionChart.a
stro
@@ -0,0 +1,51 @@
+---
+import "./DungeonDistributionChart.scss";
+import type { StatsSpecCountBucket } from "../../../lib/types";
+
+interface Props {
+  buckets: Record<string, StatsSpecCountBucket>;
+  // unique scope id so multiple charts on a page can each find their own data is
land
+  scope: string;
+}
+
+const { buckets, scope } = Astro.props;
+
+// Mirrors the spec chart's bucket selector. Top 50 is intentionally omitted -
+// every dungeon has its own top-50 list so the bars would all be identical
+// and unhelpful for the "which dungeon is hardest" question.
+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" },
+];
+
+const dataJson = JSON.stringify(buckets);
+---
+
+<figure class="dungeon-distribution-chart" data-scope={scope} data-buckets={dataJ
son}>
+  <div class="dungeon-distribution-chart__controls">
+    <nav class="dungeon-distribution-chart__tabs" role="tablist" aria-label="Run 
filter">
+      {BUCKET_TABS.map((b, i) => (
+        <button
+          type="button"
+          class:list={["dungeon-distribution-chart__tab", { active: i === 0 }]}
+          role="tab"
+          aria-selected={i === 0 ? "true" : "false"}
+          data-bucket-key={b.key}
+        >
+          {b.label}
+        </button>
+      ))}
+    </nav>
+  </div>
+  <div class="dungeon-distribution-chart__canvas">
+    {/* Filled by DungeonDistributionChart.ts on mount; kept empty server-side. *
/}
+  </div>
+</figure>
+
+<script>
+  import { initDungeonDistributionCharts } from "./DungeonDistributionChart.ts";
+  initDungeonDistributionCharts();
+  document.addEventListener("astro:page-load", () => initDungeonDistributionChart
s());
+</script>

diff --git a/web/src/components/Stats/Dungeo
nDistributionChart/DungeonDistributionChart.
astro b/web/src/components/Stats/DungeonDist
ributionChart/DungeonDistributionChart.astro
new file mode 100644
index 0000000..f4d9ad8
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistri
butionChart/DungeonDistributionChart.astro
@@ -0,0 +1,51 @@
+---
+import "./DungeonDistributionChart.scss";
+import type { StatsSpecCountBucket } from "
../../../lib/types";
+
+interface Props {
+  buckets: Record<string, StatsSpecCountBuc
ket>;
+  // unique scope id so multiple charts on 
a page can each find their own data island
+  scope: string;
+}
+
+const { buckets, scope } = Astro.props;
+
+// Mirrors the spec chart's bucket selector
. Top 50 is intentionally omitted -
+// every dungeon has its own top-50 list so
 the bars would all be identical
+// and unhelpful for the "which dungeon is 
hardest" question.
+const BUCKET_TABS: Array<{ key: string; lab
el: string }> = [
+  { key: "all_runs", label: "All Runs" },
+  { key: "gold_runs", label: "Gold" },
+  { key: "platinum_runs", label: "Platinum"
 },
+  { key: "title_runs", label: "Title" },
+];
+
+const dataJson = JSON.stringify(buckets);
+---
+
+<figure class="dungeon-distribution-chart" 
data-scope={scope} data-buckets={dataJson}>
+  <div class="dungeon-distribution-chart__c
ontrols">
+    <nav class="dungeon-distribution-chart_
_tabs" role="tablist" aria-label="Run filter
">
+      {BUCKET_TABS.map((b, i) => (
+        <button
+          type="button"
+          class:list={["dungeon-distributio
n-chart__tab", { active: i === 0 }]}
+          role="tab"
+          aria-selected={i === 0 ? "true" :
 "false"}
+          data-bucket-key={b.key}
+        >
+          {b.label}
+        </button>
+      ))}
+    </nav>
+  </div>
+  <div class="dungeon-distribution-chart__c
anvas">
+    {/* Filled by DungeonDistributionChart.
ts on mount; kept empty server-side. */}
+  </div>
+</figure>
+
+<script>
+  import { initDungeonDistributionCharts } 
from "./DungeonDistributionChart.ts";
+  initDungeonDistributionCharts();
+  document.addEventListener("astro:page-loa
d", () => initDungeonDistributionCharts());
+</script>
 

diff --git a/web/src/components/Stats/DungeonDistributionChart/DungeonDistribution
Chart.scss b/web/src/components/Stats/DungeonDistributionChart/DungeonDistribution
Chart.scss
new file mode 100644
index 0000000..2ed9f33
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistributionChart/DungeonDistributionChart.s
css
@@ -0,0 +1,130 @@
+@use "../../../styles/core/tokens" as *;
+
+.dungeon-distribution-chart {
+  margin: 0;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-md;
+  padding: $spacing-md;
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-sm;
+
+  &__controls {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: $spacing-md;
+    border-bottom: 1px solid var(--border-color);
+    margin-bottom: $spacing-xs;
+  }
+
+  &__tabs {
+    display: flex;
+    flex-wrap: wrap;
+    gap: $spacing-xs;
+  }
+
+  &__tab {
+    background: transparent;
+    border: none;
+    color: var(--text-tertiary);
+    padding: 6px 12px;
+    cursor: pointer;
+    font-size: $font-size-sm;
+    font-weight: $font-weight-semibold;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -1px;
+    transition:
+      color 0.15s ease,
+      border-color 0.15s ease;
+
+    &:hover {
+      color: var(--text-primary);
+    }
+
+    &.active {
+      color: var(--highlight-color);
+      border-bottom-color: var(--highlight-color);
+    }
+  }
+
+  &__canvas {
+    position: relative;
+    width: 100%;
+    overflow-x: auto;
+    overflow-y: hidden;
+  }
+
+  &__svg {
+    display: block;
+    max-width: none;
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-lg;
+  }
+
+  &__gridline {
+    stroke: var(--border-color);
+    stroke-width: 1;
+    stroke-dasharray: 2 4;
+  }
+
+  &__axis-label {
+    fill: var(--text-tertiary);
+    font-size: 11px;
+    font-family: inherit;
+  }
+
+  &__bar {
+    fill: var(--highlight-color);
+    pointer-events: none;
+  }
+
+  &__highlight {
+    fill: var(--text-tertiary);
+    fill-opacity: 0.12;
+    pointer-events: none;
+    transition: opacity 0.08s ease;
+  }
+
+  &__icon {
+    pointer-events: none;
+  }
+
+  &__overlay {
+    cursor: crosshair;
+  }
+
+  &__tooltip {
+    position: absolute;
+    background: var(--bg-primary);
+    border: 1px solid var(--highlight-color);
+    border-radius: $radius-sm;
+    padding: 6px 10px;
+    font-size: $font-size-xs;
+    pointer-events: none;
+    transition: opacity 0.1s ease;
+    white-space: nowrap;
+    z-index: 5;
+  }
+
+  &__tooltip-title {
+    color: var(--text-primary);
+    font-weight: $font-weight-bold;
+    margin-bottom: 2px;
+  }
+
+  &__tooltip-value {
+    color: var(--text-primary);
+    font-variant-numeric: tabular-nums;
+  }
+
+  &__tooltip-share {
+    color: var(--text-tertiary);
+  }
+}

diff --git a/web/src/components/Stats/Dungeo
nDistributionChart/DungeonDistributionChart.
scss b/web/src/components/Stats/DungeonDistr
ibutionChart/DungeonDistributionChart.scss
new file mode 100644
index 0000000..2ed9f33
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistri
butionChart/DungeonDistributionChart.scss
@@ -0,0 +1,130 @@
+@use "../../../styles/core/tokens" as *;
+
+.dungeon-distribution-chart {
+  margin: 0;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border-color);
+  border-radius: $radius-md;
+  padding: $spacing-md;
+  display: flex;
+  flex-direction: column;
+  gap: $spacing-sm;
+
+  &__controls {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: $spacing-md;
+    border-bottom: 1px solid var(--border-c
olor);
+    margin-bottom: $spacing-xs;
+  }
+
+  &__tabs {
+    display: flex;
+    flex-wrap: wrap;
+    gap: $spacing-xs;
+  }
+
+  &__tab {
+    background: transparent;
+    border: none;
+    color: var(--text-tertiary);
+    padding: 6px 12px;
+    cursor: pointer;
+    font-size: $font-size-sm;
+    font-weight: $font-weight-semibold;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -1px;
+    transition:
+      color 0.15s ease,
+      border-color 0.15s ease;
+
+    &:hover {
+      color: var(--text-primary);
+    }
+
+    &.active {
+      color: var(--highlight-color);
+      border-bottom-color: var(--highlight-
color);
+    }
+  }
+
+  &__canvas {
+    position: relative;
+    width: 100%;
+    overflow-x: auto;
+    overflow-y: hidden;
+  }
+
+  &__svg {
+    display: block;
+    max-width: none;
+  }
+
+  &__empty {
+    color: var(--text-tertiary);
+    text-align: center;
+    padding: $spacing-lg;
+  }
+
+  &__gridline {
+    stroke: var(--border-color);
+    stroke-width: 1;
+    stroke-dasharray: 2 4;
+  }
+
+  &__axis-label {
+    fill: var(--text-tertiary);
+    font-size: 11px;
+    font-family: inherit;
+  }
+
+  &__bar {
+    fill: var(--highlight-color);
+    pointer-events: none;
+  }
+
+  &__highlight {
+    fill: var(--text-tertiary);
+    fill-opacity: 0.12;
+    pointer-events: none;
+    transition: opacity 0.08s ease;
+  }
+
+  &__icon {
+    pointer-events: none;
+  }
+
+  &__overlay {
+    cursor: crosshair;
+  }
+
+  &__tooltip {
+    position: absolute;
+    background: var(--bg-primary);
+    border: 1px solid var(--highlight-color
);
+    border-radius: $radius-sm;
+    padding: 6px 10px;
+    font-size: $font-size-xs;
+    pointer-events: none;
+    transition: opacity 0.1s ease;
+    white-space: nowrap;
+    z-index: 5;
+  }
+
+  &__tooltip-title {
+    color: var(--text-primary);
+    font-weight: $font-weight-bold;
+    margin-bottom: 2px;
+  }
+
+  &__tooltip-value {
+    color: var(--text-primary);
+    font-variant-numeric: tabular-nums;
+  }
+
+  &__tooltip-share {
+    color: var(--text-tertiary);
+  }
+}
 

diff --git a/web/src/components/Stats/DungeonDistributionChart/DungeonDistribution
Chart.ts b/web/src/components/Stats/DungeonDistributionChart/DungeonDistributionCh
art.ts
new file mode 100644
index 0000000..02a1949
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistributionChart/DungeonDistributionChart.t
s
@@ -0,0 +1,300 @@
+// Vertical-bar chart of run counts per dungeon for the active bucket.
+// Visual treatment mirrors SpecDistributionChart so the two read consistently
+// when stacked on the stats page.
+
+import { getDungeonIconUrl } from "../../../lib/dungeonIcons";
+
+interface SpecEntry {
+  spec_id: number;
+  class_name: string;
+  spec_name: string;
+  count: number;
+  runs_with_spec: number;
+}
+
+interface DungeonSpecBucket {
+  total_runs: number;
+  entries: SpecEntry[];
+}
+
+interface SpecBucket {
+  total_runs: number;
+  entries: SpecEntry[];
+  by_dungeon: Record<string, DungeonSpecBucket>;
+}
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XLINK_NS = "http://www.w3.org/1999/xlink";
+
+// Dungeon ordering - matches dungeon-thresholds.ts ordering / canonical id order
.
+// Each entry's icon is resolved via the shared dungeonIcons helper.
+const DUNGEONS: Array<{ id: number; name: string; slug: string }> = [
+  { id: 2, name: "Temple of the Jade Serpent", slug: "temple-of-the-jade-serpent"
 },
+  { id: 56, name: "Stormstout Brewery", slug: "stormstout-brewery" },
+  { id: 57, name: "Gate of the Setting Sun", slug: "gate-of-the-setting-sun" },
+  { id: 58, name: "Shado-Pan Monastery", slug: "shado-pan-monastery" },
+  { id: 59, name: "Siege of Niuzao Temple", slug: "siege-of-niuzao-temple" },
+  { id: 60, name: "Mogu'shan Palace", slug: "mogu-shan-palace" },
+  { id: 76, name: "Scholomance", slug: "scholomance" },
+  { id: 77, name: "Scarlet Halls", slug: "scarlet-halls" },
+  { id: 78, name: "Scarlet Monastery", slug: "scarlet-monastery" },
+];
+
+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>(
+    ".dungeon-distribution-chart__tab.active",
+  );
+  return tab?.dataset.bucketKey ?? "all_runs";
+}
+
+function renderChart(container: HTMLElement) {
+  const canvas = container.querySelector(".dungeon-distribution-chart__canvas") a
s HTMLElement | null;
+  if (!canvas) return;
+
+  const buckets = parseBuckets(container);
+  const bucketKey = activeBucketKey(container);
+  const fullBucket = buckets[bucketKey] ?? {
+    total_runs: 0,
+    entries: [],
+    by_dungeon: {},
+  };
+
+  // Pull per-dungeon run counts. Skip dungeons with 0 runs in this bucket.
+  const visible = DUNGEONS.map((d) => {
+    const sub = fullBucket.by_dungeon?.[String(d.id)];
+    return { ...d, count: sub?.total_runs ?? 0 };
+  }).filter((d) => d.count > 0);
+
+  if (visible.length === 0) {
+    canvas.innerHTML = '<p class="dungeon-distribution-chart__empty">No data for 
this filter.</p>';
+    return;
+  }
+
+  // Sizing - same playbook as the spec chart, smaller bar minimum since there
+  // are only ~9 bars (vs ~33 specs) so they get plenty of room.
+  const containerW = canvas.clientWidth || container.clientWidth || 800;
+  const minBarW = 32;
+  const gap = 18;
+  const padding = { top: 16, right: 12, bottom: 56, left: 56 };
+
+  const naturalW = visible.length * minBarW + (visible.length - 1) * gap;
+  const innerWAvail = Math.max(containerW - padding.left - padding.right, natural
W);
+  const slackPerBar = (innerWAvail - naturalW) / visible.length;
+  const barW = Math.max(minBarW, minBarW + slackPerBar);
+
+  const slotX: number[] = [];
+  let cursor = padding.left;
+  for (let i = 0; i < visible.length; i++) {
+    slotX.push(cursor);
+    cursor += barW;
+    if (i < visible.length - 1) cursor += gap;
+  }
+  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, ...visible.map((d) => d.count));
+  const totalDenom = visible.reduce((acc, d) => acc + d.count, 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", "dungeon-distribution-chart__svg");
+
+  // Y-axis ticks
+  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", "dungeon-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", "dungeon-distribution-chart__axis-label");
+    label.textContent = tick.value.toLocaleString("en-US");
+    svg.appendChild(label);
+  }
+
+  // Tooltip + initial position so it doesn't inflate scrollHeight (same trick
+  // as the spec chart).
+  const tooltip = document.createElement("div");
+  tooltip.className = "dungeon-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, 36);
+  const iconY = padding.top + innerH + 8;
+
+  // Highlight rect drawn behind bars.
+  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", "dungeon-distribution-chart__highlight");
+  highlight.setAttribute("opacity", "0");
+  svg.appendChild(highlight);
+
+  const cols: Array<{
+    dungeon: (typeof visible)[number];
+    count: number;
+    x: number;
+    y: number;
+    cx: number;
+  }> = [];
+
+  for (let i = 0; i < visible.length; i++) {
+    const d = visible[i];
+    const barH = (d.count / maxCount) * innerH;
+    const x = slotX[i];
+    const y = padding.top + innerH - barH;
+    cols.push({ dungeon: d, count: d.count, x, y, cx: x + barW / 2 });
+
+    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", "dungeon-distribution-chart__bar");
+    svg.appendChild(rect);
+
+    const iconURL = getDungeonIconUrl(d.slug, d.name);
+    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", "dungeon-distribution-chart__icon");
+      const title = document.createElementNS(SVG_NS, "title");
+      title.textContent = d.name;
+      image.appendChild(title);
+      svg.appendChild(image);
+    }
+  }
+
+  // Single overlay for nearest-column hover (cheap to hit even with thin bars).
+  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", "dungeon-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 = totalDenom > 0 ? (c.count / totalDenom) * 100 : 0;
+    tooltip.innerHTML =
+      `<div class="dungeon-distribution-chart__tooltip-title">${c.dungeon.name}</
div>` +
+      `<div class="dungeon-distribution-chart__tooltip-value">${c.count.toLocaleS
tring("en-US")} runs` +
+      ` <span class="dungeon-distribution-chart__tooltip-share">(${sharePct.toFix
ed(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) {
+  const tabs = container.querySelectorAll<HTMLButtonElement>(".dungeon-distributi
on-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);
+    });
+  });
+}
+
+export function initDungeonDistributionCharts() {
+  const charts = document.querySelectorAll<HTMLElement>(".dungeon-distribution-ch
art");
+  charts.forEach((c) => {
+    wireControls(c);
+    renderChart(c);
+  });
+
+  let resizeTimer: number | undefined;
+  window.addEventListener(
+    "resize",
+    () => {
+      if (resizeTimer) window.clearTimeout(resizeTimer);
+      resizeTimer = window.setTimeout(() => {
+        document
+          .querySelectorAll<HTMLElement>(".dungeon-distribution-chart")
+          .forEach((c) => renderChart(c));
+      }, 150);
+    },
+    { passive: true },
+  );
+}

diff --git a/web/src/components/Stats/Dungeo
nDistributionChart/DungeonDistributionChart.
ts b/web/src/components/Stats/DungeonDistrib
utionChart/DungeonDistributionChart.ts
new file mode 100644
index 0000000..02a1949
--- /dev/null
+++ b/web/src/components/Stats/DungeonDistri
butionChart/DungeonDistributionChart.ts
@@ -0,0 +1,300 @@
+// Vertical-bar chart of run counts per dun
geon for the active bucket.
+// Visual treatment mirrors SpecDistributio
nChart so the two read consistently
+// when stacked on the stats page.
+
+import { getDungeonIconUrl } from "../../..
/lib/dungeonIcons";
+
+interface SpecEntry {
+  spec_id: number;
+  class_name: string;
+  spec_name: string;
+  count: number;
+  runs_with_spec: number;
+}
+
+interface DungeonSpecBucket {
+  total_runs: number;
+  entries: SpecEntry[];
+}
+
+interface SpecBucket {
+  total_runs: number;
+  entries: SpecEntry[];
+  by_dungeon: Record<string, DungeonSpecBuc
ket>;
+}
+
+const SVG_NS = "http://www.w3.org/2000/svg"
;
+const XLINK_NS = "http://www.w3.org/1999/xl
ink";
+
+// Dungeon ordering - matches dungeon-thres
holds.ts ordering / canonical id order.
+// Each entry's icon is resolved via the sh
ared dungeonIcons helper.
+const DUNGEONS: Array<{ id: number; name: s
tring; slug: string }> = [
+  { id: 2, name: "Temple of the Jade Serpen
t", slug: "temple-of-the-jade-serpent" },
+  { id: 56, name: "Stormstout Brewery", slu
g: "stormstout-brewery" },
+  { id: 57, name: "Gate of the Setting Sun"
, slug: "gate-of-the-setting-sun" },
+  { id: 58, name: "Shado-Pan Monastery", sl
ug: "shado-pan-monastery" },
+  { id: 59, name: "Siege of Niuzao Temple",
 slug: "siege-of-niuzao-temple" },
+  { id: 60, name: "Mogu'shan Palace", slug:
 "mogu-shan-palace" },
+  { id: 76, name: "Scholomance", slug: "sch
olomance" },
+  { id: 77, name: "Scarlet Halls", slug: "s
carlet-halls" },
+  { id: 78, name: "Scarlet Monastery", slug
: "scarlet-monastery" },
+];
+
+function parseBuckets(el: HTMLElement): Rec
ord<string, SpecBucket> {
+  const raw = el.getAttribute("data-buckets
");
+  if (!raw) return {};
+  try {
+    return JSON.parse(raw);
+  } catch {
+    return {};
+  }
+}
+
+function activeBucketKey(container: HTMLEle
ment): string {
+  const tab = container.querySelector<HTMLB
uttonElement>(
+    ".dungeon-distribution-chart__tab.activ
e",
+  );
+  return tab?.dataset.bucketKey ?? "all_run
s";
+}
+
+function renderChart(container: HTMLElement
) {
+  const canvas = container.querySelector(".
dungeon-distribution-chart__canvas") as HTML
Element | null;
+  if (!canvas) return;
+
+  const buckets = parseBuckets(container);
+  const bucketKey = activeBucketKey(contain
er);
+  const fullBucket = buckets[bucketKey] ?? 
{
+    total_runs: 0,
+    entries: [],
+    by_dungeon: {},
+  };
+
+  // Pull per-dungeon run counts. Skip dung
eons with 0 runs in this bucket.
+  const visible = DUNGEONS.map((d) => {
+    const sub = fullBucket.by_dungeon?.[Str
ing(d.id)];
+    return { ...d, count: sub?.total_runs ?
? 0 };
+  }).filter((d) => d.count > 0);
+
+  if (visible.length === 0) {
+    canvas.innerHTML = '<p class="dungeon-d
istribution-chart__empty">No data for this f
ilter.</p>';
+    return;
+  }
+
+  // Sizing - same playbook as the spec cha
rt, smaller bar minimum since there
+  // are only ~9 bars (vs ~33 specs) so the
y get plenty of room.
+  const containerW = canvas.clientWidth || 
container.clientWidth || 800;
+  const minBarW = 32;
+  const gap = 18;
+  const padding = { top: 16, right: 12, bot
tom: 56, left: 56 };
+
+  const naturalW = visible.length * minBarW
 + (visible.length - 1) * gap;
+  const innerWAvail = Math.max(containerW -
 padding.left - padding.right, naturalW);
+  const slackPerBar = (innerWAvail - natura
lW) / visible.length;
+  const barW = Math.max(minBarW, minBarW + 
slackPerBar);
+
+  const slotX: number[] = [];
+  let cursor = padding.left;
+  for (let i = 0; i < visible.length; i++) 
{
+    slotX.push(cursor);
+    cursor += barW;
+    if (i < visible.length - 1) cursor += g
ap;
+  }
+  const innerW = cursor - padding.left;
+  const totalW = innerW + padding.left + pa
dding.right;
+  const height = 280;
+  const innerH = height - padding.top - pad
ding.bottom;
+
+  const maxCount = Math.max(1, ...visible.m
ap((d) => d.count));
+  const totalDenom = visible.reduce((acc, d
) => acc + d.count, 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", "dungeon-distri
bution-chart__svg");
+
+  // Y-axis ticks
+  const yTicks = [0, 0.25, 0.5, 0.75, 1].ma
p((frac) => ({
+    y: padding.top + innerH - frac * innerH
,
+    value: Math.round(maxCount * frac),
+  }));
+  for (const tick of yTicks) {
+    const line = document.createElementNS(S
VG_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", "dungeon-dis
tribution-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", "dungeon-di
stribution-chart__axis-label");
+    label.textContent = tick.value.toLocale
String("en-US");
+    svg.appendChild(label);
+  }
+
+  // Tooltip + initial position so it doesn
't inflate scrollHeight (same trick
+  // as the spec chart).
+  const tooltip = document.createElement("d
iv");
+  tooltip.className = "dungeon-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, 36);
+  const iconY = padding.top + innerH + 8;
+
+  // Highlight rect drawn behind bars.
+  const highlight = document.createElementN
S(SVG_NS, "rect");
+  highlight.setAttribute("y", String(paddin
g.top));
+  highlight.setAttribute("height", String(i
nnerH));
+  highlight.setAttribute("width", String(ba
rW));
+  highlight.setAttribute("class", "dungeon-
distribution-chart__highlight");
+  highlight.setAttribute("opacity", "0");
+  svg.appendChild(highlight);
+
+  const cols: Array<{
+    dungeon: (typeof visible)[number];
+    count: number;
+    x: number;
+    y: number;
+    cx: number;
+  }> = [];
+
+  for (let i = 0; i < visible.length; i++) 
{
+    const d = visible[i];
+    const barH = (d.count / maxCount) * inn
erH;
+    const x = slotX[i];
+    const y = padding.top + innerH - barH;
+    cols.push({ dungeon: d, count: d.count,
 x, y, cx: x + barW / 2 });
+
+    const rect = document.createElementNS(S
VG_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", "dungeon-dis
tribution-chart__bar");
+    svg.appendChild(rect);
+
+    const iconURL = getDungeonIconUrl(d.slu
g, d.name);
+    if (iconURL) {
+      const image = document.createElementN
S(SVG_NS, "image");
+      image.setAttribute("x", String(x + (b
arW - iconSize) / 2));
+      image.setAttribute("y", String(iconY)
);
+      image.setAttribute("width", String(ic
onSize));
+      image.setAttribute("height", String(i
conSize));
+      image.setAttributeNS(XLINK_NS, "xlink
:href", iconURL);
+      image.setAttribute("href", iconURL);
+      image.setAttribute("class", "dungeon-
distribution-chart__icon");
+      const title = document.createElementN
S(SVG_NS, "title");
+      title.textContent = d.name;
+      image.appendChild(title);
+      svg.appendChild(image);
+    }
+  }
+
+  // Single overlay for nearest-column hove
r (cheap to hit even with thin bars).
+  const overlay = document.createElementNS(
SVG_NS, "rect");
+  overlay.setAttribute("x", String(padding.
left));
+  overlay.setAttribute("y", String(padding.
top));
+  overlay.setAttribute("width", String(inne
rW));
+  overlay.setAttribute("height", String(inn
erH));
+  overlay.setAttribute("fill", "transparent
");
+  overlay.setAttribute("class", "dungeon-di
stribution-chart__overlay");
+  svg.appendChild(overlay);
+
+  function nearestColIndex(svgX: number): n
umber {
+    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 = totalDenom > 0 ? (c.co
unt / totalDenom) * 100 : 0;
+    tooltip.innerHTML =
+      `<div class="dungeon-distribution-cha
rt__tooltip-title">${c.dungeon.name}</div>` 
+
+      `<div class="dungeon-distribution-cha
rt__tooltip-value">${c.count.toLocaleString(
"en-US")} runs` +
+      ` <span class="dungeon-distribution-c
hart__tooltip-share">(${sharePct.toFixed(1)}
%)</span></div>`;
+    tooltip.style.opacity = "1";
+
+    const canvasRect = canvas.getBoundingCl
ientRect();
+    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) lef
t = 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: HTMLElemen
t) {
+  const tabs = container.querySelectorAll<H
TMLButtonElement>(".dungeon-distribution-cha
rt__tab");
+  tabs.forEach((tab) => {
+    tab.addEventListener("click", () => {
+      tabs.forEach((t) => {
+        const active = t === tab;
+        t.classList.toggle("active", active
);
+        t.setAttribute("aria-selected", act
ive ? "true" : "false");
+      });
+      renderChart(container);
+    });
+  });
+}
+
+export function initDungeonDistributionChar
ts() {
+  const charts = document.querySelectorAll<
HTMLElement>(".dungeon-distribution-chart");
+  charts.forEach((c) => {
+    wireControls(c);
+    renderChart(c);
+  });
+
+  let resizeTimer: number | undefined;
+  window.addEventListener(
+    "resize",
+    () => {
+      if (resizeTimer) window.clearTimeout(
resizeTimer);
+      resizeTimer = window.setTimeout(() =>
 {
+        document
+          .querySelectorAll<HTMLElement>(".
dungeon-distribution-chart")
+          .forEach((c) => renderChart(c));
+      }, 150);
+    },
+    { passive: true },
+  );
+}
 

diff --git a/web/src/pages/challenge-mode/[season]/stats/[region].astro b/web/src/
pages/challenge-mode/[season]/stats/[region].astro
index aebae58..f4dbf06 100644
--- a/web/src/pages/challenge-mode/[season]/stats/[region].astro
+++ b/web/src/pages/challenge-mode/[season]/stats/[region].astro
@@ -6,6 +6,7 @@ import LeaderboardScopeFilter from "../../../../components/Leaderb
oard/Leaderboa
 import KpiCard from "../../../../components/Stats/KpiCard/KpiCard.astro";
 import CompletionTiers from "../../../../components/Stats/CompletionTiers/Complet
ionTiers.astro";
 import SpecDistributionChart from "../../../../components/Stats/SpecDistributionC
hart/SpecDistributionChart.astro";
+import DungeonDistributionChart from "../../../../components/Stats/DungeonDistrib
utionChart/DungeonDistributionChart.astro";
 import ActivityChart from "../../../../components/Stats/ActivityChart/ActivityCha
rt.astro";
 import type { StatsJSON, StatsScope } from "../../../../lib/types";
 
@@ -125,7 +126,7 @@ const panelId = `${region}_${seasonKey}`;
 
               <section class="stats-page__section">
                 <header class="stats-page__section-header">
-                  <h2>9-of-9 Completion Tiers</h2>
+                  <h2>Completion Percentiles</h2>
                 </header>
                 <CompletionTiers
                   gold={scope.completion_tiers["9_of_9_gold"]}
@@ -147,6 +148,13 @@ const panelId = `${region}_${seasonKey}`;
                 </header>
                 <SpecDistributionChart buckets={scope.spec_counts} scope={panelId
} />
               </section>
+
+              <section class="stats-page__section">
+                <header class="stats-page__section-header">
+                  <h2>Dungeon Distribution</h2>
+                </header>
+                <DungeonDistributionChart buckets={scope.spec_counts} scope={pane
lId} />
+              </section>
             </section>
           ) : (
             <div class="stats-page__error">

diff --git a/web/src/pages/challenge-mode/[s
eason]/stats/[region].astro b/web/src/pages/
challenge-mode/[season]/stats/[region].astro
index aebae58..f4dbf06 100644
--- a/web/src/pages/challenge-mode/[season]/
stats/[region].astro
+++ b/web/src/pages/challenge-mode/[season]/
stats/[region].astro
@@ -6,6 +6,7 @@ import LeaderboardScopeFilte
r from "../../../../components/Leaderboard/L
eaderboa
 import KpiCard from "../../../../components
/Stats/KpiCard/KpiCard.astro";
 import CompletionTiers from "../../../../co
mponents/Stats/CompletionTiers/CompletionTie
rs.astro";
 import SpecDistributionChart from "../../..
/../components/Stats/SpecDistributionChart/S
pecDistributionChart.astro";
+import DungeonDistributionChart from "../..
/../../components/Stats/DungeonDistributionC
hart/DungeonDistributionChart.astro";
 import ActivityChart from "../../../../comp
onents/Stats/ActivityChart/ActivityChart.ast
ro";
 import type { StatsJSON, StatsScope } from 
"../../../../lib/types";
 
@@ -125,7 +126,7 @@ const panelId = `${regio
n}_${seasonKey}`;
 
               <section class="stats-page__s
ection">
                 <header class="stats-page__
section-header">
-                  <h2>9-of-9 Completion Tie
rs</h2>
+                  <h2>Completion Percentile
s</h2>
                 </header>
                 <CompletionTiers
                   gold={scope.completion_ti
ers["9_of_9_gold"]}
@@ -147,6 +148,13 @@ const panelId = `${regi
on}_${seasonKey}`;
                 </header>
                 <SpecDistributionChart buck
ets={scope.spec_counts} scope={panelId} />
               </section>
+
+              <section class="stats-page__s
ection">
+                <header class="stats-page__
section-header">
+                  <h2>Dungeon Distribution<
/h2>
+                </header>
+                <DungeonDistributionChart b
uckets={scope.spec_counts} scope={panelId} /
>
+              </section>
             </section>
           ) : (
             <div class="stats-page__error">
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET