// heatmap.js (KDE + seleção de conjunto) // // Interface preservada: // - Botão #toggleHeatmap // - window.toggleHeatmap() // - Canvas #heatmapCanvas dentro de #matrix // - Loading spinner // // NOVO: escolha automática do conjunto de pontos que alimenta o KDE. // 1) Se houver pontos "as" (as-, as2-, as3-...) LIGADOS -> heatmap só do as // 2) Senão, se houver coordenadas puras (sem label) -> heatmap das puras // 3) Senão, todos os pontos rotulados LIGADOS -> heatmap deles // // Extra: window.refreshHeatmap() redesenha se o heatmap estiver visível // (chame no toggle de labels da matriz para o calor acompanhar o filtro). (function () { 'use strict'; // ========================================================================= // CONFIG // ========================================================================= const BANDWIDTH = 10; const MASSA_MIN = 0.15; const DOMINANCIA = 0.50; const NITIDEZ_MIN = 0.25; const DENSIDADE_MIN = 0.40; const MAX_PGS = 3; const MIN_SEP = 18; const GRID_SIZE = 100; // Seleção de conjunto const MIN_PONTOS = 3; // mínimo para rodar o KDE const PRIORIDADE_AS = true; // true: "as" ligado vence as puras // false: puras vencem, "as" só entra se não houver puras const LIGADO_POR_PADRAO = true; // como tratar label ausente em window.activeLabels const AS_REGEX = /^as\d*$/i; // as, as2, as3... // ========================================================================= // INTERFACE PÚBLICA // ========================================================================= let heatmapVisible = false; document.addEventListener('DOMContentLoaded', function () { const btn = document.getElementById('toggleHeatmap'); if (btn) { btn.onclick = toggleHeatmap; } else { console.error("heatmap: botão #toggleHeatmap não encontrado"); } }); window.toggleHeatmap = toggleHeatmap; // Redesenha mantendo o estado atual (usar após ligar/desligar labels) window.refreshHeatmap = function () { if (!heatmapVisible) return; clearHeatmap(); renderHeatmap(); }; function toggleHeatmap() { heatmapVisible = !heatmapVisible; if (heatmapVisible) { showLoading(); setTimeout(() => { renderHeatmap(); hideLoading(); }, 100); } else { clearHeatmap(); } } // ========================================================================= // RENDER PRINCIPAL // ========================================================================= function renderHeatmap() { const hidden = document.getElementById('hiddenCoordinates'); if (!hidden) { console.warn("heatmap: campo #hiddenCoordinates não encontrado"); return; } const todos = extrairPontos(hidden.value); const conjunto = selecionarConjunto(todos); window.qoreHeatmapModo = conjunto.modo; if (conjunto.pontos.length < MIN_PONTOS) { console.warn("heatmap: pontos insuficientes para análise (modo: " + conjunto.modo + ")"); return; } console.log("heatmap: modo =", conjunto.modo, "| pontos =", conjunto.pontos.length, conjunto.labels && conjunto.labels.length ? "| labels = " + conjunto.labels.join(", ") : ""); const pontos = conjunto.pontos; // Pipeline KDE completo const grid = construirGrid(pontos, BANDWIDTH); const maxGlobal = maximoGlobal(grid); const picos = detectarPicos(grid, BANDWIDTH, maxGlobal); const enriquecidos = enriquecer(picos, pontos, grid, BANDWIDTH, maxGlobal); const pGs = selecionar(enriquecidos); const ctx = setupCanvas(); if (!ctx) return; const W = ctx.canvas.width; const H = ctx.canvas.height; ctx.clearRect(0, 0, W, H); renderCampoKDE(ctx, grid, maxGlobal, W, H); // Atratores desligados. Para reativar, descomente: // renderAtratores(ctx, pGs, pontos, W, H); } function setupCanvas() { const matrix = document.getElementById('matrix'); if (!matrix) { console.error("heatmap: #matrix não encontrado"); return null; } let canvas = document.getElementById('heatmapCanvas'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'heatmapCanvas'; canvas.width = matrix.offsetWidth; canvas.height = matrix.offsetHeight; canvas.style.position = 'absolute'; canvas.style.top = '-31px'; canvas.style.left = '0'; canvas.style.pointerEvents = 'none'; matrix.appendChild(canvas); } return canvas.getContext('2d'); } function clearHeatmap() { const canvas = document.getElementById('heatmapCanvas'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } } // ========================================================================= // EXTRAÇÃO E SELEÇÃO DE CONJUNTO // ========================================================================= // Agora preserva o label em vez de descartar a linha rotulada. function extrairPontos(input) { return input.split('\n').map(linha => { const bruta = linha.trim(); if (!bruta) return null; const pura = bruta.split('>')[0].split('(r=')[0]; let label = null; let corpo = pura; // Captura o prefixo até o primeiro hífen seguido de número (inclui negativos) const m = pura.match(/^([A-Za-z][A-Za-z0-9_ .]*)-(?=-?\d)/); if (m) { label = m[1].trim(); corpo = pura.slice(m[0].length); } const partes = corpo.split(','); if (partes.length !== 4) return null; if (!partes.every(p => p.trim() !== '' && !isNaN(parseFloat(p)))) return null; return { x: parseFloat(partes[3]), y: parseFloat(partes[1]), label: label }; }).filter(p => p !== null); } // Estado ligado/desligado do label, lido da matriz. function estaLigado(label) { if (!label) return true; const al = window.activeLabels; if (al && typeof al === 'object') { if (Object.prototype.hasOwnProperty.call(al, label)) return al[label] !== false; const chave = Object.keys(al).find(k => k.toLowerCase() === label.toLowerCase()); if (chave) return al[chave] !== false; } // Fallback: se a matriz remove os pontos do array global ao desligar, // a presença em window.data já indica que está ligado. if (Array.isArray(window.data)) { return window.data.some(d => d && d.label && String(d.label).toLowerCase() === label.toLowerCase()); } return LIGADO_POR_PADRAO; } function ehAs(label) { return !!label && AS_REGEX.test(label); } function selecionarConjunto(todos) { const asAtivos = todos.filter(p => ehAs(p.label) && estaLigado(p.label)); const puros = todos.filter(p => !p.label); const rotuladosAtivos = todos.filter(p => p.label && !ehAs(p.label) && estaLigado(p.label)); const labelsDe = arr => [...new Set(arr.map(p => p.label))]; if (PRIORIDADE_AS && asAtivos.length >= MIN_PONTOS) { return { pontos: asAtivos, modo: 'as', labels: labelsDe(asAtivos) }; } if (puros.length >= MIN_PONTOS) { return { pontos: puros, modo: 'puras', labels: [] }; } if (asAtivos.length >= MIN_PONTOS) { return { pontos: asAtivos, modo: 'as', labels: labelsDe(asAtivos) }; } if (rotuladosAtivos.length >= MIN_PONTOS) { return { pontos: rotuladosAtivos, modo: 'rotulados', labels: labelsDe(rotuladosAtivos) }; } // Última tentativa: puras + rotulados ligados juntos const combinado = puros.concat(rotuladosAtivos, asAtivos); if (combinado.length >= MIN_PONTOS) { return { pontos: combinado, modo: 'combinado', labels: labelsDe(combinado.filter(p => p.label)) }; } return { pontos: [], modo: 'insuficiente', labels: [] }; } // ========================================================================= // PIPELINE KDE // ========================================================================= function construirGrid(pontos, bw) { const grid = Array.from({ length: GRID_SIZE }, () => new Float64Array(GRID_SIZE)); const h2 = 2 * bw * bw; const raio = Math.ceil(bw * 3); pontos.forEach(p => { const xMin = Math.max(0, Math.floor(p.x - raio)); const xMax = Math.min(GRID_SIZE - 1, Math.ceil(p.x + raio)); const yMin = Math.max(0, Math.floor(p.y - raio)); const yMax = Math.min(GRID_SIZE - 1, Math.ceil(p.y + raio)); for (let i = xMin; i <= xMax; i++) { for (let j = yMin; j <= yMax; j++) { const dx = i - p.x; const dy = j - p.y; grid[i][j] += Math.exp(-(dx * dx + dy * dy) / h2); } } }); return grid; } function maximoGlobal(grid) { let max = 0; for (let i = 0; i < GRID_SIZE; i++) { for (let j = 0; j < GRID_SIZE; j++) { if (grid[i][j] > max) max = grid[i][j]; } } return max; } function detectarPicos(grid, bw, maxGlobal) { const picos = []; const raio = Math.max(2, Math.floor(bw / 2)); const threshold = maxGlobal * 0.05; for (let i = raio; i < GRID_SIZE - raio; i++) { for (let j = raio; j < GRID_SIZE - raio; j++) { const v = grid[i][j]; if (v < threshold) continue; let ehMax = true; for (let di = -raio; di <= raio && ehMax; di++) { for (let dj = -raio; dj <= raio && ehMax; dj++) { if (di === 0 && dj === 0) continue; if (grid[i + di][j + dj] > v) ehMax = false; } } if (ehMax) picos.push({ x: i, y: j, densidade: v }); } } return picos; } function enriquecer(picos, pontos, grid, bw, maxGlobal) { const total = pontos.length; const raioMassa = bw * 1.5; return picos.map(pico => { const massa = pontos.filter(p => Math.sqrt((p.x - pico.x) ** 2 + (p.y - pico.y) ** 2) <= raioMassa ).length / total; const raio = Math.ceil(bw * 2); let vale = pico.densidade; for (let di = -raio; di <= raio; di++) { for (let dj = -raio; dj <= raio; dj++) { const i = pico.x + di; const j = pico.y + dj; if (i < 0 || i >= GRID_SIZE || j < 0 || j >= GRID_SIZE) continue; if (di * di + dj * dj > raio * raio) continue; if (grid[i][j] < vale) vale = grid[i][j]; } } const nitidez = (pico.densidade - vale) / pico.densidade; const densidadeRel = pico.densidade / maxGlobal; return { ...pico, massa, nitidez, densidadeRel }; }); } function selecionar(picos) { const filtrados = picos.filter(p => p.densidadeRel >= DENSIDADE_MIN && p.nitidez >= NITIDEZ_MIN ); const candidatos = filtrados.sort((a, b) => b.densidadeRel - a.densidadeRel); if (candidatos.length === 0) return []; if (candidatos[0].massa < MASSA_MIN) return []; const selecionados = [candidatos[0]]; const massaPrim = candidatos[0].massa; for (let i = 1; i < candidatos.length && selecionados.length < MAX_PGS; i++) { const c = candidatos[i]; if (c.massa < massaPrim * DOMINANCIA) continue; const longe = selecionados.every(s => Math.sqrt((s.x - c.x) ** 2 + (s.y - c.y) ** 2) >= MIN_SEP ); if (!longe) continue; selecionados.push(c); } return selecionados; } // ========================================================================= // RENDERIZAÇÃO DO CAMPO KDE // ========================================================================= function renderCampoKDE(ctx, grid, maxGlobal, W, H) { const maxV = maxGlobal || 1; const cellW = W / GRID_SIZE; const cellH = H / GRID_SIZE; for (let i = 0; i < GRID_SIZE; i++) { for (let j = 0; j < GRID_SIZE; j++) { const v = Math.min(1, grid[i][j] / maxV); if (v < 0.05) continue; const cx = (1 - i / 100) * W; const cy = (j / 100) * H; ctx.fillStyle = corParaDensidade(v); ctx.fillRect(cx - cellW / 2, cy - cellH / 2, cellW + 1, cellH + 1); } } } function corParaDensidade(v) { const stops = [ [0.00, [30, 50, 140, 0]], [0.20, [30, 100, 200, 70]], [0.45, [0, 200, 220, 125]], [0.65, [100, 220, 100, 155]], [0.82, [255, 200, 50, 175]], [0.93, [255, 110, 80, 195]], [0.98, [255, 40, 90, 215]], [1.00, [255, 0, 102, 225]] ]; for (let i = 1; i < stops.length; i++) { if (v <= stops[i][0]) { const [v0, c0] = stops[i - 1]; const [v1, c1] = stops[i]; const t = (v - v0) / (v1 - v0); const r = Math.round(c0[0] + t * (c1[0] - c0[0])); const g = Math.round(c0[1] + t * (c1[1] - c0[1])); const b = Math.round(c0[2] + t * (c1[2] - c0[2])); const a = (c0[3] + t * (c1[3] - c0[3])) / 255; return `rgba(${r},${g},${b},${a.toFixed(2)})`; } } const c = stops[stops.length - 1][1]; return `rgba(${c[0]},${c[1]},${c[2]},${c[3] / 255})`; } // ========================================================================= // ATRATORES // ========================================================================= function renderAtratores(ctx, pGs, pontos, W, H) { if (!pGs || pGs.length === 0) return; const raioMassa = BANDWIDTH * 1.5; pGs.forEach(pg => { const pgX = (1 - pg.x / 100) * W; const pgY = (pg.y / 100) * H; pontos.forEach(p => { const dist = Math.sqrt((p.x - pg.x) ** 2 + (p.y - pg.y) ** 2); if (dist > raioMassa) return; const pX = (1 - p.x / 100) * W; const pY = (p.y / 100) * H; const proximidade = 1 - (dist / raioMassa); const alpha = (0.2 + 0.6 * proximidade).toFixed(2); const lineWidth = 0.4 + 1.6 * proximidade; ctx.beginPath(); ctx.moveTo(pgX, pgY); ctx.lineTo(pX, pY); ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; ctx.lineWidth = lineWidth; ctx.stroke(); ctx.beginPath(); ctx.arc(pX, pY, 2.5 + 1.5 * proximidade, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.fill(); }); ctx.beginPath(); ctx.arc(pgX, pgY, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; ctx.fill(); }); } // ========================================================================= // LOADING // ========================================================================= function showLoading() { let loadingDiv = document.getElementById('loading'); if (!loadingDiv) { loadingDiv = document.createElement('div'); loadingDiv.id = 'loading'; loadingDiv.textContent = 'Carregando mapa de calor...'; loadingDiv.style.position = 'absolute'; loadingDiv.style.top = '47%'; loadingDiv.style.left = '50%'; loadingDiv.style.transform = 'translate(-50%, -50%)'; loadingDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'; loadingDiv.style.color = 'white'; loadingDiv.style.padding = '10px 20px'; loadingDiv.style.borderRadius = '5px'; loadingDiv.style.zIndex = '10'; loadingDiv.style.fontFamily = 'Arial, sans-serif'; document.body.appendChild(loadingDiv); } loadingDiv.style.display = 'block'; } function hideLoading() { const loadingDiv = document.getElementById('loading'); if (loadingDiv) { loadingDiv.style.display = 'none'; } } })();