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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user