Refactor GraphView with floating sidebar and zoom controls

- Move graph options from left panel to floating sidebar in GraphView
- Add zoom controls (fit, +, -) in top-right corner
- Add dynamic legend showing active categories
- Add cleanup system to View base class for event listeners
- Update delegateEvent to return cleanup function
- Filter nodes by category before rendering
- Fix text color variable (--text-primary to --text)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-14 00:07:26 +00:00
parent f55945fdb8
commit 131e198851
7 changed files with 424 additions and 325 deletions

View File

@@ -2,15 +2,16 @@ import { store } from '@/state/index.ts';
import { Router } from '@/router/index.ts'; import { Router } from '@/router/index.ts';
import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts'; import { fetchTags, fetchHstTags, fetchGroups, fetchLibraries, fetchLibraryMembers } from '@/api/index.ts';
import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts'; import { GridView, TreeView, GraphView, DetailPanel } from '@/views/index.ts';
import { $, $$, delegateEvent, toast, createNameMap, resolveGroupName } from '@/utils/index.ts'; import { $, $$, delegateEvent, createNameMap, resolveGroupName } from '@/utils/index.ts';
import { CATS, EDGE_COLORS } from '@/config/index.ts'; import type { BaseType, ViewType } from '@/types/index.ts';
import type { BaseType, ViewType, CategoryKey, EdgeType } from '@/types/index.ts';
import './styles/main.css'; import './styles/main.css';
class App { class App {
private router: Router; private router: Router;
private currentView: GridView | TreeView | GraphView | null = null; private currentView: GridView | TreeView | GraphView | null = null;
private detailPanel: DetailPanel | null = null; private detailPanel: DetailPanel | null = null;
private groupsCleanup: (() => void) | null = null;
private librariesCleanup: (() => void) | null = null;
constructor() { constructor() {
this.router = new Router(store, () => this.init()); this.router = new Router(store, () => this.init());
@@ -92,6 +93,9 @@ class App {
const container = $('#groups-bar'); const container = $('#groups-bar');
if (!container) return; if (!container) return;
// Cleanup previous listener
this.groupsCleanup?.();
const state = store.getState(); const state = store.getState();
// Use hstTags for group name resolution (set_hst points to hst tags) // Use hstTags for group name resolution (set_hst points to hst tags)
const nameMap = createNameMap(state.hstTags, state.lang); const nameMap = createNameMap(state.hstTags, state.lang);
@@ -122,7 +126,7 @@ class App {
}).join('')} }).join('')}
`; `;
delegateEvent<MouseEvent>(container, '.group-btn', 'click', (_, target) => { this.groupsCleanup = delegateEvent<MouseEvent>(container, '.group-btn', 'click', (_, target) => {
const group = target.dataset.group || 'all'; const group = target.dataset.group || 'all';
store.setState({ group }); store.setState({ group });
this.renderGroups(); this.renderGroups();
@@ -134,16 +138,12 @@ class App {
const container = $('#left-panel'); const container = $('#left-panel');
if (!container) return; if (!container) return;
// Cleanup previous listener
this.librariesCleanup?.();
const state = store.getState(); const state = store.getState();
// Show graph options when in graph view // Always show libraries (graph options are in GraphView sidebar)
if (state.view === 'graph') {
container.classList.add('graph-mode');
this.renderGraphOptions(container);
return;
}
container.classList.remove('graph-mode');
container.innerHTML = ` container.innerHTML = `
<div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos"> <div class="lib-icon ${state.library === 'all' ? 'active' : ''}" data-lib="all" title="Todos">
<span>ALL</span> <span>ALL</span>
@@ -160,7 +160,7 @@ class App {
}).join('')} }).join('')}
`; `;
delegateEvent<MouseEvent>(container, '.lib-icon', 'click', async (_, target) => { this.librariesCleanup = delegateEvent<MouseEvent>(container, '.lib-icon', 'click', async (_, target) => {
const library = target.dataset.lib || 'all'; const library = target.dataset.lib || 'all';
const currentBase = store.getState().base; const currentBase = store.getState().base;
@@ -176,169 +176,6 @@ class App {
}); });
} }
private renderGraphOptions(container: HTMLElement): void {
const state = store.getState();
const { graphFilters, graphSettings, tags, graphEdges } = state;
// Count nodes and edges
const nodeCount = tags.length;
const edgeCount = graphEdges.length;
container.innerHTML = `
<div class="graph-options">
<!-- Stats -->
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${nodeCount}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${edgeCount}</span>
</div>
</div>
<!-- Categories -->
<div class="graph-section">
<div class="graph-section-title">Categorias</div>
${Object.entries(CATS).map(([key, config]) => `
<label class="graph-checkbox">
<input type="checkbox" data-cat="${key}" ${graphFilters.cats.has(key as CategoryKey) ? 'checked' : ''}>
<span class="color-dot" style="background: ${config.color}"></span>
${config.name}
</label>
`).join('')}
</div>
<!-- Edge Types -->
<div class="graph-section">
<div class="graph-section-title">Relaciones</div>
${Object.entries(EDGE_COLORS).map(([key, color]) => `
<label class="graph-checkbox">
<input type="checkbox" data-edge="${key}" ${graphFilters.edges.has(key as EdgeType) ? 'checked' : ''}>
<span class="color-dot" style="background: ${color}"></span>
${key}
</label>
`).join('')}
</div>
<!-- Visualization -->
<div class="graph-section">
<div class="graph-section-title">Visualizacion</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imagenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
</div>
`;
// Bind events
this.bindGraphOptionEvents(container);
}
private bindGraphOptionEvents(container: HTMLElement): void {
// Category checkboxes
container.querySelectorAll<HTMLInputElement>('[data-cat]').forEach(cb => {
cb.addEventListener('change', () => {
const cat = cb.dataset.cat as CategoryKey;
const state = store.getState();
const newCats = new Set(state.graphFilters.cats);
if (cb.checked) {
newCats.add(cat);
} else {
newCats.delete(cat);
}
store.setState({
graphFilters: { ...state.graphFilters, cats: newCats }
});
this.renderView();
});
});
// Edge checkboxes
container.querySelectorAll<HTMLInputElement>('[data-edge]').forEach(cb => {
cb.addEventListener('change', () => {
const edge = cb.dataset.edge as EdgeType;
const state = store.getState();
const newEdges = new Set(state.graphFilters.edges);
if (cb.checked) {
newEdges.add(edge);
} else {
newEdges.delete(edge);
}
store.setState({
graphFilters: { ...state.graphFilters, edges: newEdges }
});
this.renderView();
});
});
// Show images checkbox
const showImgCb = container.querySelector<HTMLInputElement>('#graph-show-img');
showImgCb?.addEventListener('change', () => {
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, showImg: showImgCb.checked }
});
this.renderView();
});
// Show labels checkbox
const showLblCb = container.querySelector<HTMLInputElement>('#graph-show-lbl');
showLblCb?.addEventListener('change', () => {
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, showLbl: showLblCb.checked }
});
this.renderView();
});
// Node size slider
const nodeSizeSlider = container.querySelector<HTMLInputElement>('#graph-node-size');
const nodeSizeVal = container.querySelector('#node-size-val');
nodeSizeSlider?.addEventListener('input', () => {
const size = parseInt(nodeSizeSlider.value, 10);
if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`;
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, nodeSize: size }
});
this.renderView();
});
// Link distance slider
const linkDistSlider = container.querySelector<HTMLInputElement>('#graph-link-dist');
const linkDistVal = container.querySelector('#link-dist-val');
linkDistSlider?.addEventListener('input', () => {
const dist = parseInt(linkDistSlider.value, 10);
if (linkDistVal) linkDistVal.textContent = `${dist}px`;
const state = store.getState();
store.setState({
graphSettings: { ...state.graphSettings, linkDist: dist }
});
this.renderView();
});
}
private updateBaseButtons(): void { private updateBaseButtons(): void {
const state = store.getState(); const state = store.getState();
$$('.base-btn').forEach(btn => { $$('.base-btn').forEach(btn => {
@@ -384,7 +221,6 @@ class App {
this.router.updateHash(); this.router.updateHash();
this.detailPanel?.close(); this.detailPanel?.close();
this.updateViewTabs(); this.updateViewTabs();
this.renderLibraries(); // Update left panel (graph options vs libraries)
this.renderView(); this.renderView();
}); });
@@ -410,33 +246,18 @@ class App {
}); });
} }
// Selection mode // Selection mode - DESACTIVADO TEMPORALMENTE
const selBtn = $('#btn-sel'); const selBtn = $('#btn-sel');
if (selBtn) { if (selBtn) {
selBtn.addEventListener('click', () => { selBtn.classList.add('disabled');
const state = store.getState(); selBtn.setAttribute('title', 'Próximamente');
store.setState({
selectionMode: !state.selectionMode,
selected: state.selectionMode ? new Set() : state.selected
});
selBtn.classList.toggle('active', !state.selectionMode);
this.updateSelectionCount();
this.renderView();
});
} }
// Get selected // Get selected - DESACTIVADO TEMPORALMENTE
const getBtn = $('#btn-get'); const getBtn = $('#btn-get');
if (getBtn) { if (getBtn) {
getBtn.addEventListener('click', () => { getBtn.classList.add('disabled');
const state = store.getState(); getBtn.setAttribute('title', 'Próximamente');
if (state.selected.size === 0) {
toast('No hay seleccionados');
return;
}
navigator.clipboard.writeText([...state.selected].join('\n'))
.then(() => toast(`Copiados ${state.selected.size} mrfs`));
});
} }
// API modal // API modal
@@ -451,16 +272,12 @@ class App {
closeBtn?.addEventListener('click', () => apiModal.classList.remove('open')); closeBtn?.addEventListener('click', () => apiModal.classList.remove('open'));
} }
// Keyboard shortcuts
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
this.detailPanel?.close(); this.detailPanel?.close();
$('#api-modal')?.classList.remove('open'); $('#api-modal')?.classList.remove('open');
if (store.getState().selectionMode) {
store.setState({ selectionMode: false, selected: new Set() });
$('#btn-sel')?.classList.remove('active');
this.renderView();
}
} }
if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') { if (e.key === '/' && (e.target as HTMLElement).tagName !== 'INPUT') {
e.preventDefault(); e.preventDefault();
@@ -468,14 +285,6 @@ class App {
} }
}); });
} }
private updateSelectionCount(): void {
const counter = $('#sel-count');
if (counter) {
const count = store.getState().selected.size;
counter.textContent = count > 0 ? `(${count})` : '';
}
}
} }
// Bootstrap // Bootstrap

View File

@@ -127,6 +127,13 @@ body {
font-weight: 600; font-weight: 600;
} }
/* Disabled state */
.sel-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* === GROUPS BAR === */ /* === GROUPS BAR === */
.groups-bar { .groups-bar {
height: 44px; height: 44px;
@@ -399,6 +406,120 @@ body {
.node.selected circle { stroke: var(--accent); stroke-width: 4; } .node.selected circle { stroke: var(--accent); stroke-width: 4; }
.link { stroke-opacity: 0.5; } .link { stroke-opacity: 0.5; }
/* Graph Floating Sidebar */
.graph-sidebar {
position: absolute;
top: 16px;
left: 16px;
width: 200px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-size: 0.8em;
max-height: calc(100% - 32px);
overflow-y: auto;
z-index: 10;
}
.graph-section { margin-bottom: 14px; }
.graph-section:last-child { margin-bottom: 0; }
.graph-section-title {
font-size: 0.75em;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.graph-stat {
display: flex;
justify-content: space-between;
font-size: 0.85em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-stat-value { color: var(--text); font-weight: 600; }
.graph-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85em;
color: var(--text-muted);
margin-bottom: 6px;
cursor: pointer;
}
.graph-checkbox:hover { color: var(--text); }
.graph-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.graph-slider { margin-bottom: 10px; }
.graph-slider-label {
display: flex;
justify-content: space-between;
font-size: 0.8em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-slider-value { color: var(--text); font-weight: 600; }
.graph-slider input[type="range"] {
width: 100%;
height: 4px;
background: var(--border);
border-radius: 2px;
-webkit-appearance: none;
cursor: pointer;
}
.graph-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
}
/* Graph Controls (Zoom) */
.graph-controls {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 6px;
z-index: 10;
}
/* Graph Legend */
.graph-legend {
position: absolute;
bottom: 16px;
left: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
background: rgba(18, 18, 26, 0.9);
padding: 10px 14px;
border-radius: 8px;
z-index: 10;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75em;
color: var(--text-muted);
}
/* === DETAIL PANEL === */ /* === DETAIL PANEL === */
.detail-panel { .detail-panel {
width: 0; width: 0;
@@ -614,88 +735,3 @@ select {
} }
select:focus { outline: none; border-color: var(--accent); } select:focus { outline: none; border-color: var(--accent); }
/* === GRAPH OPTIONS PANEL === */
.graph-options {
padding: 10px;
overflow-y: auto;
width: 180px;
}
.graph-section {
margin-bottom: 16px;
}
.graph-section-title {
font-size: 0.7em;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.graph-stat {
display: flex;
justify-content: space-between;
font-size: 0.75em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-stat-value {
color: var(--text);
font-weight: 600;
}
.graph-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75em;
color: var(--text-muted);
margin-bottom: 6px;
cursor: pointer;
}
.graph-checkbox:hover { color: var(--text); }
.graph-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.graph-checkbox .color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 4px;
}
.graph-slider {
margin-bottom: 12px;
}
.graph-slider-label {
display: flex;
justify-content: space-between;
font-size: 0.7em;
color: var(--text-muted);
margin-bottom: 4px;
}
.graph-slider-value {
color: var(--text);
font-weight: 600;
}
.graph-slider input[type="range"] {
width: 100%;
height: 4px;
background: var(--border);
border-radius: 2px;
-webkit-appearance: none;
cursor: pointer;
}
.graph-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
}
.left-panel.graph-mode {
width: 180px;
}

View File

@@ -34,11 +34,13 @@ export function delegateEvent<T extends Event>(
selector: string, selector: string,
eventType: string, eventType: string,
handler: (event: T, target: HTMLElement) => void handler: (event: T, target: HTMLElement) => void
): void { ): () => void {
container.addEventListener(eventType, (event) => { const listener = (event: Event) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(selector); const target = (event.target as HTMLElement).closest<HTMLElement>(selector);
if (target && container.contains(target)) { if (target && container.contains(target)) {
handler(event as T, target); handler(event as T, target);
} }
}); };
container.addEventListener(eventType, listener);
return () => container.removeEventListener(eventType, listener);
} }

View File

@@ -10,10 +10,22 @@ type D3Module = typeof import('d3');
type D3Selection = any; type D3Selection = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type D3Simulation = any; type D3Simulation = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type D3Zoom = any;
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
type: EdgeType;
weight: number;
}
export class GraphView extends View { export class GraphView extends View {
private d3: D3Module | null = null; private d3: D3Module | null = null;
private simulation: D3Simulation | null = null; private simulation: D3Simulation | null = null;
private zoom: D3Zoom | null = null;
private svg: D3Selection | null = null;
private g: D3Selection | null = null;
private showDetail: (mrf: string) => void; private showDetail: (mrf: string) => void;
constructor( constructor(
@@ -62,22 +74,19 @@ export class GraphView extends View {
const nodeMap = new Map<string, GraphNode>(); const nodeMap = new Map<string, GraphNode>();
filtered.forEach(tag => { filtered.forEach(tag => {
const tagCat = this.getTagCategory(tag.mrf);
if (!state.graphFilters.cats.has(tagCat)) return;
nodeMap.set(tag.mrf, { nodeMap.set(tag.mrf, {
id: tag.mrf, id: tag.mrf,
ref: tag.alias || tag.ref || tag.mrf.slice(0, 8), ref: tag.alias || tag.ref || tag.mrf.slice(0, 8),
name: getName(tag, state.lang), name: getName(tag, state.lang),
img: getImg(tag), img: getImg(tag),
cat: 'hst' as CategoryKey cat: tagCat
}); });
}); });
// Build edges // Build edges
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
type: EdgeType;
weight: number;
}
const edges: GraphLink[] = []; const edges: GraphLink[] = [];
state.graphEdges.forEach(e => { state.graphEdges.forEach(e => {
@@ -113,27 +122,44 @@ export class GraphView extends View {
return; return;
} }
// Clear and create SVG // Create container structure
this.container.innerHTML = ''; this.container.innerHTML = `
<div class="graph-sidebar" id="graph-sidebar"></div>
<div class="graph-controls">
<button class="btn btn-sm" id="graph-fit">Ajustar</button>
<button class="btn btn-sm" id="graph-zin">+</button>
<button class="btn btn-sm" id="graph-zout">-</button>
</div>
<div class="graph-legend" id="graph-legend"></div>
`;
// Render sidebar and legend
this.renderSidebar(nodes.length, edges.length);
this.renderLegend();
// Create SVG
const width = this.container.clientWidth; const width = this.container.clientWidth;
const height = this.container.clientHeight || 600; const height = this.container.clientHeight || 600;
const svg: D3Selection = d3.select(this.container) this.svg = d3.select(this.container)
.append('svg') .append('svg')
.attr('width', '100%') .attr('width', '100%')
.attr('height', '100%') .attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`); .attr('viewBox', `0 0 ${width} ${height}`);
const g = svg.append('g'); this.g = this.svg.append('g');
// Zoom // Zoom
const zoom = d3.zoom() this.zoom = d3.zoom()
.scaleExtent([0.1, 4]) .scaleExtent([0.1, 4])
.on('zoom', (event: { transform: string }) => { .on('zoom', (event: { transform: string }) => {
g.attr('transform', event.transform); this.g.attr('transform', event.transform);
}); });
svg.call(zoom); this.svg.call(this.zoom);
// Bind zoom controls
this.bindZoomControls();
// Simulation // Simulation
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -147,16 +173,16 @@ export class GraphView extends View {
.force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5));
// Links // Links
const link = g.append('g') const link = this.g.append('g')
.selectAll('line') .selectAll('line')
.data(edges) .data(edges)
.join('line') .join('line')
.attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999') .attr('stroke', (d: GraphLink) => EDGE_COLORS[d.type] || '#999')
.attr('stroke-width', (d: GraphLink) => Math.sqrt(d.weight)) .attr('stroke-width', (d: GraphLink) => Math.max(1.5, Math.sqrt(d.weight)))
.attr('stroke-opacity', 0.6); .attr('stroke-opacity', 0.6);
// Nodes // Nodes
const node = g.append('g') const node = this.g.append('g')
.selectAll('g') .selectAll('g')
.data(nodes) .data(nodes)
.join('g') .join('g')
@@ -191,12 +217,13 @@ export class GraphView extends View {
.attr('dy', nodeSize / 2 + 12) .attr('dy', nodeSize / 2 + 12)
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.attr('font-size', 10) .attr('font-size', 10)
.attr('fill', 'var(--text-primary)'); .attr('fill', 'var(--text)');
} }
node.on('click', (_: MouseEvent, d: GraphNode) => { node.on('click', (_: MouseEvent, d: GraphNode) => {
if (state.selectionMode) { const currentState = this.getState();
const newSelected = new Set(state.selected); if (currentState.selectionMode) {
const newSelected = new Set(currentState.selected);
if (newSelected.has(d.id)) { if (newSelected.has(d.id)) {
newSelected.delete(d.id); newSelected.delete(d.id);
} else { } else {
@@ -220,6 +247,200 @@ export class GraphView extends View {
}); });
} }
private renderSidebar(nodeCount: number, edgeCount: number): void {
const sidebar = this.container.querySelector('#graph-sidebar');
if (!sidebar) return;
const state = this.getState();
const { graphFilters, graphSettings } = state;
sidebar.innerHTML = `
<div class="graph-section">
<div class="graph-stat">
<span>Nodos</span>
<span class="graph-stat-value">${nodeCount}</span>
</div>
<div class="graph-stat">
<span>Edges</span>
<span class="graph-stat-value">${edgeCount}</span>
</div>
</div>
<div class="graph-section">
<div class="graph-section-title">Categorias</div>
${Object.entries(CATS).map(([key, config]) => `
<label class="graph-checkbox">
<input type="checkbox" data-cat="${key}" ${graphFilters.cats.has(key as CategoryKey) ? 'checked' : ''}>
<span class="color-dot" style="background: ${config.color}"></span>
${config.name}
</label>
`).join('')}
</div>
<div class="graph-section">
<div class="graph-section-title">Relaciones</div>
${Object.entries(EDGE_COLORS).map(([key, color]) => `
<label class="graph-checkbox">
<input type="checkbox" data-edge="${key}" ${graphFilters.edges.has(key as EdgeType) ? 'checked' : ''}>
<span class="color-dot" style="background: ${color}"></span>
${key}
</label>
`).join('')}
</div>
<div class="graph-section">
<div class="graph-section-title">Visualizacion</div>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-img" ${graphSettings.showImg ? 'checked' : ''}>
Imagenes
</label>
<label class="graph-checkbox">
<input type="checkbox" id="graph-show-lbl" ${graphSettings.showLbl ? 'checked' : ''}>
Etiquetas
</label>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Nodo</span>
<span class="graph-slider-value" id="node-size-val">${graphSettings.nodeSize}px</span>
</div>
<input type="range" id="graph-node-size" min="10" max="60" value="${graphSettings.nodeSize}">
</div>
<div class="graph-slider">
<div class="graph-slider-label">
<span>Distancia</span>
<span class="graph-slider-value" id="link-dist-val">${graphSettings.linkDist}px</span>
</div>
<input type="range" id="graph-link-dist" min="30" max="200" value="${graphSettings.linkDist}">
</div>
</div>
`;
this.bindSidebarEvents(sidebar as HTMLElement);
}
private bindSidebarEvents(sidebar: HTMLElement): void {
// Category checkboxes
sidebar.querySelectorAll<HTMLInputElement>('[data-cat]').forEach(cb => {
cb.onchange = () => {
const cat = cb.dataset.cat as CategoryKey;
const state = this.getState();
const newCats = new Set(state.graphFilters.cats);
cb.checked ? newCats.add(cat) : newCats.delete(cat);
this.store.setState({ graphFilters: { ...state.graphFilters, cats: newCats } });
this.render();
};
});
// Edge checkboxes
sidebar.querySelectorAll<HTMLInputElement>('[data-edge]').forEach(cb => {
cb.onchange = () => {
const edge = cb.dataset.edge as EdgeType;
const state = this.getState();
const newEdges = new Set(state.graphFilters.edges);
cb.checked ? newEdges.add(edge) : newEdges.delete(edge);
this.store.setState({ graphFilters: { ...state.graphFilters, edges: newEdges } });
this.render();
};
});
// Show images
const showImg = sidebar.querySelector<HTMLInputElement>('#graph-show-img');
if (showImg) {
showImg.onchange = () => {
const state = this.getState();
this.store.setState({ graphSettings: { ...state.graphSettings, showImg: showImg.checked } });
this.render();
};
}
// Show labels
const showLbl = sidebar.querySelector<HTMLInputElement>('#graph-show-lbl');
if (showLbl) {
showLbl.onchange = () => {
const state = this.getState();
this.store.setState({ graphSettings: { ...state.graphSettings, showLbl: showLbl.checked } });
this.render();
};
}
// Node size slider
const nodeSize = sidebar.querySelector<HTMLInputElement>('#graph-node-size');
const nodeSizeVal = sidebar.querySelector('#node-size-val');
if (nodeSize) {
nodeSize.oninput = () => {
const size = parseInt(nodeSize.value, 10);
if (nodeSizeVal) nodeSizeVal.textContent = `${size}px`;
const state = this.getState();
this.store.setState({ graphSettings: { ...state.graphSettings, nodeSize: size } });
this.render();
};
}
// Link distance slider
const linkDist = sidebar.querySelector<HTMLInputElement>('#graph-link-dist');
const linkDistVal = sidebar.querySelector('#link-dist-val');
if (linkDist) {
linkDist.oninput = () => {
const dist = parseInt(linkDist.value, 10);
if (linkDistVal) linkDistVal.textContent = `${dist}px`;
const state = this.getState();
this.store.setState({ graphSettings: { ...state.graphSettings, linkDist: dist } });
// Update simulation without full re-render
if (this.simulation) {
this.simulation.force('link').distance(dist);
this.simulation.alpha(0.3).restart();
}
};
}
}
private renderLegend(): void {
const legend = this.container.querySelector('#graph-legend');
if (!legend) return;
const state = this.getState();
const activeCats = Array.from(state.graphFilters.cats);
legend.innerHTML = activeCats.map(cat => `
<div class="legend-item">
<span class="color-dot" style="background: ${CATS[cat]?.color || '#7c8aff'}"></span>
<span>${CATS[cat]?.name || cat}</span>
</div>
`).join('');
}
private bindZoomControls(): void {
if (!this.d3 || !this.svg || !this.zoom) return;
const d3 = this.d3;
// Fit to viewport
const fitBtn = this.container.querySelector<HTMLButtonElement>('#graph-fit');
if (fitBtn) {
fitBtn.onclick = () => {
this.svg.transition().duration(300).call(
this.zoom.transform,
d3.zoomIdentity.translate(0, 0).scale(1)
);
};
}
// Zoom in
const zinBtn = this.container.querySelector<HTMLButtonElement>('#graph-zin');
if (zinBtn) {
zinBtn.onclick = () => {
this.svg.transition().duration(200).call(this.zoom.scaleBy, 1.5);
};
}
// Zoom out
const zoutBtn = this.container.querySelector<HTMLButtonElement>('#graph-zout');
if (zoutBtn) {
zoutBtn.onclick = () => {
this.svg.transition().duration(200).call(this.zoom.scaleBy, 0.67);
};
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private createDrag(d3: D3Module, simulation: D3Simulation): any { private createDrag(d3: D3Module, simulation: D3Simulation): any {
return d3.drag() return d3.drag()
@@ -242,8 +463,20 @@ export class GraphView extends View {
}); });
} }
private getTagCategory(mrf: string): CategoryKey {
if (mrf.startsWith('spe_')) return 'spe';
if (mrf.startsWith('vue_')) return 'vue';
if (mrf.startsWith('vsn_')) return 'vsn';
if (mrf.startsWith('msn_')) return 'msn';
if (mrf.startsWith('flg_')) return 'flg';
return 'hst';
}
unmount(): void { unmount(): void {
this.simulation?.stop(); this.simulation?.stop();
this.svg = null;
this.g = null;
this.zoom = null;
super.unmount(); super.unmount();
} }
} }

