// === GRAPH VIEW === let gSvg, gG, gZoom, gSim; function renderGraphSidebar() { const el = document.getElementById("graph-sidebar"); const nc = filterTags().length; const ec = state.graphEdges.length + state.treeEdges.length; el.innerHTML = `
${nc}
Nodos
${ec}
Edges

Categorias

${Object.entries(CATS).map(([k,v]) => `
${v.name}
` ).join("")}

Relaciones

${Object.entries(EDGE_COLORS).map(([k,v]) => `
${k}
` ).join("")}

Visualizacion

Nodo: ${state.graphSettings.nodeSize}px
Distancia: ${state.graphSettings.linkDist}px
`; // Bind category filters el.querySelectorAll("[data-cat]").forEach(f => { f.onclick = () => { const c = f.dataset.cat; state.graphFilters.cats.has(c) ? state.graphFilters.cats.delete(c) : state.graphFilters.cats.add(c); initGraph(); }; }); // Bind edge filters el.querySelectorAll("[data-edge]").forEach(f => { f.onclick = () => { const e = f.dataset.edge; state.graphFilters.edges.has(e) ? state.graphFilters.edges.delete(e) : state.graphFilters.edges.add(e); initGraph(); }; }); // Bind settings document.getElementById("graph-show-img").onchange = e => { state.graphSettings.showImg = e.target.checked; updateGraphVisuals(); }; document.getElementById("graph-show-lbl").onchange = e => { state.graphSettings.showLbl = e.target.checked; updateGraphVisuals(); }; document.getElementById("graph-node-size").oninput = e => { state.graphSettings.nodeSize = +e.target.value; updateGraphVisuals(); }; document.getElementById("graph-link-dist").oninput = e => { state.graphSettings.linkDist = +e.target.value; if (gSim) { gSim.force("link").distance(state.graphSettings.linkDist); gSim.alpha(0.3).restart(); } }; } function renderGraphLegend() { document.getElementById("graph-legend").innerHTML = Object.entries(CATS) .filter(([k]) => state.graphFilters.cats.has(k)) .map(([k,v]) => `
${v.name}
`) .join(""); } function updateGraphVisuals() { if (!gG) return; const ns = state.graphSettings.nodeSize; gG.selectAll(".node circle").attr("r", ns); gG.selectAll(".node image") .attr("x", -ns+5) .attr("y", -ns+5) .attr("width", (ns-5)*2) .attr("height", (ns-5)*2) .style("display", state.graphSettings.showImg ? "block" : "none"); gG.selectAll(".node text") .attr("dx", ns+5) .style("display", state.graphSettings.showLbl ? "block" : "none"); renderGraphSidebar(); } async function initGraph() { const container = document.getElementById("graph-view"); const svg = document.getElementById("graph-svg"); const w = container.clientWidth - 230; const h = container.clientHeight; if (gSim) gSim.stop(); gSvg = d3.select(svg).attr("width", w + 230).attr("height", h); gSvg.selectAll("*").remove(); gG = gSvg.append("g").attr("transform", "translate(230, 0)"); gZoom = d3.zoom() .scaleExtent([0.05, 4]) .on("zoom", e => gG.attr("transform", `translate(230, 0) ${e.transform}`)); gSvg.call(gZoom); renderGraphSidebar(); renderGraphLegend(); // Fetch data if needed if (!state.graphEdges.length) await fetchGraphEdges(); if (!state.treeEdges.length) await fetchTreeEdges(); // Build nodes const filtered = filterTags(); const nodes = filtered.map(t => { const grupo = t.set_hst || "hst"; const groupInfo = state.groups.find(g => g.mrf === grupo); const cat = groupInfo?.ref || "hst"; return { id: t.mrf, ref: t.ref || "", name: getName(t), img: getImg(t), cat }; }).filter(n => state.graphFilters.cats.has(n.cat) || state.graphFilters.cats.has("hst")); if (!nodes.length) { gG.append("text") .attr("x", w/2) .attr("y", h/2) .attr("text-anchor", "middle") .attr("fill", "#666") .text("Sin datos - activa categorias"); return; } // Build edges const nodeIds = new Set(nodes.map(n => n.id)); const edges = []; state.graphEdges.forEach(e => { const t = e.edge_type || "relation"; if (state.graphFilters.edges.has(t) && nodeIds.has(e.mrf_a) && nodeIds.has(e.mrf_b)) { edges.push({source: e.mrf_a, target: e.mrf_b, type: t, weight: e.weight || 0.5}); } }); if (state.graphFilters.edges.has("hierarchy")) { state.treeEdges.forEach(e => { if (nodeIds.has(e.mrf_parent) && nodeIds.has(e.mrf_child)) { edges.push({source: e.mrf_parent, target: e.mrf_child, type: "hierarchy", weight: 0.8}); } }); } // Create simulation const ns = state.graphSettings.nodeSize; gSim = d3.forceSimulation(nodes) .force("link", d3.forceLink(edges).id(d => d.id).distance(state.graphSettings.linkDist)) .force("charge", d3.forceManyBody().strength(-150)) .force("center", d3.forceCenter(w/2, h/2)) .force("collision", d3.forceCollide(ns + 4)); // Draw links const link = gG.append("g") .selectAll("line") .data(edges) .enter() .append("line") .attr("stroke", d => EDGE_COLORS[d.type] || "#333") .attr("stroke-width", d => Math.max(1.5, d.weight * 3)) .attr("class", "link"); // Draw nodes const node = gG.append("g") .selectAll("g") .data(nodes) .enter() .append("g") .attr("class", d => `node ${state.selected.has(d.id) ? "selected" : ""}`) .call(d3.drag() .on("start", (e,d) => { if (!e.active) gSim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on("drag", (e,d) => { d.fx = e.x; d.fy = e.y; }) .on("end", (e,d) => { if (!e.active) gSim.alphaTarget(0); d.fx = null; d.fy = null; }) ); node.append("circle") .attr("r", ns) .attr("fill", d => CATS[d.cat]?.color || "#7c8aff") .attr("stroke", d => state.selected.has(d.id) ? "var(--accent)" : "#1a1a24") .attr("stroke-width", d => state.selected.has(d.id) ? 4 : 2); node.filter(d => d.img && state.graphSettings.showImg) .append("image") .attr("href", d => d.img) .attr("x", -ns+5) .attr("y", -ns+5) .attr("width", (ns-5)*2) .attr("height", (ns-5)*2) .attr("clip-path", "circle(50%)"); node.append("text") .attr("dx", ns+5) .attr("dy", 4) .text(d => d.ref) .style("display", state.graphSettings.showLbl ? "block" : "none"); node.on("click", (e,d) => state.selectionMode ? toggleSel(d.id) : showDetail(d.id)); // Tick handler gSim.on("tick", () => { link .attr("x1", d => d.source.x) .attr("y1", d => d.source.y) .attr("x2", d => d.target.x) .attr("y2", d => d.target.y); node.attr("transform", d => `translate(${d.x},${d.y})`); }); // Controls document.getElementById("graph-fit").onclick = () => { const b = gG.node().getBBox(); if (b.width) { const s = Math.min((w-100)/b.width, (h-100)/b.height, 2); gSvg.transition().call(gZoom.transform, d3.zoomIdentity .translate(w/2-(b.x+b.width/2)*s+230, h/2-(b.y+b.height/2)*s) .scale(s) ); } }; document.getElementById("graph-zin").onclick = () => gSvg.transition().call(gZoom.scaleBy, 1.5); document.getElementById("graph-zout").onclick = () => gSvg.transition().call(gZoom.scaleBy, 0.67); }