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

@@ -10,10 +10,22 @@ type D3Module = typeof import('d3');
type D3Selection = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-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 {
private d3: D3Module | 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;
constructor(
@@ -62,22 +74,19 @@ export class GraphView extends View {
const nodeMap = new Map<string, GraphNode>();
filtered.forEach(tag => {
const tagCat = this.getTagCategory(tag.mrf);
if (!state.graphFilters.cats.has(tagCat)) return;
nodeMap.set(tag.mrf, {
id: tag.mrf,
ref: tag.alias || tag.ref || tag.mrf.slice(0, 8),
name: getName(tag, state.lang),
img: getImg(tag),
cat: 'hst' as CategoryKey
cat: tagCat
});
});
// Build edges
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
type: EdgeType;
weight: number;
}
const edges: GraphLink[] = [];
state.graphEdges.forEach(e => {
@@ -113,27 +122,44 @@ export class GraphView extends View {
return;
}
// Clear and create SVG
this.container.innerHTML = '';
// Create container structure
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 height = this.container.clientHeight || 600;
const svg: D3Selection = d3.select(this.container)
this.svg = d3.select(this.container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`);
const g = svg.append('g');
this.g = this.svg.append('g');
// Zoom
const zoom = d3.zoom()
this.zoom = d3.zoom()
.scaleExtent([0.1, 4])
.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
// 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));
// Links
const link = g.append('g')
const link = this.g.append('g')
.selectAll('line')
.data(edges)
.join('line')
.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);
// Nodes
const node = g.append('g')
const node = this.g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
@@ -191,12 +217,13 @@ export class GraphView extends View {
.attr('dy', nodeSize / 2 + 12)
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.attr('fill', 'var(--text-primary)');
.attr('fill', 'var(--text)');
}
node.on('click', (_: MouseEvent, d: GraphNode) => {
if (state.selectionMode) {
const newSelected = new Set(state.selected);
const currentState = this.getState();
if (currentState.selectionMode) {
const newSelected = new Set(currentState.selected);
if (newSelected.has(d.id)) {
newSelected.delete(d.id);
} 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
private createDrag(d3: D3Module, simulation: D3Simulation): any {
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 {
this.simulation?.stop();
this.svg = null;
this.g = null;
this.zoom = null;
super.unmount();
}
}

View File

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

View File

@@ -75,9 +75,9 @@ export class TreeView extends View {
}
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;
if (!group) return;
@@ -88,11 +88,15 @@ export class TreeView extends View {
}
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;
if (!mrf) return;
// Obtener estado FRESCO en cada click (no del closure)
const state = this.getState();
if (state.selectionMode) {
const newSelected = new Set(state.selected);
if (newSelected.has(mrf)) {
@@ -105,5 +109,6 @@ export class TreeView extends View {
this.showDetail(mrf);
}
});
this.addCleanup(cleanup2);
}
}

View File

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