// heatmap.js (KDE + atratores) // // Substitui o heatmap.js original. Mantém a mesma interface: // - Botão #toggleHeatmap controla on/off // - Função global toggleHeatmap() exposta // - Canvas #heatmapCanvas criado dentro de #matrix // - Loading spinner durante render // // Por dentro usa o pipeline KDE (mesmo do pg4.js) + atratores ligados. // Sem UI de debug — só a camada visual. (function () { 'use strict'; // ========================================================================= // CONFIG (alinhado com pg4.js — ajuste aqui se calibrar em pg4-debug) // ========================================================================= 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; // ========================================================================= // INTERFACE PÚBLICA (igual ao heatmap.js original) // ========================================================================= 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"); } }); // Exposição global para quem chama externamente window.toggleHeatmap = toggleHeatmap; 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 pontos = extrairPontos(hidden.value); if (pontos.length < 3) { console.warn("heatmap: pontos insuficientes para análise"); return; } // 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); // Render: campo + atratores 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 a linha abaixo: // 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'; // alinhamento preservado do original 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); } } // ========================================================================= // PIPELINE KDE // ========================================================================= function extrairPontos(input) { return input.split('\n').map(linha => { if (/^[a-zA-Z]+[a-zA-Z0-9_-]*-/.test(linha)) return null; const pura = linha.split('>')[0].split('(r=')[0]; const partes = pura.split(','); if (partes.length === 4 && partes.every(p => !isNaN(parseFloat(p)))) { return { x: parseFloat(partes[3]), y: parseFloat(partes[1]) }; } return null; }).filter(p => p !== null); } 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) { // Filtros: densidade relativa, nitidez const filtrados = picos.filter(p => p.densidadeRel >= DENSIDADE_MIN && p.nitidez >= NITIDEZ_MIN ); // Ordenação por densidade absoluta (pico mais denso = primário) const candidatos = filtrados.sort((a, b) => b.densidadeRel - a.densidadeRel); if (candidatos.length === 0) return []; // Portão de massa mínima no primário 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; // Mapeamento lógico (0-100) → canvas, idêntico ao pg4 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) { // Stops que concentram o vermelho no extremo superior. // Azul/verde ocupam a maior parte da escala; vermelho só no pico real. 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 (ligados por padrão) // ========================================================================= 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(); }); // Núcleo brilhante no centro do pG ctx.beginPath(); ctx.arc(pgX, pgY, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; ctx.fill(); }); } // ========================================================================= // LOADING (idêntico ao heatmap.js original) // ========================================================================= 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'; } } })();