llm-semantic-visualizer / src /visualizer /DimensionInspector.js
hbauzan's picture
Release v4.17.7 — sync from GitHub main
819a47f
Raw
History Blame Contribute Delete
29.6 kB
/**
* 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();
}