// qore-matriz-utils.js - Funções de clustering e cálculo para matriz FECA // Este arquivo pode ser hospedado no Webflow como asset // Configurações de clustering const NUM_CLUSTERS_PG = 4; const MIN_PERCENTUAL_CLUSTER = 0.15; const RAIO_AGRUPAMENTO_PG = 20; // Distância euclidiana entre dois pontos function distanciaEntrePontos(p1, p2) { return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); } // Calcula centro de massa de um conjunto de pontos function calcularCentro(pontos) { if (pontos.length === 0) return { x: 50, y: 50 }; const soma = pontos.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 }); return { x: soma.x / pontos.length, y: soma.y / pontos.length }; } // Algoritmo K-Means para clusterização function kMeansClusterizacao(pontos, k) { if (pontos.length < k) k = pontos.length; if (k === 0) return []; let centroids = pontos.slice(0, k).map(p => ({ x: p.x, y: p.y })); let mudou = true, clusters, iterations = 0; const maxIterations = 50; while (mudou && iterations < maxIterations) { clusters = Array.from({ length: k }, () => []); iterations++; pontos.forEach(ponto => { let menorDistancia = Infinity, clusterIndex = 0; centroids.forEach((centroid, index) => { const dist = distanciaEntrePontos(ponto, centroid); if (dist < menorDistancia) { menorDistancia = dist; clusterIndex = index; } }); clusters[clusterIndex].push(ponto); }); mudou = false; centroids = centroids.map((centroid, index) => { if (clusters[index].length === 0) return centroid; const novoCentro = calcularCentro(clusters[index]); if (Math.abs(novoCentro.x - centroid.x) > 0.01 || Math.abs(novoCentro.y - centroid.y) > 0.01) { mudou = true; } return novoCentro; }); } return centroids.map((centro, index) => ({ centro, pontos: clusters[index], densidade: clusters[index].length })); } // Unifica clusters próximos function unificarClustersProximos(clusters) { const clustersUnificados = []; clusters.forEach(cluster => { const clusterProximo = clustersUnificados.find(existing => distanciaEntrePontos(cluster.centro, existing.centro) < RAIO_AGRUPAMENTO_PG ); if (clusterProximo) { clusterProximo.centro = { x: (clusterProximo.centro.x + cluster.centro.x) / 2, y: (clusterProximo.centro.y + cluster.centro.y) / 2 }; clusterProximo.pontos = [...clusterProximo.pontos, ...cluster.pontos]; clusterProximo.densidade += cluster.densidade; } else { clustersUnificados.push({ ...cluster }); } }); return clustersUnificados; } // Gera labels únicos para respondentes (considera nome + sobrenome + data) function generateUniqueLabels(respondentes) { const labels = {}; const byFirstName = {}; // Agrupa por primeiro nome respondentes.forEach(r => { const firstName = r.name.split(' ')[0]; if (!byFirstName[firstName]) byFirstName[firstName] = []; byFirstName[firstName].push(r); }); // Para cada grupo de primeiro nome Object.keys(byFirstName).forEach(firstName => { const group = byFirstName[firstName]; if (group.length === 1) { labels[group[0].id] = { shortLabel: firstName, fullLabel: firstName }; } else { const byFullName = {}; group.forEach(r => { if (!byFullName[r.name]) byFullName[r.name] = []; byFullName[r.name].push(r); }); Object.keys(byFullName).forEach(fullName => { const sameNameGroup = byFullName[fullName]; const nameParts = fullName.split(' '); let baseLabel = firstName; if (nameParts.length > 1) { baseLabel = `${firstName} ${nameParts[1][0]}.`; } const otherFullNames = Object.keys(byFullName).filter(fn => fn !== fullName); let needsMoreSpecific = false; otherFullNames.forEach(otherFn => { const otherParts = otherFn.split(' '); let otherBase = otherParts[0]; if (otherParts.length > 1) { otherBase = `${otherParts[0]} ${otherParts[1][0]}.`; } if (otherBase === baseLabel) needsMoreSpecific = true; }); if (needsMoreSpecific && nameParts.length > 1) { for (let len = 2; len <= nameParts[1].length; len++) { const testLabel = `${firstName} ${nameParts[1].substring(0, len)}.`; let stillConflicts = false; otherFullNames.forEach(otherFn => { const otherParts = otherFn.split(' '); if (otherParts.length > 1) { const otherTest = `${otherParts[0]} ${otherParts[1].substring(0, Math.min(len, otherParts[1].length))}.`; if (otherTest === testLabel) stillConflicts = true; } }); if (!stillConflicts) { baseLabel = testLabel; break; } } } if (sameNameGroup.length === 1) { labels[sameNameGroup[0].id] = { shortLabel: baseLabel, fullLabel: baseLabel }; } else { sameNameGroup.sort((a, b) => new Date(a.completed_at) - new Date(b.completed_at)); const monthNames = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']; sameNameGroup.forEach(r => { const date = new Date(r.completed_at); const month = monthNames[date.getMonth()]; const year = date.getFullYear().toString().slice(-2); labels[r.id] = { shortLabel: baseLabel, fullLabel: `${baseLabel} (${month}/${year})` }; }); } }); } }); return labels; } // Calculadoras de orçamento function calcularAmostra(N, confPct, errPct) { if (!N || N <= 0) return null; const e = errPct / 100, p = 0.5; const zScores = { 80: 1.2816, 85: 1.4395, 90: 1.6449, 95: 1.96, 98: 2.3263, 99: 2.5758 }; const Z = zScores[confPct] || 1.96; const n0 = (Z * Z * p * (1 - p)) / (e * e); const n = (N * n0) / ((N - 1) + n0); return Math.ceil(n); } function calcularPrecoCompleto(a, r, q) { r = r || 2; q = q || 0; const SETUP = 7000, RECORTE_EXTRA = 500, PERGUNTA = 800, K = 1000; const unitMax = 60, unitMinAsym = 7.648, minQtd = 3, maxQtd = 10000; const qtdAjustada = Math.max(Math.min(a, maxQtd), minQtd); const multiplicador = unitMinAsym + (unitMax - unitMinAsym) * (K / (qtdAjustada + K)); const Assessments = a * multiplicador; const E_EXTRAS = 40, extrasFactor = qtdAjustada / (qtdAjustada + E_EXTRAS); const Recortes = Math.max(r - 2, 0) * RECORTE_EXTRA * extrasFactor; const Perguntas = q * PERGUNTA * extrasFactor; return Math.round(SETUP + Assessments + Recortes + Perguntas); } function calcularPrecoPulso(pulsoQtd) { if (pulsoQtd <= 0) return 0; const PULSO_UNIT_MAX = 30, PULSO_UNIT_MIN = 10, PULSO_MAX_A = 3000; const pulsoQtdClamp = Math.max(Math.min(pulsoQtd, PULSO_MAX_A), 1); const pulsoUnit = PULSO_UNIT_MAX - (PULSO_UNIT_MAX - PULSO_UNIT_MIN) * ((pulsoQtdClamp - 1) / (PULSO_MAX_A - 1)); return Math.round(pulsoQtd * pulsoUnit); }