┌─ ...mponents/Stats/DungeonDistributionChart/DungeonDistributionChart.ts ───┐
│ 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 }, │
│ + ); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...t/DungeonDistributionChart.ts ───┐
│ 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 }, │
│ + ); │
│ +} │
└──────────────────────────────────────────────┘