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

// 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";

// matches dungeon-thresholds.ts canonical id order
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",
  ) as HTMLElement | null;
  if (!canvas) return;

  const buckets = parseBuckets(container);
  const bucketKey = activeBucketKey(container);
  const fullBucket = buckets[bucketKey] ?? {
    total_runs: 0,
    entries: [],
    by_dungeon: {},
  };

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

  // smaller bar minimum than the spec chart - only ~9 bars so they fit fine
  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,
    naturalW,
  );
  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}</d
iv>` +
      `<div class="dungeon-distribution-chart__tooltip-value">${c.count.toLocaleSt
ring("en-US")} runs` +
      ` <span class="dungeon-distribution-chart__tooltip-share">(${sharePct.toFixe
d(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-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);
    });
  });
}

export function initDungeonDistributionCharts() {
  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 },
  );
}

// Vertical-bar chart of run counts per dung
eon for the active bucket.
// Visual treatment mirrors SpecDistribution
Chart 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, DungeonSpecBuck
et>;
}

const SVG_NS = "http://www.w3.org/2000/svg";
const XLINK_NS = "http://www.w3.org/1999/xli
nk";

// matches dungeon-thresholds.ts canonical i
d order
const DUNGEONS: Array<{ id: number; name: st
ring; 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", slu
g: "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: "scho
lomance" },
  { id: 77, name: "Scarlet Halls", slug: "sc
arlet-halls" },
  { id: 78, name: "Scarlet Monastery", slug:
 "scarlet-monastery" },
];

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>(
    ".dungeon-distribution-chart__tab.active
",
  );
  return tab?.dataset.bucketKey ?? "all_runs
";
}

function renderChart(container: HTMLElement)
 {
  const canvas = container.querySelector(
    ".dungeon-distribution-chart__canvas",
  ) as HTMLElement | null;
  if (!canvas) return;

  const buckets = parseBuckets(container);
  const bucketKey = activeBucketKey(containe
r);
  const fullBucket = buckets[bucketKey] ?? {
    total_runs: 0,
    entries: [],
    by_dungeon: {},
  };

  // skip dungeons with 0 runs in this bucke
t
  const visible = DUNGEONS.map((d) => {
    const sub = fullBucket.by_dungeon?.[Stri
ng(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;
  }

  // smaller bar minimum than the spec chart
 - only ~9 bars so they fit fine
  const containerW = canvas.clientWidth || c
ontainer.clientWidth || 800;
  const minBarW = 32;
  const gap = 18;
  const padding = { top: 16, right: 12, bott
om: 56, left: 56 };

  const naturalW = visible.length * minBarW 
+ (visible.length - 1) * gap;
  const innerWAvail = Math.max(
    containerW - padding.left - padding.righ
t,
    naturalW,
  );
  const slackPerBar = (innerWAvail - natural
W) / visible.length;
  const barW = Math.max(minBarW, minBarW + s
lackPerBar);

  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 += ga
p;
  }
  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, ...visible.ma
p((d) => d.count));
  const totalDenom = visible.reduce((acc, d)
 => acc + d.count, 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", "dungeon-distrib
ution-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(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", "dungeon-dist
ribution-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", "dungeon-dis
tribution-chart__axis-label");
    label.textContent = tick.value.toLocaleS
tring("en-US");
    svg.appendChild(label);
  }

  // Tooltip + initial position so it doesn'
t inflate scrollHeight (same trick
  // as the spec chart).
  const tooltip = document.createElement("di
v");
  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(in
nerH));
  highlight.setAttribute("width", String(bar
W));
  highlight.setAttribute("class", "dungeon-d
istribution-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) * inne
rH;
    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(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", "dungeon-dist
ribution-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 + (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", "dungeon-d
istribution-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(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", "dungeon-dis
tribution-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 = totalDenom > 0 ? (c.cou
nt / totalDenom) * 100 : 0;
    tooltip.innerHTML =
      `<div class="dungeon-distribution-char
t__tooltip-title">${c.dungeon.name}</div>` +
      `<div class="dungeon-distribution-char
t__tooltip-value">${c.count.toLocaleString("
en-US")} runs` +
      ` <span class="dungeon-distribution-ch
art__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
) {
  const tabs = container.querySelectorAll<HT
MLButtonElement>(
    ".dungeon-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);
    });
  });
}

export function initDungeonDistributionChart
s() {
  const charts = document.querySelectorAll<H
TMLElement>(
    ".dungeon-distribution-chart",
  );
  charts.forEach((c) => {
    wireControls(c);
    renderChart(c);
  });

  let resizeTimer: number | undefined;
  window.addEventListener(
    "resize",
    () => {
      if (resizeTimer) window.clearTimeout(r
esizeTimer);
      resizeTimer = window.setTimeout(() => 
{
        document
          .querySelectorAll<HTMLElement>(".d
ungeon-distribution-chart")
          .forEach((c) => renderChart(c));
      }, 150);
    },
    { passive: true },
  );
}
 
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET