|
|
|
|
@@ -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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|