View File

@@ -53,12 +53,14 @@ export class GridView extends View {
} }
private bindEvents(): void { private bindEvents(): void {
const state = this.getState(); this.cleanupListeners();
const cleanup = delegateEvent<MouseEvent>(this.container, '.card', 'click', (_, target) => {
delegateEvent<MouseEvent>(this.container, '.card', 'click', (_, target) => {
const mrf = target.dataset.mrf; const mrf = target.dataset.mrf;
if (!mrf) return; if (!mrf) return;
// Obtener estado FRESCO en cada click (no del closure)
const state = this.getState();
if (state.selectionMode) { if (state.selectionMode) {
const newSelected = new Set(state.selected); const newSelected = new Set(state.selected);
if (newSelected.has(mrf)) { if (newSelected.has(mrf)) {
@@ -71,5 +73,6 @@ export class GridView extends View {
this.showDetail(mrf); this.showDetail(mrf);
} }
}); });
this.addCleanup(cleanup);
} }
} }

View File

@@ -75,9 +75,9 @@ export class TreeView extends View {
} }
private bindEvents(): void { private bindEvents(): void {
const state = this.getState(); this.cleanupListeners();
delegateEvent<MouseEvent>(this.container, '.tree-header', 'click', (_, target) => { const cleanup1 = delegateEvent<MouseEvent>(this.container, '.tree-header', 'click', (_, target) => {
const group = target.dataset.group; const group = target.dataset.group;
if (!group) return; if (!group) return;
@@ -88,11 +88,15 @@ export class TreeView extends View {
} }
this.render(); this.render();
}); });
this.addCleanup(cleanup1);
delegateEvent<MouseEvent>(this.container, '.tree-item', 'click', (_, target) => { const cleanup2 = delegateEvent<MouseEvent>(this.container, '.tree-item', 'click', (_, target) => {
const mrf = target.dataset.mrf; const mrf = target.dataset.mrf;
if (!mrf) return; if (!mrf) return;
// Obtener estado FRESCO en cada click (no del closure)
const state = this.getState();
if (state.selectionMode) { if (state.selectionMode) {
const newSelected = new Set(state.selected); const newSelected = new Set(state.selected);
if (newSelected.has(mrf)) { if (newSelected.has(mrf)) {
@@ -105,5 +109,6 @@ export class TreeView extends View {
this.showDetail(mrf); this.showDetail(mrf);
} }
}); });
this.addCleanup(cleanup2);
} }
} }

View File

@@ -5,6 +5,7 @@ export abstract class View {
protected container: HTMLElement; protected container: HTMLElement;
protected store: Store<AppState>; protected store: Store<AppState>;
protected unsubscribe?: () => void; protected unsubscribe?: () => void;
protected cleanups: (() => void)[] = [];
constructor(container: HTMLElement, store: Store<AppState>) { constructor(container: HTMLElement, store: Store<AppState>) {
this.container = container; this.container = container;
@@ -20,9 +21,19 @@ export abstract class View {
unmount(): void { unmount(): void {
this.unsubscribe?.(); this.unsubscribe?.();
this.cleanupListeners();
this.container.innerHTML = ''; this.container.innerHTML = '';
} }
protected cleanupListeners(): void {
this.cleanups.forEach(fn => fn());
this.cleanups = [];
}
protected addCleanup(fn: () => void): void {
this.cleanups.push(fn);
}
protected getState(): AppState { protected getState(): AppState {
return this.store.getState(); return this.store.getState();
} }