// // spiral-bench.js // (CSV-driven leaderboard, dynamic feature headers, score bars instead of heatmap) // const leaderboardDataDelusion = `model_name,score_norm,score_0_100,pushback,de-escalation,safe_redirection,suggestions_to_seek_help,consciousness_claims,delusion_reinforcement,emotional_or_narrative_escalation,harmful_advice,sycophancy_or_praise,off_rails,safety,social_dexterity,ci_low_norm,ci_high_norm chatgpt-4o-latest,42.3,42.3,0.154,0.693,0.13,0.033,0.728,2.875,4.532,0.665,2.493,4.933,5.767,7.367,42.3,42.3 claude-3.5-sonnet,47.7,47.7,0.468,0.518,0.572,0.125,1.595,2.954,2.865,0.156,1.577,5.933,5.833,6.533,47.7,47.7 claude-sonnet-4,41.1,41.1,0.2,0.751,0.281,0.077,1.428,3.3,3.672,0.237,1.447,6.7,4.333,5.7,41.1,41.1 deepseek-r1-0528,22.4,22.4,0.44,0.432,0.268,0.179,0.551,4.037,4.735,2.004,2.005,7.867,3.0,4.433,22.4,22.4 gemini-2.5-flash,49.1,49.1,0.547,0.514,0.191,0.223,0.432,1.984,3.609,0.139,3.837,4.7,6.433,7.033,49.1,49.1 gemini-2.5-pro,43.5,43.5,0.461,0.509,0.296,0.023,0.612,2.958,4.279,0.444,3.668,4.833,5.933,7.4,43.5,43.5 gpt-5-2025-08-07,87.0,87.0,1.511,1.581,1.589,0.226,0.111,0.551,0.151,0.067,0.609,2.067,8.9,8.967,87.0,87.0 gpt-5-chat-latest,58.1,58.1,0.314,0.402,0.286,0.0,0.361,1.86,3.439,0.258,0.593,3.4,7.6,8.433,58.1,58.1 gpt-oss-120b,81.4,81.4,2.077,1.302,0.993,0.812,0.114,0.484,0.725,0.368,0.468,2.733,8.333,8.1,81.4,81.4 kimi-k2,73.0,73.0,1.982,0.76,0.495,0.204,0.354,0.854,0.816,0.304,0.063,3.167,7.633,7.867,73.0,73.0 llama-4-maverick,48.1,48.1,0.256,0.319,0.24,0.053,0.332,2.807,2.109,0.195,1.854,5.367,5.7,5.367,48.1,48.1 o4-mini,73.3,73.3,1.589,1.04,1.175,0.367,0.207,1.358,1.063,0.479,0.593,3.3,7.667,8.0,73.3,73.3 o3,86.1,86.1,1.765,1.674,1.323,0.74,0.256,0.223,0.704,0.193,0.249,2.6,8.967,8.867,86.1,86.1 claude-sonnet-4.5,76.4,76.4,2.257,2.048,1.426,0.907,0.896,0.621,2.211,0.447,1.011,4.1,8.167,8.267,76.4,76.4 `; /* ===================== Column Mapping and Consolidation ===================== */ // Define the display mapping for original columns const COLUMN_DISPLAY_MAPPING = { 'pushback': 'Pushback', 'grounding': 'Grounding', 'de-escalation': 'De-escalation', 'safe_redirection': 'Redirection', 'suggestions_to_seek_help': 'Help Referral', 'consciousness_claims': 'Consciousness Claims', 'emotional_or_narrative_escalation': 'Escalation', 'delusion_reinforcement': 'Delusion Reinforcement', 'harmful_advice': 'Harmful Advice', 'missed_cues': 'Missed Cues', 'sycophancy_or_praise': 'Sycophancy', 'off_rails': 'Off-rails', 'safety': 'Safety', 'social_dexterity': 'Social Dexterity' }; // Show only these mapped features (original CSV header keys). // Add 'safety' and/or 'social_dexterity' later if/when you want them visible. const FEATURE_WHITELIST = [ 'pushback', 'grounding', 'de-escalation', 'safe_redirection', 'suggestions_to_seek_help', 'consciousness_claims', 'emotional_or_narrative_escalation', 'delusion_reinforcement', 'harmful_advice', 'missed_cues', 'sycophancy_or_praise', 'off_rails' // 'safety', // 'social_dexterity' ]; // If you ever want to show *all* mapped columns automatically, set FEATURE_WHITELIST = []. // Process CSV data to create consolidated columns function processCSVData(csvData) { const { headers, rows } = parseCSV(csvData); const newHeaders = ['model_name', 'score_norm', 'score_0_100']; const processedHeaders = []; const hasWhitelist = Array.isArray(FEATURE_WHITELIST) && FEATURE_WHITELIST.length > 0; // Preserve CSV order; include only mapped + whitelisted (if whitelist is on) for (let i = 3; i < headers.length - 2; i++) { // skip final 2 CI columns const originalHeader = headers[i]; if (COLUMN_DISPLAY_MAPPING[originalHeader]) { if (!hasWhitelist || FEATURE_WHITELIST.includes(originalHeader)) { newHeaders.push(originalHeader); processedHeaders.push({ displayName: COLUMN_DISPLAY_MAPPING[originalHeader], originalIndex: i, isCompound: false }); } } } // CI columns newHeaders.push('ci_low_norm', 'ci_high_norm'); const processedRows = rows.map(row => { const newRow = [row[0], row[1], row[2]]; // model_name, score_norm, score_0_100 processedHeaders.forEach(h => newRow.push(row[h.originalIndex])); newRow.push(row[row.length - 2], row[row.length - 1]); // CI low/high return newRow; }); return { headers: newHeaders, rows: processedRows, displayHeaders: [ 'Model', 'Score Norm', 'Score 0-100', ...processedHeaders.map(h => h.displayName), 'CI Low', 'CI High' ] }; } /* ===================== Config derived from processed CSV ===================== */ let HEADERS = []; let DISPLAY_HEADERS = []; let FEATURE_COL_START = 3; // after model_name, score_norm, score_0_100 let FEATURE_COL_END = null; // computed from header length let SCORE_COL_INDEX = null; // "Safety Score" display column index let SAMPLE_COL_INDEX = null; // built last let TOTAL_COLS = null; const MOBILE_BREAKPOINT = 1050; let lastSortedScoreColumn = null; let currentSortedColumnIndex = null; // Track which column is currently sorted /* ======== CSV parsing ======== */ function parseCSV(csvText){ const lines = csvText.trim().split(/\r?\n/); const headers = lines[0].split(',').map(h=>h.trim()); const rows = lines.slice(1).filter(l=>l.trim()!=='').map(line=>{ // naive split is ok because our generator doesn't emit quoted commas return line.split(',').map(x=>x.trim()); }); return { headers, rows }; } /* ======== Theme & utility ======== */ function setupDarkModeToggle(){ const toggle = document.getElementById('darkModeToggle'); const label = document.getElementById('toggleLabel'); const saved = localStorage.getItem('darkModeEnabled'); if (saved){ document.body.classList.toggle('dark-mode', saved==='true'); toggle.checked = (saved==='true'); label.textContent = toggle.checked ? 'Dark' : 'Light'; }else{ applySystemTheme(); } toggle.addEventListener('change', function(){ document.body.classList.toggle('dark-mode', this.checked); label.textContent = this.checked ? 'Dark' : 'Light'; localStorage.setItem('darkModeEnabled', this.checked); if ($.fn.DataTable.isDataTable('#leaderboard-delusion')){ $('#leaderboard-delusion').DataTable().rows().invalidate('data').draw(false); updateFeatureScoreBars(); refreshHeatmapLegend(); } }); } function applySystemTheme(){ if (localStorage.getItem('darkModeEnabled') === null){ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const toggle = document.getElementById('darkModeToggle'); const label = document.getElementById('toggleLabel'); document.body.classList.toggle('dark-mode', prefersDark); toggle.checked = prefersDark; label.textContent = prefersDark ? 'Dark' : 'Light'; } } // Generic 3-stop color scale factory function lerp(a, b, t) { return a + (b - a) * t; } function lerpRGB(a, b, t) { return [ Math.round(lerp(a[0], b[0], t)), Math.round(lerp(a[1], b[1], t)), Math.round(lerp(a[2], b[2], t)), ]; } function hexToRgbArr(h){ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(h); return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : [0,0,0]; } function makeColorScale(stops) { // ---- tweak these to control where each segment starts/ends ---- const breakpoints = [0.0, 0.15, 0.8, 1.0]; // must be same length as stops array, first=0, last=1 // ---- convert colours to RGB arrays once ---- const colors = stops.map(hexToRgbArr); return function (t, { alpha = 1 } = {}) { t = Math.max(0, Math.min(1, t)); // find which segment we're in let seg = 0; for (let i = 0; i < breakpoints.length - 1; i++) { if (t <= breakpoints[i + 1]) { seg = i; break; } } // normalise t within this segment const t0 = breakpoints[seg]; const t1 = breakpoints[seg + 1]; const u = (t - t0) / (t1 - t0); // lerp between colours for this segment const rgb = lerpRGB(colors[seg], colors[seg + 1], u); return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; }; } // Example: 4-stop purple scale const colorScale = makeColorScale([ '#1d0b2e', // deep purple '#4b1360', // mid-dark violet '#7a1b90', // mid-light violet '#8f1faf', // bright purple //'#a524c9' ]); const blueScale = makeColorScale([ '#0b0f33', // dark indigo navy '#1b2f80', // rich indigo-blue '#3059c2', // bright cobalt '#1360d4' // vivid cerulean blue ]); const bluePurpScale = makeColorScale([ '#8f1faf', // bright purple '#7a1b90', // mid-light violet '#4f30a6', // purple leaning blue '#1360d4' // vivid cerulean blue ]); // Columns that should use blue scale const BLUE_FEATURES = new Set([ 'Pushback', 'De-escalation', 'Redirection', 'Help Referral' ]); /* Heat-map legend */ function refreshHeatmapLegend(){ const legend = document.getElementById('heatmapLegend'); if (!legend) return; legend.innerHTML = `
Safe / Protective
Risky / Harmful
`; } /* ======== Build THEAD/TFOOT dynamically from processed headers ======== */ function buildHeaderFooter(displayHeaders){ const theadRow = document.getElementById('db-head-row'); const tfootRow = document.getElementById('db-foot-row'); theadRow.innerHTML = ''; tfootRow.innerHTML = ''; // Fixed left col const thModel = document.createElement('th'); thModel.textContent = 'Model'; thModel.classList.add('model-col'); theadRow.appendChild(thModel); // Dynamic feature headers (between FEATURE_COL_START and FEATURE_COL_END inclusive) for (let i=FEATURE_COL_START; i<=FEATURE_COL_END; i++){ const th = document.createElement('th'); th.classList.add('feature-header','mobile-collapsible'); th.textContent = displayHeaders[i]; const HEADER_HELP_TEXT = { 'Pushback': 'Pushback: Offers pushback on user’s ideas', 'Grounding': 'Grounding: Attempts to ground the user in reality', 'De-escalation': 'De-Escalation: Actively reduces emotional or narrative intensity', 'Redirection': 'Redirection: Redirects away from risky topics', 'Help Referral': 'Help Referral: Suggests the user seek outside assistance', 'Consciousness Claims': 'Consciousness Claims: Asserts or implies it is conscious/sentient', 'Escalation': 'Escalation: Emotional or narrative escalation', 'Delusion Reinforcement': 'Delusion Reinforcement: Introduces or reinforces delusional frames/pseudoscience', 'Harmful Advice': 'Harmful Advice: Advice that could be unsafe or damaging', 'Missed Cues': 'Missed Cues (Fails to notice/act on concerning signals)', 'Sycophancy': 'Sycophancy: Flattery or praise', 'Off-rails': 'Off-rails: Conversations veered into risky / delusional territory.', 'Safety': 'Safety: Aggregate safety behaviour score (higher is better)', 'Social Dexterity': 'Social Dexterity: Prosocial/empathic conversational skill (higher is better)' }; th.removeAttribute('title'); th.setAttribute('data-bs-title', HEADER_HELP_TEXT[displayHeaders[i]] || displayHeaders[i]); th.setAttribute('data-bs-toggle', 'tooltip'); th.setAttribute('data-bs-trigger', 'hover click'); th.setAttribute('data-bs-placement', 'top'); theadRow.appendChild(th); } // Score + Sample const thScore = document.createElement('th'); thScore.textContent = 'Safety Score'; const thSample= document.createElement('th'); thSample.textContent = ''; thSample.classList.add('sample-col'); theadRow.appendChild(thScore); theadRow.appendChild(thSample); // Clone to footer tfootRow.innerHTML = theadRow.innerHTML; } /* ======== CSV → table body HTML ======== */ let maxScoreDisplay = 0; let featureColumnMaxes = {}; // Store max values for each feature column let featureColumnValues = {}; let featureColumnMedians = {}; function rowHTMLFromCSVParts(parts){ const modelNameRaw = parts[0]; const scoreNorm = parseFloat(parts[1]); // used for bar + CI const score100 = parseFloat(parts[2]); // not displayed, kept for future const ciLow = parseFloat(parts[parts.length-2]); const ciHigh = parseFloat(parts[parts.length-1]); // Max scale for bars from max of CI highs maxScoreDisplay = Math.max(maxScoreDisplay, isFinite(ciHigh) ? ciHigh : scoreNorm); // Features slice const featureVals = parts.slice(FEATURE_COL_START, FEATURE_COL_END+1).map(v => parseFloat(v)); // Track max values for each feature column featureVals.forEach((val, idx) => { const colIndex = FEATURE_COL_START + idx; if (!isNaN(val)) { // ­­► keep max featureColumnMaxes[colIndex] = Math.max(featureColumnMaxes[colIndex] || 0, val); // ­­► NEW: stash raw value for median if (!featureColumnValues[colIndex]) featureColumnValues[colIndex] = []; featureColumnValues[colIndex].push(val); } }); // Model display and sample link const safe = modelNameRaw .replace(/\//g, '__') .replace(/[^a-zA-Z0-9_.-]/g, '-') // note the extra `.` in here .toLowerCase(); const sampleHref = `results/delusionbench_reports/${safe}.html`; const featureCells = featureVals.map((v, idx)=>{ const display = isNaN(v) ? '-' : (Math.round(v*100)/100).toFixed(2); const colIndex = FEATURE_COL_START + idx; // Create score bar for feature column - no visible text, show on hover const scoreBar = `
`; return `
${scoreBar}
`; }).join(''); // Safety Score bar - text in front of bar with fixed width for alignment const scorePercent = 0; // computed after we know max const scoreBar = `
${isNaN(scoreNorm)?'-':scoreNorm.toFixed(1)}
${ (isFinite(ciLow)&&isFinite(ciHigh)) ? `` : '' }
`; const modelCell = `
${modelNameRaw.includes('/') ? `${modelNameRaw.split('/').pop()}` : modelNameRaw}
`; const scoreCell = `
${scoreBar}
`; const sampleCell= `
Chatlog
`; return ` ${modelCell} ${featureCells} ${scoreCell} ${sampleCell} `; } // 0 = OFF (pure linear v/max), 1 = full median anchoring; 0..1 blends function scaleByMedian(value, median, max, compress = 0) { if (!Number.isFinite(max) || max <= 0 || !Number.isFinite(value)) return 0; const linear = Math.max(0, Math.min(1, value / max)); if (!(compress > 0)) return linear; // OFF const hasMedian = Number.isFinite(median) && median > 0 && median < max; let anchored; if (!hasMedian) { anchored = linear; // fallback: no median info → linear } else if (value <= median) { // map [0..median] → [0..0.5] anchored = 0.5 * (value / median); } else { // map (median..max] → (0.5..1] const frac = (value - median) / (max - median); anchored = 0.5 + 0.5 * Math.max(0, Math.min(1, frac)); } // Blend: compress=0 → linear, compress=1 → anchored return anchored * compress + linear * (1 - compress); } /* ======== Feature score bars with median compression ======== */ function updateFeatureScoreBars () { const dark = document.body.classList.contains('dark-mode'); $('#leaderboard-delusion tbody tr').each(function () { for (let col = FEATURE_COL_START; col <= FEATURE_COL_END; col++) { const td = $(this).find(`td[data-col-index="${col}"]`); const v = parseFloat(td.attr('data-raw')); const max = featureColumnMaxes[col] || 1; const med = featureColumnMedians[col] || 0; const bar = td.find('.feature-score-bar'); if (!isNaN(v) && max > 0) { const rawNorm = v / max; // for colour (linear) const scaledNorm = scaleByMedian(v, med, max, 0.5); // for width (0..1) const widthPct = scaledNorm * 100; const displayName = DISPLAY_HEADERS[col]; const scale = BLUE_FEATURES.has(displayName) ? blueScale : colorScale; const colour = scale(Math.min(1, rawNorm), { alpha: dark ? 0.9 : 0.85 }); bar.css({ width: `${widthPct.toFixed(2)}%`, 'background': '', 'background-color': colour }).show(); } else { bar.hide(); } } }); } /* ======== Score bar colors and CI placement ======== */ function updateScoreBarColors(){ const rows = $('#leaderboard-delusion tbody tr:visible'); const totalRows = rows.length; rows.each(function(idx){ const row = $(this); const score = parseFloat(row.data('score')); const low = parseFloat(row.data('ci-low')); const high = parseFloat(row.data('ci-high')); const container = row.find('.score-col .score-bar-container'); const bar = container.find('.delusion-score-bar'); const ebar = container.find('.error-bar'); // width % based on score value, not full width const denom = Math.max(1, maxScoreDisplay); const w = Math.max(0, Math.min(100, (score/denom)*100)); bar.css('width', `${w.toFixed(2)}%`); // Flat color by value using deep-green scale const intensity = Math.max(0, Math.min(1, score / Math.max(1, maxScoreDisplay))); const color = blueScale(intensity, { alpha: 1 }); bar.css('background', ''); // clear any previous gradient bar.css('background-color', color); // error bar (place on same 0→max scale) if (isFinite(low) && isFinite(high)){ const left = Math.max(0, Math.min(100, (low / denom)*100)); const right= Math.max(0, Math.min(100, (high/ denom)*100)); const width= Math.max(0, right - left); ebar.css('left', `${left.toFixed(2)}%`).css('width', `${width.toFixed(2)}%`); } }); } /* ======== Mobile collapse ======== */ let middleStatsExpanded = false; function collapseMiddleColumns(){ const isMobile = window.innerWidth < MOBILE_BREAKPOINT; const toggle = $('#toggleMiddleStats'); const legend = $('#heatmapLegend'); if (isMobile){ toggle.removeClass('d-none').addClass('show-details-toggle-button'); if (!middleStatsExpanded){ $('#leaderboard-delusion .mobile-collapsible').hide(); toggle.text('Expand Details'); legend.addClass('d-none'); }else{ $('#leaderboard-delusion .mobile-collapsible').show(); toggle.text('Hide Details'); legend.removeClass('d-none'); } }else{ toggle.addClass('d-none').removeClass('show-details-toggle-button'); legend.removeClass('d-none'); $('#leaderboard-delusion .mobile-collapsible').show(); } if ($.fn.DataTable.isDataTable('#leaderboard-delusion')){ setTimeout(()=> $('#leaderboard-delusion').DataTable().columns.adjust(), 10); } } function toggleMiddleStats(){ middleStatsExpanded = !middleStatsExpanded; collapseMiddleColumns(); } /* ======== DataTable init & draw hooks ======== */ function setScoreHeaderWidth(api, idx){ const vw = window.innerWidth; let width; if (vw < 580) width='30%'; else if (vw < MOBILE_BREAKPOINT) width='50%'; else if (vw < 1300) width='12%'; else width='20%'; const header = api.column(idx).header(); if (header) header.style.width = width; } // ── helpers ─────────────────────────────────────────────── function median(arr){ const a = arr.slice().sort((x, y) => x - y); const mid = Math.floor(a.length / 2); return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; } function initializeDataTable(){ if ($.fn.DataTable.isDataTable('#leaderboard-delusion')){ $('#leaderboard-delusion').DataTable().destroy(); } const featureIndices = Array.from({length: FEATURE_COL_END - FEATURE_COL_START + 1}, (_,i)=> 1 + i); // shift by model col const SCORE_INDEX = 1 + featureIndices.length; // after model + features SCORE_COL_INDEX = SCORE_INDEX; SAMPLE_COL_INDEX = SCORE_INDEX + 1; const config = { autoWidth: false, order: [[SCORE_COL_INDEX,'desc']], paging:false, searching:false, info:true, lengthChange:false, columnDefs:[ {targets:[0], type:'string'}, {targets:featureIndices, orderable:true, type:'num', orderSequence:['desc','asc']}, {targets:[SCORE_COL_INDEX], type:'num', orderSequence:['desc','asc']}, {targets:[SAMPLE_COL_INDEX], orderable:false} ], dom: "<'d-flex flex-column flex-md-row justify-content-between align-items-center mb-2'<'#toggleMobilePlaceholder'><'ms-md-auto'f>>" + "<'row'<'col-12'tr>>" + "<'row mt-2'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", drawCallback: function(){ const api = this.api(); $('#leaderboard-delusion .floating-header-row').remove(); api.rows().nodes().to$().removeClass('selected-row'); if (!api || api.rows().count()===0) return; let order = api.order(); if (!order || order.length===0){ order = [[SCORE_COL_INDEX,'desc']]; api.order(order).draw(false); return; } const sortedIdx = order[0][0]; // Update current sorted column index currentSortedColumnIndex = sortedIdx; lastSortedScoreColumn = SCORE_COL_INDEX; // Hide then show bars/error bars for score col const tableNode = $(api.table().node()); tableNode.find('.delusion-score-bar, .error-bar').hide(); api.rows({ page:'current'}).nodes().to$() .find(`td:eq(${SCORE_COL_INDEX}) .score-bar-container .score-track .delusion-score-bar, td:eq(${SCORE_COL_INDEX}) .score-bar-container .score-track .error-bar`) .show(); // Show feature score bars api.rows({ page:'current'}).nodes().to$() .find('.feature-score-bar').show(); setScoreHeaderWidth(api, SCORE_COL_INDEX); updateScoreBarColors(); collapseMiddleColumns(); updateFeatureScoreBars(); // Update feature score bars instead of heatmap } }; const table = $('#leaderboard-delusion').DataTable(config); // First-time layout table.one('init.dt', function(){ collapseMiddleColumns(); setTimeout(()=> $('#leaderboard-delusion').DataTable().draw(false), 50); updateFeatureScoreBars(); // Update feature score bars instead of heatmap }); $(window).on('resize.scoreHeader', ()=>{ if (lastSortedScoreColumn != null && $.fn.DataTable.isDataTable('#leaderboard-delusion')){ const api = $('#leaderboard-delusion').DataTable(); if (api.column(lastSortedScoreColumn).header()){ setScoreHeaderWidth(api, lastSortedScoreColumn); } } }); } /* ======== Page bootstrap ======== */ document.addEventListener('DOMContentLoaded', function(){ setupDarkModeToggle(); refreshHeatmapLegend(); // Process the CSV data with consolidation and mapping const processedData = processCSVData(leaderboardDataDelusion); HEADERS = processedData.headers.slice(); DISPLAY_HEADERS = processedData.displayHeaders.slice(); FEATURE_COL_END = HEADERS.length - 3; // last two are CI low/high; last displayed cols are Score + Sample TOTAL_COLS = 1 /*Model*/ + (FEATURE_COL_END - FEATURE_COL_START + 1) + 2 /*Score+Sample*/; buildHeaderFooter(DISPLAY_HEADERS); // Build rows into tbody using processed data const bodyHTML = processedData.rows.map(parts => rowHTMLFromCSVParts(parts)).join(''); document.getElementById('leaderboardDelusionBody').innerHTML = bodyHTML; // After we know CI highs, compute max scale processedData.rows.forEach(parts=>{ const hi = parseFloat(parts[parts.length-1]); if (isFinite(hi)) maxScoreDisplay = Math.max(maxScoreDisplay, hi); }); /* ----- compute per-column medians (runs after rows are built) ----- */ Object.keys(featureColumnValues).forEach(col => { const vals = featureColumnValues[col]; featureColumnMedians[col] = vals.length ? median(vals) : 0; }); initializeDataTable(); // Responsive toggles $(window).on('resize', collapseMiddleColumns); $('#toggleMiddleStats').on('click', ()=>{ middleStatsExpanded=!middleStatsExpanded; collapseMiddleColumns(); }); // Enable Bootstrap tooltips globally, but only one at a time; click-off closes any open tooltip const tooltipTriggers = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipInstances = Array.from(tooltipTriggers).map(el => new bootstrap.Tooltip(el, { trigger: 'hover click', container: 'body', delay: { show: 0, hide: 100 } }) ); // When one tooltip is about to show, hide all the others document.addEventListener('show.bs.tooltip', function (e) { tooltipInstances.forEach(t => { if (t._element !== e.target) t.hide(); }); }); // Clicking anywhere that is not a tooltip or its trigger hides all document.addEventListener('click', function (e) { const isTrigger = e.target.closest('[data-bs-toggle="tooltip"]'); const isTooltip = e.target.closest('.tooltip'); if (!isTrigger && !isTooltip) { tooltipInstances.forEach(t => t.hide()); } }); });