Files
ARCHITECT 9b244138b5 Add pending apps and frontend components
- 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>
2026-01-16 18:26:59 +00:00

265 lines
9.6 KiB
JavaScript

// === 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 = `
<div class="graph-stats">
<div class="graph-stat">
<div class="graph-stat-val">${nc}</div>
<div class="graph-stat-label">Nodos</div>
</div>
<div class="graph-stat">
<div class="graph-stat-val">${ec}</div>
<div class="graph-stat-label">Edges</div>
</div>
</div>
<h4>Categorias</h4>
<div class="graph-filters">
${Object.entries(CATS).map(([k,v]) =>
`<div class="graph-filter ${state.graphFilters.cats.has(k) ? "active" : ""}" data-cat="${k}">
<span class="dot" style="background:${v.color}"></span>${v.name}
</div>`
).join("")}
</div>
<h4>Relaciones</h4>
<div class="graph-filters">
${Object.entries(EDGE_COLORS).map(([k,v]) =>
`<div class="graph-filter ${state.graphFilters.edges.has(k) ? "active" : ""}" data-edge="${k}">
<span class="dot" style="background:${v}"></span>${k}
</div>`
).join("")}
</div>
<h4>Visualizacion</h4>
<div style="margin:8px 0">
<label><input type="checkbox" id="graph-show-img" ${state.graphSettings.showImg ? "checked" : ""}> Imagenes</label>
</div>
<div style="margin:8px 0">
<label><input type="checkbox" id="graph-show-lbl" ${state.graphSettings.showLbl ? "checked" : ""}> Etiquetas</label>
</div>
<div style="margin:12px 0">
<div style="color:var(--text-muted);margin-bottom:6px">Nodo: ${state.graphSettings.nodeSize}px</div>
<input type="range" class="graph-slider" id="graph-node-size" min="10" max="50" value="${state.graphSettings.nodeSize}">
</div>
<div style="margin:12px 0">
<div style="color:var(--text-muted);margin-bottom:6px">Distancia: ${state.graphSettings.linkDist}px</div>
<input type="range" class="graph-slider" id="graph-link-dist" min="40" max="200" value="${state.graphSettings.linkDist}">
</div>`;
// 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]) => `<div class="legend-item"><div class="legend-color" style="background:${v.color}"></div>${v.name}</div>`)
.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);
}