import { View } from '../View.ts'; import { filterTags, getName, getImg } from '@/utils/index.ts'; import { fetchGraphEdges, fetchTreeEdges } from '@/api/index.ts'; import { CATS, EDGE_COLORS } from '@/config/index.ts'; import type { Store } from '@/state/store.ts'; import type { AppState, GraphNode, CategoryKey, EdgeType } from '@/types/index.ts'; type D3Module = typeof import('d3'); // eslint-disable-next-line @typescript-eslint/no-explicit-any type D3Selection = any; // eslint-disable-next-line @typescript-eslint/no-explicit-any type D3Simulation = any; export class GraphView extends View { private d3: D3Module | null = null; private simulation: D3Simulation | null = null; private showDetail: (mrf: string) => void; constructor( container: HTMLElement, store: Store, showDetail: (mrf: string) => void ) { super(container, store); this.showDetail = showDetail; } async mount(): Promise { this.container.innerHTML = '
Cargando grafo...
'; // Lazy load D3 if (!this.d3) { this.d3 = await import('d3'); } // Load graph data const state = this.getState(); if (state.graphEdges.length === 0) { const [graphEdges, treeEdges] = await Promise.all([ fetchGraphEdges(), fetchTreeEdges() ]); this.store.setState({ graphEdges, treeEdges }); } this.render(); } render(): void { if (!this.d3) return; const d3 = this.d3; const state = this.getState(); // Build nodes from filtered tags const filtered = filterTags(state.tags, { search: state.search, group: state.group, library: state.library, libraryMembers: state.libraryMembers, lang: state.lang }); const nodeMap = new Map(); filtered.forEach(tag => { 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 }); }); // Build edges interface GraphLink { source: string | GraphNode; target: string | GraphNode; type: EdgeType; weight: number; } const edges: GraphLink[] = []; state.graphEdges.forEach(e => { if (nodeMap.has(e.mrf_a) && nodeMap.has(e.mrf_b)) { if (state.graphFilters.edges.has(e.edge_type)) { edges.push({ source: e.mrf_a, target: e.mrf_b, type: e.edge_type, weight: e.weight || 1 }); } } }); state.treeEdges.forEach(e => { if (nodeMap.has(e.mrf_parent) && nodeMap.has(e.mrf_child)) { if (state.graphFilters.edges.has('hierarchy')) { edges.push({ source: e.mrf_parent, target: e.mrf_child, type: 'hierarchy', weight: 1 }); } } }); const nodes = Array.from(nodeMap.values()); if (nodes.length === 0) { this.container.innerHTML = '
Sin nodos para mostrar
'; return; } // Clear and create SVG this.container.innerHTML = ''; const width = this.container.clientWidth; const height = this.container.clientHeight || 600; const svg: D3Selection = d3.select(this.container) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${width} ${height}`); const g = svg.append('g'); // Zoom const zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event: { transform: string }) => { g.attr('transform', event.transform); }); svg.call(zoom); // Simulation // eslint-disable-next-line @typescript-eslint/no-explicit-any this.simulation = d3.forceSimulation(nodes as any) .force('link', d3.forceLink(edges) // eslint-disable-next-line @typescript-eslint/no-explicit-any .id((d: any) => d.id) .distance(state.graphSettings.linkDist)) .force('charge', d3.forceManyBody().strength(-150)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(state.graphSettings.nodeSize + 5)); // Links const link = 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-opacity', 0.6); // Nodes const node = g.append('g') .selectAll('g') .data(nodes) .join('g') .attr('cursor', 'pointer') .call(this.createDrag(d3, this.simulation)); const nodeSize = state.graphSettings.nodeSize; if (state.graphSettings.showImg) { node.append('image') .attr('xlink:href', (d: GraphNode) => d.img || '') .attr('width', nodeSize) .attr('height', nodeSize) .attr('x', -nodeSize / 2) .attr('y', -nodeSize / 2) .attr('clip-path', 'circle(50%)'); // Fallback for nodes without image node.filter((d: GraphNode) => !d.img) .append('circle') .attr('r', nodeSize / 2) .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); } else { node.append('circle') .attr('r', nodeSize / 2) .attr('fill', (d: GraphNode) => CATS[d.cat]?.color || '#7c8aff'); } if (state.graphSettings.showLbl) { node.append('text') .text((d: GraphNode) => d.ref) .attr('dy', nodeSize / 2 + 12) .attr('text-anchor', 'middle') .attr('font-size', 10) .attr('fill', 'var(--text-primary)'); } node.on('click', (_: MouseEvent, d: GraphNode) => { if (state.selectionMode) { const newSelected = new Set(state.selected); if (newSelected.has(d.id)) { newSelected.delete(d.id); } else { newSelected.add(d.id); } this.store.setState({ selected: newSelected }); } else { this.showDetail(d.id); } }); // Tick this.simulation.on('tick', () => { link .attr('x1', (d: GraphLink) => (d.source as GraphNode).x!) .attr('y1', (d: GraphLink) => (d.source as GraphNode).y!) .attr('x2', (d: GraphLink) => (d.target as GraphNode).x!) .attr('y2', (d: GraphLink) => (d.target as GraphNode).y!); node.attr('transform', (d: GraphNode) => `translate(${d.x},${d.y})`); }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private createDrag(d3: D3Module, simulation: D3Simulation): any { return d3.drag() // eslint-disable-next-line @typescript-eslint/no-explicit-any .on('start', (event: any, d: any) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .on('drag', (event: any, d: any) => { d.fx = event.x; d.fy = event.y; }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .on('end', (event: any, d: any) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }); } unmount(): void { this.simulation?.stop(); super.unmount(); } }