model-explorer / js /ui /graphMinimap.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* GraphMinimap - BαΊ£n Δ‘α»“ thu nhỏ toΓ n bα»™ Δ‘α»“ thα»‹
* Hiển thα»‹ minimap ở gΓ³c mΓ n hΓ¬nh, cho phΓ©p click/drag để Δ‘iều hΖ°α»›ng Δ‘α»“ thα»‹ chΓ­nh.
* Tα»± Δ‘α»™ng αΊ©n khi sα»‘ node < 20. Cung cαΊ₯p nΓΊt toggle αΊ©n/hiện.
* Requirements: 17.1, 17.2, 17.3, 17.4, 17.5
*/
class GraphMinimap {
/**
* @param {string} graphContainerId - ID of the main graph container element
*/
constructor(graphContainerId) {
/** @type {string} */
this._graphContainerId = graphContainerId;
/** @type {HTMLElement|null} Minimap overlay wrapper */
this._wrapper = null;
/** @type {HTMLElement|null} Canvas for minimap rendering */
this._canvas = null;
/** @type {HTMLElement|null} Viewport indicator rectangle */
this._viewport = null;
/** @type {HTMLElement|null} Toggle button */
this._toggleBtn = null;
/** @type {boolean} User preference: minimap visible */
this._userVisible = true;
/** @type {boolean} Auto-hidden because node count < threshold */
this._autoHidden = false;
/** @type {number} Threshold below which minimap auto-hides */
this._autoHideThreshold = 20;
/** @type {boolean} Whether user is dragging on the minimap */
this._isDragging = false;
/** @type {Function|null} Bound handler refs for cleanup */
this._onMouseMoveBound = null;
this._onMouseUpBound = null;
/** @type {Function|null} EventBus unsubscribe for graph:rendered */
this._unsubGraphRendered = null;
/** @type {Function|null} EventBus unsubscribe for model:loaded */
this._unsubModelLoaded = null;
/** @type {number|null} requestAnimationFrame id */
this._rafId = null;
this._createDOM();
this._bindEvents();
}
// ─── DOM Creation ──────────────────────────────────────────────────────────
/**
* Create the minimap overlay, canvas, viewport indicator, and toggle button.
* @private
*/
_createDOM() {
// Wrapper – positioned in bottom-right corner of the graph container
const wrapper = document.createElement('div');
wrapper.className = 'graph-minimap-wrapper';
wrapper.setAttribute('role', 'navigation');
wrapper.setAttribute('aria-label', 'Graph minimap');
wrapper.style.cssText = [
'position: absolute',
'bottom: 12px',
'right: 12px',
'width: 180px',
'height: 130px',
'background: rgba(255,255,255,0.95)',
'border: 1px solid #ccc',
'border-radius: 6px',
'box-shadow: 0 2px 8px rgba(0,0,0,0.15)',
'overflow: hidden',
'z-index: 100',
'display: none',
'cursor: crosshair',
'user-select: none'
].join(';');
this._wrapper = wrapper;
// Canvas for drawing the minimap graph
const canvas = document.createElement('canvas');
canvas.className = 'graph-minimap-canvas';
canvas.width = 180;
canvas.height = 130;
canvas.style.cssText = 'width:100%;height:100%;display:block;';
wrapper.appendChild(canvas);
this._canvas = canvas;
// Viewport indicator (semi-transparent rectangle showing visible area)
const viewport = document.createElement('div');
viewport.className = 'graph-minimap-viewport';
viewport.style.cssText = [
'position: absolute',
'border: 2px solid #0d6efd',
'background: rgba(13,110,253,0.1)',
'pointer-events: none',
'transition: none'
].join(';');
wrapper.appendChild(viewport);
this._viewport = viewport;
// Toggle button – always visible near the minimap position
const toggleBtn = document.createElement('button');
toggleBtn.className = 'graph-minimap-toggle btn btn-sm btn-outline-secondary';
toggleBtn.setAttribute('aria-label', 'Toggle minimap');
toggleBtn.setAttribute('title', 'Toggle minimap');
toggleBtn.innerHTML = '<i class="fas fa-map"></i>';
toggleBtn.style.cssText = [
'position: absolute',
'bottom: 12px',
'right: 12px',
'z-index: 101',
'display: none',
'opacity: 0.8'
].join(';');
this._toggleBtn = toggleBtn;
// Append to graph container's parent (so they overlay the graph)
const graphContainer = document.getElementById(this._graphContainerId);
if (graphContainer) {
// Ensure parent is positioned for absolute children
const parent = graphContainer.parentElement;
if (parent) {
const pos = window.getComputedStyle(parent).position;
if (pos === 'static') {
parent.style.position = 'relative';
}
parent.appendChild(wrapper);
parent.appendChild(toggleBtn);
}
}
}
// ─── Event Binding ─────────────────────────────────────────────────────────
/**
* Bind all event listeners.
* @private
*/
_bindEvents() {
// Toggle button click
if (this._toggleBtn) {
this._toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
}
// Minimap click β†’ navigate main graph
if (this._wrapper) {
this._wrapper.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
this._isDragging = true;
this._navigateToPosition(e);
});
this._onMouseMoveBound = (e) => {
if (this._isDragging) {
e.preventDefault();
this._navigateToPosition(e);
}
};
this._onMouseUpBound = () => {
this._isDragging = false;
};
document.addEventListener('mousemove', this._onMouseMoveBound);
document.addEventListener('mouseup', this._onMouseUpBound);
}
// Listen for graph:rendered to update minimap
if (typeof EventBus !== 'undefined' && typeof CONFIG !== 'undefined') {
this._unsubGraphRendered = EventBus.on(CONFIG.EVENTS.GRAPH_RENDERED, () => {
this._onGraphUpdated();
});
this._unsubModelLoaded = EventBus.on(CONFIG.EVENTS.MODEL_LOADED, () => {
// Small delay to let graph render finish
setTimeout(() => this._onGraphUpdated(), 300);
});
}
}
// ─── Core Logic ────────────────────────────────────────────────────────────
/**
* Called when the main graph is updated/rendered.
* Determines visibility and redraws the minimap.
* @private
*/
_onGraphUpdated() {
const cy = this._getCytoscapeInstance();
if (!cy) {
this._hide();
return;
}
const nodeCount = cy.nodes().length;
// Auto-hide when fewer than threshold nodes
if (nodeCount < this._autoHideThreshold) {
this._autoHidden = true;
this._hide();
if (this._toggleBtn) {
this._toggleBtn.style.display = 'none';
}
return;
}
this._autoHidden = false;
// Show toggle button
if (this._toggleBtn) {
this._toggleBtn.style.display = 'block';
}
// Show minimap if user preference is visible
if (this._userVisible) {
this._show();
}
this._drawMinimap(cy);
this._updateViewport(cy);
this._listenToCytoscapeEvents(cy);
}
/**
* Draw the minimap representation of the graph onto the canvas.
* @param {cytoscape.Core} cy
* @private
*/
_drawMinimap(cy) {
if (!this._canvas) return;
const ctx = this._canvas.getContext('2d');
if (!ctx) return;
const cw = this._canvas.width;
const ch = this._canvas.height;
ctx.clearRect(0, 0, cw, ch);
// Get bounding box of all elements
const bb = cy.elements().boundingBox();
if (!bb || bb.w === 0 || bb.h === 0) return;
// Compute scale to fit graph into minimap with padding
const padding = 8;
const scaleX = (cw - padding * 2) / bb.w;
const scaleY = (ch - padding * 2) / bb.h;
const scale = Math.min(scaleX, scaleY);
const offsetX = padding + ((cw - padding * 2) - bb.w * scale) / 2;
const offsetY = padding + ((ch - padding * 2) - bb.h * scale) / 2;
// Draw edges
ctx.strokeStyle = 'rgba(150,150,150,0.4)';
ctx.lineWidth = 0.5;
cy.edges().forEach(function (edge) {
const src = edge.source().position();
const tgt = edge.target().position();
const x1 = (src.x - bb.x1) * scale + offsetX;
const y1 = (src.y - bb.y1) * scale + offsetY;
const x2 = (tgt.x - bb.x1) * scale + offsetX;
const y2 = (tgt.y - bb.y1) * scale + offsetY;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
});
// Draw nodes
cy.nodes().forEach(function (node) {
var pos = node.position();
var x = (pos.x - bb.x1) * scale + offsetX;
var y = (pos.y - bb.y1) * scale + offsetY;
var color = '#87CEEB'; // default
if (node.hasClass('input-node')) color = '#28a745';
else if (node.hasClass('output-node')) color = '#dc3545';
else if (node.hasClass('initializer-node')) color = '#6c757d';
else if (node.hasClass('highlighted')) color = '#FFD700';
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
});
// Store transform info for click-to-navigate
this._transform = { bb: bb, scale: scale, offsetX: offsetX, offsetY: offsetY };
}
/**
* Update the viewport indicator rectangle to reflect the main graph's visible area.
* @param {cytoscape.Core} cy
* @private
*/
_updateViewport(cy) {
if (!this._viewport || !this._canvas || !this._transform) return;
var t = this._transform;
var ext = cy.extent(); // visible area in model coordinates
var left = (ext.x1 - t.bb.x1) * t.scale + t.offsetX;
var top = (ext.y1 - t.bb.y1) * t.scale + t.offsetY;
var width = ext.w * t.scale;
var height = ext.h * t.scale;
// Clamp to canvas bounds
var cw = this._canvas.width;
var ch = this._canvas.height;
left = Math.max(0, Math.min(left, cw));
top = Math.max(0, Math.min(top, ch));
width = Math.min(width, cw - left);
height = Math.min(height, ch - top);
this._viewport.style.left = left + 'px';
this._viewport.style.top = top + 'px';
this._viewport.style.width = Math.max(width, 4) + 'px';
this._viewport.style.height = Math.max(height, 4) + 'px';
}
/**
* Listen to Cytoscape viewport events (zoom, pan) to keep minimap in sync.
* @param {cytoscape.Core} cy
* @private
*/
_listenToCytoscapeEvents(cy) {
// Remove previous listeners to avoid duplicates
cy.off('viewport.minimap');
cy.on('viewport.minimap', () => {
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
this._updateViewport(cy);
});
});
}
/**
* Navigate the main graph to the position clicked/dragged on the minimap.
* @param {MouseEvent} e
* @private
*/
_navigateToPosition(e) {
if (!this._wrapper || !this._transform) return;
var cy = this._getCytoscapeInstance();
if (!cy) return;
var rect = this._wrapper.getBoundingClientRect();
var clickX = e.clientX - rect.left;
var clickY = e.clientY - rect.top;
var t = this._transform;
// Convert minimap coordinates back to graph model coordinates
var modelX = (clickX - t.offsetX) / t.scale + t.bb.x1;
var modelY = (clickY - t.offsetY) / t.scale + t.bb.y1;
// Pan so the clicked model point is centered in the viewport
var zoom = cy.zoom();
cy.pan({
x: cy.width() / 2 - modelX * zoom,
y: cy.height() / 2 - modelY * zoom
});
// Update viewport indicator immediately
this._updateViewport(cy);
}
/**
* Get the Cytoscape instance from the GraphVisualizer.
* @returns {cytoscape.Core|null}
* @private
*/
_getCytoscapeInstance() {
// Try via the exposed _onnxApp debug interface
if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') {
var gv = window._onnxApp.getGraphVisualizer();
if (gv && gv._cy) {
return gv._cy;
}
}
return null;
}
// ─── Visibility ────────────────────────────────────────────────────────────
/**
* Show the minimap overlay.
* @private
*/
_show() {
if (this._wrapper) {
this._wrapper.style.display = 'block';
}
// Shift toggle button to the left of the minimap
if (this._toggleBtn) {
this._toggleBtn.style.right = '198px';
}
}
/**
* Hide the minimap overlay.
* @private
*/
_hide() {
if (this._wrapper) {
this._wrapper.style.display = 'none';
}
// Reset toggle button position
if (this._toggleBtn) {
this._toggleBtn.style.right = '12px';
}
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Toggle minimap visibility (user preference).
*/
toggle() {
if (this._autoHidden) return; // Can't toggle when auto-hidden
this._userVisible = !this._userVisible;
if (this._userVisible) {
this._show();
// Redraw in case graph changed while hidden
var cy = this._getCytoscapeInstance();
if (cy) {
this._drawMinimap(cy);
this._updateViewport(cy);
}
} else {
this._hide();
}
}
/**
* Show the minimap (programmatic).
*/
show() {
this._userVisible = true;
if (!this._autoHidden) {
this._show();
}
}
/**
* Hide the minimap (programmatic).
*/
hide() {
this._userVisible = false;
this._hide();
}
/**
* Force a redraw of the minimap. Call after external graph changes.
*/
refresh() {
this._onGraphUpdated();
}
/**
* Check if the minimap is currently visible.
* @returns {boolean}
*/
isVisible() {
return this._wrapper ? this._wrapper.style.display !== 'none' : false;
}
/**
* Destroy and clean up all resources.
*/
destroy() {
// Cancel pending animation frame
if (this._rafId) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
// Remove document-level listeners
if (this._onMouseMoveBound) {
document.removeEventListener('mousemove', this._onMouseMoveBound);
this._onMouseMoveBound = null;
}
if (this._onMouseUpBound) {
document.removeEventListener('mouseup', this._onMouseUpBound);
this._onMouseUpBound = null;
}
// Unsubscribe EventBus
if (this._unsubGraphRendered) {
this._unsubGraphRendered();
this._unsubGraphRendered = null;
}
if (this._unsubModelLoaded) {
this._unsubModelLoaded();
this._unsubModelLoaded = null;
}
// Remove Cytoscape viewport listener
var cy = this._getCytoscapeInstance();
if (cy) {
cy.off('viewport.minimap');
}
// Remove DOM elements
if (this._wrapper && this._wrapper.parentNode) {
this._wrapper.parentNode.removeChild(this._wrapper);
}
if (this._toggleBtn && this._toggleBtn.parentNode) {
this._toggleBtn.parentNode.removeChild(this._toggleBtn);
}
this._wrapper = null;
this._canvas = null;
this._viewport = null;
this._toggleBtn = null;
this._transform = null;
}
}
// Export as global for browser usage
window.GraphMinimap = GraphMinimap;