Spaces:
Sleeping
Sleeping
| /** | |
| * 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 `<svg width="18" height="6" style="vertical-align:middle;margin-right:4px;display:inline-block;flex-shrink:0;"> | |
| <line x1="0" y1="3" x2="18" y2="3" stroke="${color}" stroke-width="2" ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} /> | |
| </svg>`; | |
| } | |
| // 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 = ` | |
| <input type="number" id="inspector-dim-input" class="input-field" min="0" max="${maxInspectorDim}" value="${STATE.reliefInspectDim}" style="flex:1; font-size:10px; padding:2px; background:oklch(from var(--color-neutral-950) l c h / 0.5); text-align:center; height:22px; border-radius:3px; border:1px solid oklch(from var(--color-neutral-50) l c h / 0.15); color:var(--color-neutral-200);"> | |
| <button id="inspector-dim-add-btn" class="btn btn-primary" style="font-size:10px; padding:2px 8px; min-height:22px; height:22px; cursor:pointer;">+ Add</button> | |
| `; | |
| dimSec.appendChild(dimAddRow); | |
| const dimSelectRow = document.createElement('div'); | |
| dimSelectRow.style.cssText = 'display:flex; align-items:center; gap:4px; margin-bottom:2px;'; | |
| dimSelectRow.innerHTML = ` | |
| <select id="inspector-dim-select-dropdown" class="input-field" style="flex:1; font-size:9px; padding:2px; background:oklch(from var(--color-neutral-950) l c h / 0.5); height:22px; border-radius:3px; border:1px solid oklch(from var(--color-neutral-50) l c h / 0.15); color:var(--color-neutral-200); width:130px; overflow:hidden; text-overflow:ellipsis;"></select> | |
| `; | |
| 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 = ` | |
| <select id="inspector-pdf-select" class="input-field" style="flex:1; font-size:9px; padding:2px; background:oklch(from var(--color-neutral-950) l c h / 0.5); height:22px; border-radius:3px; border:1px solid oklch(from var(--color-neutral-50) l c h / 0.15); color:var(--color-neutral-200); width:130px; overflow:hidden; text-overflow:ellipsis;"></select> | |
| <button id="inspector-pdf-add-btn" class="btn btn-secondary" style="font-size:10px; padding:2px 8px; min-height:22px; height:22px; cursor:pointer;">+ Add</button> | |
| `; | |
| 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 = '<span style="font-size:10px; color:var(--color-neutral-500); padding:6px; display:block; text-align:center;">Sin dims comparadas.</span>'; | |
| } 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 = ` | |
| <div style="display:flex; align-items:center; gap:2px; flex:1; min-width:0;"> | |
| ${svgIcon} | |
| <span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:${dObj.visible ? 'var(--color-neutral-200)' : 'var(--color-neutral-600)'};">${label}</span> | |
| </div> | |
| <button class="dim-vis-btn" title="Mostrar/ocultar" style="background:none;border:none;cursor:pointer;color:var(--color-neutral-400);font-size:12px;padding:0 2px;">${dObj.visible ? '👁' : '🚫'}</button> | |
| <button class="dim-del-btn" title="Quitar" style="background:none;border:none;cursor:pointer;color:var(--color-semantic-error);font-size:12px;padding:0 2px;">✕</button> | |
| `; | |
| 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 = '<span style="font-size:10px; color:var(--color-neutral-500); padding:6px; display:block; text-align:center;">Sin PDFs activos.</span>'; | |
| } 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 = ` | |
| <div style="display:flex; align-items:center; gap:4px; flex:1; min-width:0;"> | |
| <span style="width:8px;height:8px;border-radius:2px;background:${colorCss};flex-shrink:0;"></span> | |
| <span title="${doc.filename}" style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:${doc.visible ? 'var(--color-neutral-200)' : 'var(--color-neutral-600)'};">${doc.label}</span> | |
| </div> | |
| <button class="doc-vis-btn" title="Mostrar/ocultar" style="background:none;border:none;cursor:pointer;color:var(--color-neutral-400);font-size:12px;padding:0 2px;">${doc.visible ? '👁' : '🚫'}</button> | |
| <button class="doc-del-btn" title="Quitar" style="background:none;border:none;cursor:pointer;color:var(--color-semantic-error);font-size:12px;padding:0 2px;">✕</button> | |
| `; | |
| 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(); | |
| } | |