- apps/captain-mobile: Mobile API service - apps/flow-ui: Flow UI application - apps/mindlink: Mindlink application - apps/storage: Storage API and workers - apps/tzzr-cli: TZZR CLI tool - deck-frontend/backups: Historical TypeScript versions - hst-frontend: Standalone HST frontend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
250 lines
7.2 KiB
TypeScript
250 lines
7.2 KiB
TypeScript
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<AppState>,
|
|
showDetail: (mrf: string) => void
|
|
) {
|
|
super(container, store);
|
|
this.showDetail = showDetail;
|
|
}
|
|
|
|
async mount(): Promise<void> {
|
|
this.container.innerHTML = '<div class="loading">Cargando grafo...</div>';
|
|
|
|
// 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<string, GraphNode>();
|
|
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 = '<div class="empty">Sin nodos para mostrar</div>';
|
|
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();
|
|
}
|
|
}
|