model-explorer / js /core /graphPathHighlighter.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* GraphPathHighlighter - Highlight đường Δ‘i trong Δ‘α»“ thα»‹
* Tìm và highlight upstream path (từ input đến node) và downstream path (từ node đến output).
* LΓ m mờ cΓ‘c node/edge khΓ΄ng thuα»™c đường Δ‘i. XΓ³a highlight khi click vΓ o vΓΉng trα»‘ng.
* Requirements: 18.1, 18.2, 18.3, 18.4, 18.5
*/
class GraphPathHighlighter {
constructor() {
/** @type {boolean} Whether a path is currently highlighted */
this._isActive = false;
/** @type {string|null} The node ID being traced */
this._tracedNodeId = null;
/** @type {Function|null} EventBus unsubscribe for path:highlight-requested */
this._unsubHighlight = null;
/** @type {Function|null} EventBus unsubscribe for path:cleared */
this._unsubCleared = null;
/** @type {Function|null} EventBus unsubscribe for model:loaded */
this._unsubModelLoaded = null;
this._bindEvents();
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Highlight the full path (upstream + downstream) through a node.
* @param {cytoscape.Core} cy - Cytoscape instance
* @param {string} nodeId - The node to trace paths through
*/
highlightPath(cy, nodeId) {
if (!cy || !nodeId) return;
const node = cy.getElementById(nodeId);
if (!node || node.length === 0) {
console.warn('[GraphPathHighlighter] Node not found:', nodeId);
return;
}
// Find upstream and downstream paths
const upstreamIds = this._findUpstream(cy, nodeId);
const downstreamIds = this._findDownstream(cy, nodeId);
console.log('[GraphPathHighlighter] upstream:', upstreamIds.size, 'downstream:', downstreamIds.size, 'total nodes:', cy.nodes().length);
// Combine all path node IDs (include the selected node itself)
const pathNodeIds = new Set([...upstreamIds, nodeId, ...downstreamIds]);
// If no path found at all (isolated node with no connections)
if (pathNodeIds.size === 1 && upstreamIds.size === 0 && downstreamIds.size === 0) {
this._showMessage('KhΓ΄ng tΓ¬m thαΊ₯y đường Δ‘i nΓ o qua node nΓ y.');
return;
}
// Collect edges on the path
const pathEdgeIds = this._findPathEdges(cy, pathNodeIds);
// Set traced node BEFORE applying highlight so _applyHighlight can use it
this._isActive = true;
this._tracedNodeId = nodeId;
// Apply highlight classes
this._applyHighlight(cy, pathNodeIds, pathEdgeIds);
// Emit event
if (typeof EventBus !== 'undefined') {
EventBus.emit('path:highlighted', {
nodeId: nodeId,
upstreamCount: upstreamIds.size,
downstreamCount: downstreamIds.size
});
}
}
/**
* Clear all path highlighting and restore normal graph appearance.
* @param {cytoscape.Core} cy - Cytoscape instance
*/
clearPath(cy) {
if (!cy) return;
cy.startBatch();
cy.elements().removeClass('path-highlighted path-dimmed path-source');
cy.elements().removeStyle('opacity');
cy.endBatch();
this._isActive = false;
this._tracedNodeId = null;
if (typeof EventBus !== 'undefined') {
EventBus.emit('path:cleared', {});
}
}
/**
* Check if path highlighting is currently active.
* @returns {boolean}
*/
isActive() {
return this._isActive;
}
/**
* Get the currently traced node ID.
* @returns {string|null}
*/
getTracedNodeId() {
return this._tracedNodeId;
}
/**
* Destroy and clean up all resources.
*/
destroy() {
if (this._unsubHighlight) {
this._unsubHighlight();
this._unsubHighlight = null;
}
if (this._unsubCleared) {
this._unsubCleared();
this._unsubCleared = null;
}
if (this._unsubModelLoaded) {
this._unsubModelLoaded();
this._unsubModelLoaded = null;
}
this._isActive = false;
this._tracedNodeId = null;
}
// ─── Path Finding ──────────────────────────────────────────────────────────
/**
* Find all upstream (ancestor) nodes via BFS backwards from the given node.
* Traverses incoming edges to find all predecessors up to input nodes.
* @param {cytoscape.Core} cy
* @param {string} nodeId
* @returns {Set<string>} Set of upstream node IDs
* @private
*/
_findUpstream(cy, nodeId) {
const visited = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const currentId = queue.shift();
const current = cy.getElementById(currentId);
if (!current || current.length === 0) continue;
// Get all incoming edges (edges where this node is the target)
const incomers = current.incomers('node');
incomers.forEach(function (predecessor) {
const predId = predecessor.id();
if (!visited.has(predId)) {
visited.add(predId);
queue.push(predId);
}
});
}
return visited;
}
/**
* Find all downstream (descendant) nodes via BFS forward from the given node.
* Traverses outgoing edges to find all successors down to output nodes.
* @param {cytoscape.Core} cy
* @param {string} nodeId
* @returns {Set<string>} Set of downstream node IDs
* @private
*/
_findDownstream(cy, nodeId) {
const visited = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const currentId = queue.shift();
const current = cy.getElementById(currentId);
if (!current || current.length === 0) continue;
// Get all outgoing edges (edges where this node is the source)
const outgoers = current.outgoers('node');
outgoers.forEach(function (successor) {
const sucId = successor.id();
if (!visited.has(sucId)) {
visited.add(sucId);
queue.push(sucId);
}
});
}
return visited;
}
/**
* Find all edges that connect nodes within the path set.
* @param {cytoscape.Core} cy
* @param {Set<string>} pathNodeIds
* @returns {Set<string>} Set of edge IDs on the path
* @private
*/
_findPathEdges(cy, pathNodeIds) {
const pathEdgeIds = new Set();
cy.edges().forEach(function (edge) {
var srcId = edge.source().id();
var tgtId = edge.target().id();
if (pathNodeIds.has(srcId) && pathNodeIds.has(tgtId)) {
pathEdgeIds.add(edge.id());
}
});
return pathEdgeIds;
}
// ─── Highlight Application ─────────────────────────────────────────────────
/**
* Apply visual highlight to path elements and dim non-path elements.
* @param {cytoscape.Core} cy
* @param {Set<string>} pathNodeIds
* @param {Set<string>} pathEdgeIds
* @private
*/
_applyHighlight(cy, pathNodeIds, pathEdgeIds) {
// First clear any existing path highlight
cy.elements().removeClass('path-highlighted path-dimmed path-source');
cy.elements().removeStyle('opacity');
// Batch style changes for performance
cy.startBatch();
// Dim ALL elements first using inline style (more reliable than class)
cy.elements().style('opacity', 0.15);
// Un-dim and highlight path nodes
pathNodeIds.forEach(function (id) {
var el = cy.getElementById(id);
if (el && el.length > 0) {
el.style('opacity', 1);
el.addClass('path-highlighted');
}
});
// Un-dim and highlight path edges
pathEdgeIds.forEach(function (id) {
var el = cy.getElementById(id);
if (el && el.length > 0) {
el.style('opacity', 1);
el.addClass('path-highlighted');
}
});
// Mark the source node distinctly
var sourceId = this._tracedNodeId;
if (sourceId) {
var sourceNode = cy.getElementById(sourceId);
if (sourceNode && sourceNode.length > 0) {
sourceNode.addClass('path-source');
}
}
cy.endBatch();
}
// ─── Event Handling ────────────────────────────────────────────────────────
/**
* Bind EventBus listeners for path highlight requests and clearing.
* @private
*/
_bindEvents() {
if (typeof EventBus === 'undefined' || typeof CONFIG === 'undefined') return;
// Listen for path highlight requests (Shift+click or Trace Path button)
this._unsubHighlight = EventBus.on('path:highlight-requested', (data) => {
console.log('[GraphPathHighlighter] path:highlight-requested received:', data);
if (!data || !data.nodeId) return;
var cy = this._getCytoscapeInstance();
if (!cy) {
console.warn('[GraphPathHighlighter] No Cytoscape instance available');
return;
}
console.log('[GraphPathHighlighter] Highlighting path for node:', data.nodeId);
this.highlightPath(cy, data.nodeId);
if (cy) {
this.highlightPath(cy, data.nodeId);
}
});
// Listen for path clear requests
this._unsubCleared = EventBus.on('path:clear-requested', () => {
var cy = this._getCytoscapeInstance();
if (cy) {
this.clearPath(cy);
}
});
// Clear path when a new model is loaded
this._unsubModelLoaded = EventBus.on(CONFIG.EVENTS.MODEL_LOADED, () => {
var cy = this._getCytoscapeInstance();
if (cy) {
this.clearPath(cy);
}
});
}
/**
* Attach Shift+click and background-click handlers to a Cytoscape instance.
* Call this after the graph is initialized/rendered.
* @param {cytoscape.Core} cy
*/
attachCytoscapeHandlers(cy) {
if (!cy) return;
// Remove previous handlers to avoid duplicates
cy.off('tap.pathHighlighter');
// Shift+click on a node β†’ trace path
cy.on('tap.pathHighlighter', 'node', (evt) => {
if (evt.originalEvent && evt.originalEvent.shiftKey) {
var nodeId = evt.target.id();
this.highlightPath(cy, nodeId);
}
});
// Click on background β†’ clear path
cy.on('tap.pathHighlighter', (evt) => {
if (evt.target === cy && this._isActive) {
this.clearPath(cy);
}
});
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
* Get the Cytoscape instance from the global app interface.
* @returns {cytoscape.Core|null}
* @private
*/
_getCytoscapeInstance() {
if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') {
var gv = window._onnxApp.getGraphVisualizer();
if (gv && gv._cy) {
return gv._cy;
}
}
return null;
}
/**
* Show a temporary message to the user (e.g., "no path found").
* @param {string} message
* @private
*/
_showMessage(message) {
// Use EventBus error display if available
if (typeof EventBus !== 'undefined' && typeof CONFIG !== 'undefined') {
EventBus.emit(CONFIG.EVENTS.ERROR_OCCURRED, {
message: message,
type: 'info'
});
return;
}
console.info('[GraphPathHighlighter]', message);
}
}
// Export as global for browser usage
window.GraphPathHighlighter = GraphPathHighlighter;