/** * DimensionInspector.js * Modo C de comparación: inspector 2D de UNA dimensión a través de los chunks de * varios documentos. Elegimos un panel HTML/canvas (no líneas 3D) porque comparar * perfiles exactos (valores y cruces por cero) se lee mucho mejor en un plano 2D * con ejes y grilla que dentro de la escena 3D con cámara libre. * * Eje X = chunk_index (re-muestreado a una longitud común para alinear documentos * de distinto largo, coherente con STATE.reliefAlignMode). Eje Y = activación de la * dimensión fijada. Una serie coloreada por documento, con leyenda. */ import { STATE, visibleReliefDocs, removeReliefDoc, getMaxDimensions } from '../core/State.js'; import { dimensionSeries, resampleSeries } from './ReliefMath.js'; const PANEL_ID = 'dimension-inspector'; const CANVAS_ID = 'dimension-inspector-canvas'; function _hexCss(hex) { return '#' + (hex & 0xffffff).toString(16).padStart(6, '0'); } // Helpers for multi-dimension plotting function _getSeriesColor(baseColorHex, dIdx, totalDims) { if (totalDims <= 1) return _hexCss(baseColorHex); // Convert hex integer to RGB const r = ((baseColorHex >> 16) & 0xff) / 255; const g = ((baseColorHex >> 8) & 0xff) / 255; const b = (baseColorHex & 0xff) / 255; // RGB to HSL const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } // Shift hue and lightness slightly for different dimensions of the same document h = (h + (dIdx / totalDims) * 0.18) % 1.0; l = Math.max(0.4, Math.min(0.75, l + (dIdx - (totalDims - 1) / 2) * 0.08)); return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; } function _getLineDash(dIdx) { const dashes = [ [], // solid [6, 4], // dashed [2, 3], // dotted [8, 4, 2, 4], // dash-dotted [12, 6], // long dashed ]; return dashes[dIdx % dashes.length]; } function _getLegendSvg(dIdx, color) { const dasharray = _getLineDash(dIdx).join(','); return ` `; } // Crea el panel una sola vez y lo devuelve. function _ensurePanel() { let panel = document.getElementById(PANEL_ID); if (panel) return panel; panel = document.createElement('div'); panel.id = PANEL_ID; panel.className = 'fullscreen-inspector'; panel.style.display = 'none'; const header = document.createElement('div'); header.className = 'inspector-header'; const titleCol = document.createElement('div'); titleCol.className = 'inspector-title-col'; const title = document.createElement('span'); title.id = PANEL_ID + '-title'; title.className = 'inspector-title'; title.textContent = 'Inspector de dimensión'; titleCol.appendChild(title); header.appendChild(titleCol); const actions = document.createElement('div'); actions.className = 'inspector-actions'; const aiButton = document.createElement('button'); aiButton.id = PANEL_ID + '-ai-btn'; aiButton.className = 'btn btn-secondary'; aiButton.style.cssText = 'font-size: 10px; min-height: 24px; padding: 2px 8px; display: flex; align-items: center; gap: 4px; cursor: pointer;'; aiButton.innerHTML = '🤖 Nombrar (IA)'; aiButton.onclick = (e) => { e.stopPropagation(); const dim = STATE.reliefInspectDim | 0; document.dispatchEvent(new CustomEvent('open-naming-modal', { detail: { dimIndex: dim } })); }; actions.appendChild(aiButton); const exportBtn = document.createElement('button'); exportBtn.id = PANEL_ID + '-export-btn'; exportBtn.className = 'btn btn-secondary'; exportBtn.style.cssText = 'font-size: 10px; min-height: 24px; padding: 2px 8px; display: flex; align-items: center; gap: 4px; cursor: pointer;'; exportBtn.innerHTML = '📋 Exportar para LLM'; exportBtn.onclick = async (e) => { e.stopPropagation(); const dim = STATE.reliefInspectDim | 0; const space = STATE.featureSpace || 'RAW'; const originalLabel = exportBtn.innerHTML; try { exportBtn.disabled = true; exportBtn.innerHTML = '⏳ Copiando...'; const markdownString = await exportDimensionContext(dim, space); await navigator.clipboard.writeText(markdownString); exportBtn.innerHTML = '¡Copiado!'; } catch (err) { console.error("Error copying dimension context to clipboard:", err); exportBtn.innerHTML = '❌ Error'; } finally { setTimeout(() => { exportBtn.disabled = false; exportBtn.innerHTML = originalLabel; }, 2000); } }; actions.appendChild(exportBtn); const closeBtn = document.createElement('button'); closeBtn.className = 'inspector-close-btn'; closeBtn.innerHTML = '✕'; closeBtn.onclick = (e) => { e.stopPropagation(); const compModeSelect = document.getElementById('comparison-mode-selector'); if (compModeSelect) { compModeSelect.value = 'A'; compModeSelect.dispatchEvent(new Event('change')); } else { STATE.comparisonMode = 'A'; if (typeof window.applyComparison === 'function') window.applyComparison(); } }; actions.appendChild(closeBtn); header.appendChild(actions); // Body container const body = document.createElement('div'); body.id = PANEL_ID + '-body'; body.className = 'inspector-body'; // Left Column (Canvas) const leftCol = document.createElement('div'); leftCol.className = 'inspector-left-col'; const canvas = document.createElement('canvas'); canvas.id = CANVAS_ID; canvas.style.cssText = 'flex:1; width:100%; height:100%; display:block;'; leftCol.appendChild(canvas); // Right Column (Controls/Legend) const rightCol = document.createElement('div'); rightCol.id = PANEL_ID + '-controls'; rightCol.className = 'inspector-right-col'; // Dimension Section const dimSec = document.createElement('div'); dimSec.style.cssText = 'display:flex; flex-direction:column; gap:4px;'; const dimHeader = document.createElement('div'); dimHeader.style.cssText = 'font-weight:bold; font-size:10px; color:var(--color-brand-primary); letter-spacing:0.5px;'; dimHeader.textContent = 'DIMENSIONES'; dimSec.appendChild(dimHeader); const dimAddRow = document.createElement('div'); dimAddRow.style.cssText = 'display:flex; align-items:center; gap:4px; margin-bottom:2px;'; const maxInspectorDim = getMaxDimensions() - 1; dimAddRow.innerHTML = ` `; dimSec.appendChild(dimAddRow); const dimSelectRow = document.createElement('div'); dimSelectRow.style.cssText = 'display:flex; align-items:center; gap:4px; margin-bottom:2px;'; dimSelectRow.innerHTML = ` `; dimSec.appendChild(dimSelectRow); const dimList = document.createElement('div'); dimList.id = 'inspector-dim-list'; dimList.style.cssText = 'display:flex; flex-direction:column; gap:3px; max-height:130px; overflow-y:auto; padding:3px; border:1px solid oklch(from var(--color-neutral-50) l c h / 0.06); border-radius:4px; background:oklch(from var(--color-neutral-950) l c h / 0.25);'; dimSec.appendChild(dimList); // Document Section const docSec = document.createElement('div'); docSec.style.cssText = 'display:flex; flex-direction:column; gap:4px; margin-top:2px;'; const docHeader = document.createElement('div'); docHeader.style.cssText = 'font-weight:bold; font-size:10px; color:var(--color-brand-secondary); letter-spacing:0.5px;'; docHeader.textContent = 'DOCUMENTOS (PDF)'; docSec.appendChild(docHeader); const docAddRow = document.createElement('div'); docAddRow.style.cssText = 'display:flex; align-items:center; gap:4px; margin-bottom:2px;'; docAddRow.innerHTML = ` `; docSec.appendChild(docAddRow); const docList = document.createElement('div'); docList.id = 'inspector-doc-list'; docList.style.cssText = 'display:flex; flex-direction:column; gap:3px; max-height:130px; overflow-y:auto; padding:3px; border:1px solid oklch(from var(--color-neutral-50) l c h / 0.06); border-radius:4px; background:oklch(from var(--color-neutral-950) l c h / 0.25);'; docSec.appendChild(docList); rightCol.appendChild(dimSec); rightCol.appendChild(docSec); body.appendChild(leftCol); body.appendChild(rightCol); panel.appendChild(header); panel.appendChild(body); const host = document.getElementById('app') || document.body; host.appendChild(panel); // Bind add dimension button const dimInput = dimAddRow.querySelector('#inspector-dim-input'); const dimAddBtn = dimAddRow.querySelector('#inspector-dim-add-btn'); const triggerAddDim = () => { const val = parseInt(dimInput.value, 10); const maxDim = getMaxDimensions() - 1; if (!Number.isNaN(val) && val >= 0 && val <= maxDim) { STATE.reliefInspectDim = val; if (!STATE.reliefInspectDims) STATE.reliefInspectDims = []; if (!STATE.reliefInspectDims.some(d => d.dim === val)) { STATE.reliefInspectDims.push({ dim: val, visible: true }); } // Sync to sidebar const sidebarInput = document.getElementById('inspect-dim-input'); if (sidebarInput) sidebarInput.value = val; if (typeof window.sidebarRefreshDims === 'function') window.sidebarRefreshDims(); renderDimensionInspector(); } }; dimAddBtn.onclick = triggerAddDim; dimInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); triggerAddDim(); } }; const dimSelectDropdown = dimSelectRow.querySelector('#inspector-dim-select-dropdown'); if (dimSelectDropdown) { dimSelectDropdown.onchange = function() { const val = parseInt(this.value, 10); STATE.reliefInspectDim = val; if (!STATE.reliefInspectDims) STATE.reliefInspectDims = []; if (!STATE.reliefInspectDims.some(d => d.dim === val)) { STATE.reliefInspectDims.push({ dim: val, visible: true }); } const sidebarInput = document.getElementById('inspect-dim-input'); if (sidebarInput) sidebarInput.value = val; if (typeof window.sidebarRefreshDims === 'function') window.sidebarRefreshDims(); renderDimensionInspector(); }; } // Bind add PDF button const pdfSelectEl = docAddRow.querySelector('#inspector-pdf-select'); const pdfAddBtn = docAddRow.querySelector('#inspector-pdf-add-btn'); pdfAddBtn.onclick = () => { const val = pdfSelectEl.value; if (val) { const sidebarSelect = document.getElementById('relief-file-selector'); const sidebarBtn = document.getElementById('btn-relief-visualize'); if (sidebarSelect && sidebarBtn) { sidebarSelect.value = val; sidebarBtn.click(); } } }; // Keep inspectorPdfSelect in sync with updates window.inspectorRefreshPdfSelect = () => { const sidebarSelect = document.getElementById('relief-file-selector'); if (sidebarSelect && pdfSelectEl) { const currentSelVal = pdfSelectEl.value; pdfSelectEl.innerHTML = ''; Array.from(sidebarSelect.options).forEach(opt => { if (opt.value) { // Skip empty placeholder const newOpt = document.createElement('option'); newOpt.value = opt.value; newOpt.textContent = opt.textContent; pdfSelectEl.appendChild(newOpt); } }); if (currentSelVal && Array.from(pdfSelectEl.options).some(o => o.value === currentSelVal)) { pdfSelectEl.value = currentSelVal; } } }; if (typeof ResizeObserver !== 'undefined') { const resizeObserver = new ResizeObserver(() => { if (panel.style.display !== 'none' && panel.classList.contains('show')) { renderDimensionInspector(); } }); resizeObserver.observe(leftCol); } return panel; } export function hideDimensionInspector() { const panel = document.getElementById(PANEL_ID); if (panel) { panel.classList.remove('show'); setTimeout(() => { if (!panel.classList.contains('show')) { panel.style.display = 'none'; } }, 300); } } export function showDimensionInspector() { const panel = _ensurePanel(); panel.style.display = 'flex'; void panel.offsetWidth; // force layout recalculation panel.classList.add('show'); if (typeof window.inspectorRefreshPdfSelect === 'function') { window.inspectorRefreshPdfSelect(); } if (typeof window.refreshAllDimensionSelects === 'function') { window.refreshAllDimensionSelects(); } renderDimensionInspector(); } export async function exportDimensionContext(dimIndex, space) { const spaceKey = `${space.toLowerCase()}_${dimIndex}`; const dictEntry = STATE.dimensionDictionary ? STATE.dimensionDictionary[spaceKey] : null; const name = dictEntry ? dictEntry.name : (STATE.dimensionLabels[spaceKey] || 'Sin nombre'); const rationale = dictEntry ? dictEntry.rationale : 'Sin justificación registrada.'; const activeFilenames = STATE.reliefDocs.filter(d => d.visible).map(d => d.filename); let topPos = []; let topNeg = []; if (STATE.provider && STATE.provider.ready) { try { const response = await STATE.provider.getTopChunksForDimension(dimIndex, activeFilenames, space, 8); topPos = response.top_activators || []; topNeg = response.bottom_activators || []; } catch (err) { console.error("Error fetching top chunks for dimension:", err); } } const lines = []; lines.push(`# Dimensión #${dimIndex} (${space.toUpperCase()})`); lines.push(``); lines.push(`- **Nombre Semántico:** ${name}`); lines.push(`- **Justificación:** ${rationale}`); lines.push(``); lines.push(`## Top Activadores (+)`); if (topPos.length > 0) { topPos.forEach(c => { lines.push(`- **[Valor: ${c.value.toFixed(4)}]** ${c.text.trim()} *(Archivo: ${c.filename}, Chunk: ${c.chunk_index})*`); }); } else { lines.push(`- No se encontraron activadores positivos.`); } lines.push(``); lines.push(`## Bottom Activadores (-)`); if (topNeg.length > 0) { topNeg.forEach(c => { lines.push(`- **[Valor: ${c.value.toFixed(4)}]** ${c.text.trim()} *(Archivo: ${c.filename}, Chunk: ${c.chunk_index})*`); }); } else { lines.push(`- No se encontraron activadores negativos/inhibidores.`); } return lines.join('\n'); } function _renderControls() { const dimList = document.getElementById('inspector-dim-list'); const docList = document.getElementById('inspector-doc-list'); if (!dimList || !docList) return; // 1. Render dimensions list dimList.innerHTML = ''; const dims = STATE.reliefInspectDims || []; if (dims.length === 0) { dimList.innerHTML = 'Sin dims comparadas.'; } else { const prefix = (STATE.featureSpace || 'RAW').toLowerCase(); dims.forEach((dObj, idx) => { const dim = dObj.dim; const savedName = STATE.dimensionLabels[`${prefix}_${dim}`]; const label = savedName ? `#${dim} [${savedName}]` : `#${dim}`; const row = document.createElement('div'); row.style.cssText = 'display:flex; align-items:center; justify-content:space-between; gap:4px; font-size:10px; padding:2px 4px; border-radius:2px; transition:background 0.15s; color:var(--color-neutral-300);'; row.onmouseenter = () => row.style.background = 'oklch(from var(--color-neutral-50) l c h / 0.03)'; row.onmouseleave = () => row.style.background = ''; // Swatch for this dimension pattern const svgIcon = _getLegendSvg(idx, 'var(--color-neutral-400)'); row.innerHTML = `
${svgIcon} ${label}
`; row.querySelector('.dim-vis-btn').onclick = (e) => { e.stopPropagation(); dObj.visible = !dObj.visible; if (typeof window.sidebarRefreshDims === 'function') window.sidebarRefreshDims(); renderDimensionInspector(); }; row.querySelector('.dim-del-btn').onclick = (e) => { e.stopPropagation(); STATE.reliefInspectDims.splice(idx, 1); if (typeof window.sidebarRefreshDims === 'function') window.sidebarRefreshDims(); renderDimensionInspector(); }; dimList.appendChild(row); }); } // 2. Render documents list docList.innerHTML = ''; const docs = STATE.reliefDocs || []; if (docs.length === 0) { docList.innerHTML = 'Sin PDFs activos.'; } else { docs.forEach((doc) => { const colorCss = _hexCss(doc.color); const row = document.createElement('div'); row.style.cssText = 'display:flex; align-items:center; justify-content:space-between; gap:4px; font-size:10px; padding:2px 4px; border-radius:2px; transition:background 0.15s; color:var(--color-neutral-300);'; row.onmouseenter = () => row.style.background = 'oklch(from var(--color-neutral-50) l c h / 0.03)'; row.onmouseleave = () => row.style.background = ''; row.innerHTML = `
${doc.label}
`; row.querySelector('.doc-vis-btn').onclick = (e) => { e.stopPropagation(); doc.visible = !doc.visible; if (typeof window.sidebarRefreshDocs === 'function') window.sidebarRefreshDocs(); if (typeof window.applyComparison === 'function') window.applyComparison(); renderDimensionInspector(); }; row.querySelector('.doc-del-btn').onclick = (e) => { e.stopPropagation(); removeReliefDoc(doc.id); if (typeof window.sidebarRefreshDocs === 'function') window.sidebarRefreshDocs(); if (typeof window.applyComparison === 'function') window.applyComparison(); renderDimensionInspector(); }; docList.appendChild(row); }); } } // Dibuja el gráfico. Idempotente: se puede llamar al cambiar la dimensión, los // documentos visibles o su color. export function renderDimensionInspector() { const panel = document.getElementById(PANEL_ID); if (!panel || panel.style.display === 'none') return; const docs = visibleReliefDocs(); const visibleDims = (STATE.reliefInspectDims || []).filter(d => d.visible); const titleEl = document.getElementById(PANEL_ID + '-title'); const prefix = (STATE.featureSpace || 'RAW').toLowerCase(); if (titleEl) { if (visibleDims.length === 1) { const dim = visibleDims[0].dim; const savedName = STATE.dimensionLabels[`${prefix}_${dim}`]; titleEl.textContent = `Dimensión #${dim}${savedName ? ` [${savedName}]` : ''} · ${docs.length} doc(s)`; } else if (visibleDims.length > 1) { titleEl.textContent = `Comparación · ${visibleDims.length} dimensiones · ${docs.length} doc(s)`; } else { titleEl.textContent = 'Comparación · Sin dimensiones activas'; } } const canvas = document.getElementById(CANVAS_ID); if (!canvas) return; // Ajustar el buffer del canvas al tamaño en pantalla (nitidez en HiDPI). // El canvas recién mostrado puede medir 0 antes del primer paint; caemos al // ancho explícito del panel para dibujar siempre, sin depender de rAF. const rect = canvas.getBoundingClientRect(); const cssW = rect.width || canvas.clientWidth || (panel.clientWidth - 260) || 560; const cssH = rect.height || canvas.clientHeight || (panel.clientHeight - 48) || 350; const dpr = window.devicePixelRatio || 1; const W = Math.max(1, Math.floor(cssW * dpr)); const H = Math.max(1, Math.floor(cssH * dpr)); if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; } const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, W, H); // padR = 100 * dpr to fit text labels at the end of curves const padL = 44 * dpr, padR = 105 * dpr, padT = 12 * dpr, padB = 22 * dpr; const plotW = W - padL - padR; const plotH = H - padT - padB; // Extraer series (perfil de las dimensiones por documento) y alinear su largo. const rawSeries = []; docs.forEach(doc => { visibleDims.forEach(dObj => { const dim = dObj.dim; const values = dimensionSeries(doc.matrix, dim); if (values.length > 0) { const savedName = STATE.dimensionLabels[`${prefix}_${dim}`]; rawSeries.push({ doc, dim, savedName, values }); } }); }); if (rawSeries.length === 0) { ctx.fillStyle = 'var(--color-neutral-400)'; ctx.font = `${11 * dpr}px monospace`; ctx.fillText('Sin series para inspeccionar (seleccioná PDFs y agregá dimensiones).', padL, padT + plotH / 2); _renderControls(); return; } const targetLen = STATE.reliefAlignMode === 'INDEX' ? Math.min(...rawSeries.map(s => s.values.length)) : Math.max(...rawSeries.map(s => s.values.length)); const series = rawSeries.map(s => ({ doc: s.doc, dim: s.dim, savedName: s.savedName, values: resampleSeries(s.values, targetLen) })); // Rango Y simétrico alrededor de cero para que el signo se lee de inmediato. let absMax = 0; for (const s of series) for (const v of s.values) absMax = Math.max(absMax, Math.abs(v)); if (absMax < 1e-9) absMax = 1; const xAt = (i) => padL + (targetLen <= 1 ? 0 : (i / (targetLen - 1)) * plotW); const yAt = (v) => padT + plotH / 2 - (v / absMax) * (plotH / 2); // Grilla + eje cero. ctx.strokeStyle = 'oklch(from var(--color-neutral-50) l c h / 0.08)'; ctx.lineWidth = 1 * dpr; ctx.beginPath(); for (let g = 0; g <= 4; g++) { const y = padT + (g / 4) * plotH; ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); } ctx.stroke(); ctx.strokeStyle = 'oklch(from var(--color-neutral-50) l c h / 0.25)'; ctx.beginPath(); ctx.moveTo(padL, yAt(0)); ctx.lineTo(padL + plotW, yAt(0)); ctx.stroke(); // Etiquetas de eje Y. ctx.fillStyle = 'var(--color-neutral-400)'; ctx.font = `${9 * dpr}px monospace`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(`+${absMax.toFixed(3)}`, padL - 6 * dpr, padT); ctx.fillText('0', padL - 6 * dpr, yAt(0)); ctx.fillText(`-${absMax.toFixed(3)}`, padL - 6 * dpr, padT + plotH); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('chunk →', padL + plotW / 2, padT + plotH + 6 * dpr); // Series. series.forEach(s => { const dIdx = visibleDims.findIndex(d => d.dim === s.dim); const color = _getSeriesColor(s.doc.color, dIdx, visibleDims.length); const dash = _getLineDash(dIdx); ctx.strokeStyle = color; ctx.lineWidth = 1.8 * dpr; ctx.setLineDash(dash.map(v => v * dpr)); ctx.beginPath(); s.values.forEach((v, i) => { const x = xAt(i), y = yAt(v); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); ctx.setLineDash([]); // Dibujar etiquetas al final de cada curva en el canvas (collision avoidance) ctx.font = `${9 * dpr}px monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const labels = []; series.forEach(s => { const dIdx = visibleDims.findIndex(d => d.dim === s.dim); const color = _getSeriesColor(s.doc.color, dIdx, visibleDims.length); const yTarget = yAt(s.values[targetLen - 1]); const shortName = s.doc.baseLabel || s.doc.label || ''; // Truncate document name to 10 chars const truncatedDocName = shortName.length > 10 ? shortName.substring(0, 10) + '…' : shortName; const labelText = visibleDims.length === 1 ? truncatedDocName : (docs.length === 1 ? `Dim #${s.dim}` : `${truncatedDocName} · #${s.dim}`); labels.push({ text: labelText, y: yTarget, color: color, }); }); // Sort by y coordinate ascending to declutter labels.sort((a, b) => a.y - b.y); // Ensure min distance of 11 * dpr const minSep = 11 * dpr; for (let j = 1; j < labels.length; j++) { if (labels[j].y - labels[j-1].y < minSep) { labels[j].y = labels[j-1].y + minSep; } } for (let j = labels.length - 2; j >= 0; j--) { if (labels[j+1].y - labels[j].y < minSep) { labels[j].y = labels[j+1].y - minSep; } } // Now draw each label labels.forEach(l => { ctx.fillStyle = l.color; ctx.fillText(l.text, padL + plotW + 6 * dpr, l.y); }); _renderControls(); }