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>
This commit is contained in:
72
hst-frontend/js/views/detail.js
Normal file
72
hst-frontend/js/views/detail.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// === DETAIL PANEL ===
|
||||
|
||||
async function showDetail(mrf) {
|
||||
const tag = state.tags.find(t => t.mrf === mrf);
|
||||
if (!tag) return;
|
||||
|
||||
state.selectedTag = tag;
|
||||
document.getElementById("right-panel").classList.add("open");
|
||||
|
||||
const hdr = document.getElementById("detail-header");
|
||||
const img = getFullImg(tag);
|
||||
const ref = (tag.ref || "").toUpperCase();
|
||||
|
||||
// Set placeholder
|
||||
document.getElementById("detail-placeholder").textContent = ref.slice(0, 2);
|
||||
|
||||
// Remove existing image
|
||||
hdr.querySelector("img")?.remove();
|
||||
|
||||
// Add image if exists
|
||||
if (img) {
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "detail-img";
|
||||
imgEl.src = img;
|
||||
imgEl.alt = ref;
|
||||
hdr.insertBefore(imgEl, hdr.firstChild);
|
||||
}
|
||||
|
||||
// Bind close
|
||||
document.getElementById("detail-close").onclick = closeDetail;
|
||||
|
||||
// Fill basic info
|
||||
document.getElementById("detail-ref").textContent = ref;
|
||||
document.getElementById("detail-mrf").textContent = tag.mrf || "";
|
||||
document.getElementById("detail-mrf").onclick = () => copyMrf(tag.mrf);
|
||||
document.getElementById("detail-name").textContent = getName(tag);
|
||||
document.getElementById("detail-desc").textContent = tag.txt || tag.alias || "";
|
||||
|
||||
// Fetch and render children
|
||||
const children = await fetchChildren(mrf);
|
||||
const chSec = document.getElementById("children-section");
|
||||
const chList = document.getElementById("children-list");
|
||||
|
||||
if (children.length) {
|
||||
chSec.style.display = "block";
|
||||
chList.innerHTML = children.map(c =>
|
||||
`<span class="tag-chip" data-mrf="${c.mrf}">${c.ref || c.mrf.slice(0,8)}</span>`
|
||||
).join("");
|
||||
chList.querySelectorAll(".tag-chip").forEach(ch =>
|
||||
ch.onclick = () => showDetail(ch.dataset.mrf)
|
||||
);
|
||||
} else {
|
||||
chSec.style.display = "none";
|
||||
}
|
||||
|
||||
// Fetch and render related
|
||||
const related = await fetchRelated(mrf);
|
||||
const relSec = document.getElementById("related-section");
|
||||
const relList = document.getElementById("related-list");
|
||||
|
||||
if (related.length) {
|
||||
relSec.style.display = "block";
|
||||
relList.innerHTML = related.map(r =>
|
||||
`<span class="tag-chip" data-mrf="${r.mrf}" title="${r.edge_type}">${r.ref || r.mrf.slice(0,8)}</span>`
|
||||
).join("");
|
||||
relList.querySelectorAll(".tag-chip").forEach(ch =>
|
||||
ch.onclick = () => showDetail(ch.dataset.mrf)
|
||||
);
|
||||
} else {
|
||||
relSec.style.display = "none";
|
||||
}
|
||||
}
|
||||
264
hst-frontend/js/views/graph.js
Normal file
264
hst-frontend/js/views/graph.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// === 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);
|
||||
}
|
||||
33
hst-frontend/js/views/grid.js
Normal file
33
hst-frontend/js/views/grid.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// === GRID VIEW ===
|
||||
|
||||
function renderGrid() {
|
||||
const el = document.getElementById("grid-view");
|
||||
const filtered = filterTags();
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">:/</div><div>No se encontraron tags</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = filtered.map(tag => {
|
||||
const img = getImg(tag);
|
||||
const ref = (tag.ref || "").toUpperCase();
|
||||
const ph = ref.slice(0, 2);
|
||||
const sel = state.selected.has(tag.mrf);
|
||||
|
||||
return `<div class="card ${sel ? "selected" : ""}" data-mrf="${tag.mrf}">
|
||||
<div class="card-checkbox ${state.selectionMode ? "visible" : ""} ${sel ? "checked" : ""}"></div>
|
||||
<div class="card-image">
|
||||
${img ? `<img class="card-img" src="${img}" loading="lazy" alt="${ref}">` : `<div class="card-placeholder">${ph}</div>`}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-ref">${ref}</div>
|
||||
<div class="card-name">${getName(tag)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
el.querySelectorAll(".card").forEach(c => {
|
||||
c.onclick = () => state.selectionMode ? toggleSel(c.dataset.mrf) : showDetail(c.dataset.mrf);
|
||||
});
|
||||
}
|
||||
64
hst-frontend/js/views/tree.js
Normal file
64
hst-frontend/js/views/tree.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// === TREE VIEW ===
|
||||
|
||||
function renderTree() {
|
||||
const el = document.getElementById("tree-view");
|
||||
const filtered = filterTags();
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div>Sin datos</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group tags by set_hst
|
||||
const groups = new Map();
|
||||
filtered.forEach(t => {
|
||||
const g = t.set_hst || "other";
|
||||
if (!groups.has(g)) groups.set(g, []);
|
||||
groups.get(g).push(t);
|
||||
});
|
||||
|
||||
el.innerHTML = [...groups.entries()].map(([gid, tags]) => {
|
||||
const info = state.groups.find(g => g.mrf === gid);
|
||||
const name = info ? (info.name_es || info.ref) : gid === "other" ? "Sin grupo" : gid.slice(0, 10);
|
||||
|
||||
return `<div class="tree-root">
|
||||
<div class="tree-item" data-expand="${gid}">
|
||||
<span class="tree-toggle">+</span>
|
||||
<span class="tree-name" style="font-weight:600;color:var(--accent)">${name} (${tags.length})</span>
|
||||
</div>
|
||||
<div class="tree-children" id="tree-${gid}">
|
||||
${tags.map(t => {
|
||||
const sel = state.selected.has(t.mrf);
|
||||
const img = getImg(t);
|
||||
return `<div class="tree-node">
|
||||
<div class="tree-item ${sel ? "selected" : ""}" data-mrf="${t.mrf}">
|
||||
<span class="tree-checkbox ${state.selectionMode ? "visible" : ""} ${sel ? "checked" : ""}"></span>
|
||||
<span class="tree-toggle"></span>
|
||||
${img ? `<img class="tree-img" src="${img}" alt="">` : ""}
|
||||
<span class="tree-name">${t.ref} - ${getName(t)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Bind expand/collapse
|
||||
el.querySelectorAll(".tree-item[data-expand]").forEach(i => {
|
||||
i.onclick = () => {
|
||||
const ch = document.getElementById(`tree-${i.dataset.expand}`);
|
||||
if (ch) {
|
||||
ch.classList.toggle("open");
|
||||
i.querySelector(".tree-toggle").textContent = ch.classList.contains("open") ? "-" : "+";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Bind tag click
|
||||
el.querySelectorAll(".tree-item[data-mrf]").forEach(i => {
|
||||
i.onclick = e => {
|
||||
e.stopPropagation();
|
||||
state.selectionMode ? toggleSel(i.dataset.mrf) : showDetail(i.dataset.mrf);
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user