import { drawWorkflowMinimap } from "../../../components/sidebar/utils/minimap.js";
import { buildFloatingViewerMediaElement } from "../floatingViewerMedia.js";
import {
findWorkflowNode,
ensureWorkflowObjectInfo,
getNodeDisplayName,
getNodeParamEntries,
getNodeType,
getNodeTypeLabel,
getWorkflowNodes,
resolveAssetWorkflow,
} from "./workflowGraphMapData.js";
import {
copyNodeParamValue,
copyNodeJson,
importNodeToCurrentGraph,
importWorkflowToCanvas,
transferNodeParamsToSelectedCanvasNode,
} from "./workflowGraphMapActions.js";
export class WorkflowGraphMapPanel {
[key: string]: any;
constructor({ large = false } = {}) {
this._asset = null;
this._workflow = null;
this._selectedNodeId = "";
this._renderInfo = null;
this._resizeObserver = null;
this._resizeObservedTarget = null;
this._resizeObserverWindow = null;
this._large = Boolean(large);
this._view = { zoom: 1, centerX: null, centerY: null };
this._drag = null;
this._previewMedia = null;
this._previewKey = "";
this._el = this._build();
}
get el() {
return this._el;
}
setAsset(asset: any) {
if (this._asset === asset) return;
this._asset = asset || null;
this._workflow = resolveAssetWorkflow(this._asset);
this._selectedNodeId = "";
this._view = { zoom: 1, centerX: null, centerY: null };
this.refresh();
ensureWorkflowObjectInfo(this._workflow).then(() => this.refresh()).catch(() => {});
}
refresh() {
this._ensureResizeObserver();
if (!this._el?.isConnected) return;
this._renderCanvas();
this._renderDetails();
}
dispose() {
this._disposePreviewMedia();
this._resizeObserver?.disconnect?.();
this._resizeObserver = null;
this._el?.remove?.();
}
_build() {
const root = document.createElement("div");
root.className = "mjr-wgm";
if (this._large) root.className += " mjr-wgm--large";
const mapWrap = document.createElement("div");
mapWrap.className = "mjr-wgm-map-wrap";
this._mapWrap = mapWrap;
if (this._large) {
this._canvas = document.createElement("canvas");
this._canvas.className = "mjr-wgm-canvas";
this._canvas.addEventListener?.("click", (event: any) => this._handleCanvasClick(event));
this._canvas.addEventListener?.("wheel", (event: any) => this._handleWheel(event), {
passive: false,
});
this._canvas.addEventListener?.("dblclick", (event: any) => this._handleCanvasDblClick(event));
this._canvas.addEventListener?.("pointerdown", (event: any) => this._handlePointerDown(event));
mapWrap.appendChild(this._canvas);
} else {
this._preview = document.createElement("div");
this._preview.className = "mjr-wgm-preview";
mapWrap.appendChild(this._preview);
}
root.appendChild(mapWrap);
this._status = document.createElement("div");
this._status.className = "mjr-wgm-status";
root.appendChild(this._status);
this._details = document.createElement("div");
this._details.className = "mjr-wgm-details";
root.appendChild(this._details);
this._ensureResizeObserver();
return root;
}
_ensureResizeObserver() {
const target = this._mapWrap;
if (!target) return;
const win = _getElementWindow(target);
const ResizeObserverCtor = win?.ResizeObserver || globalThis.ResizeObserver;
if (typeof ResizeObserverCtor !== "function") return;
if (
this._resizeObserver &&
this._resizeObservedTarget === target &&
this._resizeObserverWindow === win
) {
return;
}
try {
this._resizeObserver?.disconnect?.();
} catch (e: any) {
console.debug?.(e);
}
try {
this._resizeObserver = new ResizeObserverCtor(() => this.refresh());
this._resizeObserver.observe(target);
this._resizeObservedTarget = target;
this._resizeObserverWindow = win;
} catch (e: any) {
console.debug?.(e);
this._resizeObserver = null;
this._resizeObservedTarget = null;
this._resizeObserverWindow = null;
}
}
_renderCanvas() {
if (!this._large) {
this._renderPreview();
return;
}
const canvas = this._canvas;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));
const win = _getElementWindow(canvas);
const dpr = Math.max(1, Math.min(2, Number(win?.devicePixelRatio) || 1));
const nextW = Math.floor(width * dpr);
const nextH = Math.floor(height * dpr);
if (canvas.width !== nextW || canvas.height !== nextH) {
canvas.width = nextW;
canvas.height = nextH;
}
const ctx = canvas.getContext?.("2d");
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!this._workflow) {
ctx?.clearRect?.(0, 0, width, height);
this._renderInfo = null;
return;
}
this._renderInfo = drawWorkflowMinimap(canvas, this._workflow, {
showNodeLabels: true,
showViewport: false,
view: {
hoveredNodeId: this._selectedNodeId || null,
zoom: this._view.zoom,
centerX: this._view.centerX,
centerY: this._view.centerY,
},
});
if (this._renderInfo?.resolvedView) {
this._view.centerX = this._renderInfo.resolvedView.centerX;
this._view.centerY = this._renderInfo.resolvedView.centerY;
this._view.zoom = this._renderInfo.resolvedView.zoom;
}
}
_renderDetails() {
const nodeCount = getWorkflowNodes(this._workflow).length;
if (!this._workflow) {
this._status.textContent = this._large
? "No workflow graph in selected image"
: "Selected asset - no workflow graph";
_replaceChildren(this._details);
return;
}
this._status.textContent = this._large
? this._selectedNodeId
? `${nodeCount} nodes - selected #${this._selectedNodeId}`
: `${nodeCount} nodes - select a node`
: `${nodeCount} nodes - graph opened in viewer`;
const node = findWorkflowNode(this._workflow, this._selectedNodeId);
if (!node) {
const empty = document.createElement("div");
empty.className = "mjr-ws-sidebar-empty";
empty.textContent = this._large
? "Click a node in the graph map"
: "Use the large Graph Map in the viewer to select nodes";
_replaceChildren(this._details, empty);
return;
}
const title = document.createElement("div");
title.className = "mjr-wgm-node-title";
title.textContent = getNodeDisplayName(node);
const meta = document.createElement("div");
meta.className = "mjr-wgm-node-meta";
meta.textContent = `#${this._selectedNodeId} ${getNodeTypeLabel(node) || getNodeType(node) || "Node"}`;
const actions = document.createElement("div");
actions.className = "mjr-wgm-actions";
actions.appendChild(this._makeAction("Copy node", "pi pi-copy", () => copyNodeJson(node)));
actions.appendChild(
this._makeAction("Import node", "pi pi-plus-circle", () => importNodeToCurrentGraph(node)),
);
actions.appendChild(
this._makeAction("Import workflow", "pi pi-download", () =>
importWorkflowToCanvas(this._workflow),
),
);
actions.appendChild(
this._makeAction("Transfer params to selected canvas node", "pi pi-arrow-right-arrow-left", () => {
const result = transferNodeParamsToSelectedCanvasNode(node);
return result?.ok;
}),
);
const params = document.createElement("div");
params.className = "mjr-wgm-params";
for (const [key, value] of getNodeParamEntries(node)) {
const row = document.createElement("div");
row.className = "mjr-wgm-param";
row.tabIndex = 0;
row.role = "button";
row.title = `Copy ${String(key)}`;
const k = document.createElement("span");
k.className = "mjr-wgm-param-key";
k.textContent = String(key);
const v = document.createElement("span");
v.className = "mjr-wgm-param-value";
v.textContent = _formatValue(value);
row.appendChild(k);
row.appendChild(v);
row.addEventListener("click", () => this._copyParam(row, value));
row.addEventListener("keydown", (event: any) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault?.();
this._copyParam(row, value);
});
params.appendChild(row);
}
if (!params.childElementCount) {
const empty = document.createElement("div");
empty.className = "mjr-ws-node-empty";
empty.textContent = "No simple parameters found";
params.appendChild(empty);
}
_replaceChildren(this._details, title, meta, actions, params);
}
_makeAction(label: any, iconClass: any, action: any) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mjr-wgm-action";
btn.title = label;
btn.innerHTML = `${label}`;
btn.addEventListener("click", async () => {
try {
const ok = await action();
btn.classList.toggle("is-ok", !!ok);
_getElementWindow(btn).setTimeout(() => btn.classList.remove("is-ok"), 700);
} catch (e: any) {
console.debug?.("[MFV Graph Map] action failed", e);
}
});
return btn;
}
async _copyParam(row: any, value: any) {
try {
const ok = await copyNodeParamValue(value);
row.classList.toggle("is-ok", !!ok);
row.classList.toggle("is-error", !ok);
_getElementWindow(row).setTimeout(() => {
row.classList.remove("is-ok");
row.classList.remove("is-error");
}, 750);
} catch (e: any) {
row.classList.add("is-error");
_getElementWindow(row).setTimeout(() => row.classList.remove("is-error"), 750);
console.debug?.("[MFV Graph Map] param copy failed", e);
}
}
_renderPreview() {
if (!this._preview) return;
const asset = _normalizePreviewAsset(this._asset);
const previewKey = _getPreviewKey(asset);
if (this._previewMedia && previewKey && previewKey === this._previewKey) {
if (this._preview.firstChild !== this._previewMedia || this._preview.childNodes.length !== 1) {
_replaceChildren(this._preview, this._previewMedia);
}
return;
}
this._disposePreviewMedia();
const media = buildFloatingViewerMediaElement(asset, { fill: true });
if (media) {
media.classList?.add?.("mjr-wgm-preview-media");
this._previewMedia = media;
this._previewKey = previewKey;
this._preview.appendChild(media);
return;
}
const empty = document.createElement("div");
empty.className = "mjr-wgm-preview-empty";
empty.textContent = "No preview";
_replaceChildren(this._preview, empty);
}
_disposePreviewMedia() {
const media = this._previewMedia;
this._previewMedia = null;
this._previewKey = "";
if (!media) return;
try {
media._mjrMediaControlsHandle?.destroy?.();
} catch (e: any) {
console.debug?.("[MFV Graph Map] preview cleanup failed", e);
}
try {
const playable = media.querySelectorAll?.("video, audio") || [];
for (const el of playable) el.pause?.();
} catch (e: any) {
console.debug?.("[MFV Graph Map] preview pause failed", e);
}
media.remove?.();
}
_handleCanvasClick(event: any) {
if (this._drag?.moved) return;
const rect = this._canvas.getBoundingClientRect();
const hit = this._renderInfo?.hitTestNode?.(event.clientX - rect.left, event.clientY - rect.top);
if (!hit?.id) return;
this._selectedNodeId = String(hit.id);
this.refresh();
}
_handleWheel(event: any) {
if (!this._workflow) return;
event.preventDefault?.();
const direction = Number(event.deltaY) > 0 ? -1 : 1;
const factor = direction > 0 ? 1.18 : 1 / 1.18;
const oldZoom = Number(this._view.zoom || 1);
const newZoom = Math.max(1, Math.min(8, oldZoom * factor));
if (newZoom === oldZoom) return;
const view = this._renderInfo?.resolvedView;
if (view?.renderScale && view?.viewMinX != null && view?.viewMinY != null) {
const rect = this._canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const { renderScale, viewMinX, viewMinY, visibleW, visibleH, pad } = view;
// World point under cursor before zoom change
const worldX = viewMinX + (mouseX - pad) / renderScale;
const worldY = viewMinY + (mouseY - pad) / renderScale;
// renderScale scales linearly with zoom; adjust center to keep world point under cursor
const newRenderScale = renderScale * (newZoom / oldZoom);
const newVisibleW = visibleW * (oldZoom / newZoom);
const newVisibleH = visibleH * (oldZoom / newZoom);
const newViewMinX = worldX - (mouseX - pad) / newRenderScale;
const newViewMinY = worldY - (mouseY - pad) / newRenderScale;
this._view.zoom = newZoom;
this._view.centerX = newViewMinX + newVisibleW / 2;
this._view.centerY = newViewMinY + newVisibleH / 2;
} else {
this._view.zoom = newZoom;
}
this.refresh();
}
_handleCanvasDblClick(event: any) {
if (!this._workflow) return;
const rect = this._canvas.getBoundingClientRect();
const hit = this._renderInfo?.hitTestNode?.(event.clientX - rect.left, event.clientY - rect.top);
if (hit?.x != null && hit?.w != null) {
// Zoom to fit the clicked node (or subgraph container) with context margin
this._zoomToWorldRect(hit.x, hit.y, hit.w, hit.h);
} else {
// Double-click on background: reset to fit-all
this._view = { zoom: 1, centerX: null, centerY: null };
this.refresh();
}
}
_zoomToWorldRect(x: any, y: any, w: any, h: any) {
const info = this._renderInfo;
if (!info?.bounds) return;
const { bounds } = info;
const boundsW = Math.max(1, bounds.maxX - bounds.minX);
const boundsH = Math.max(1, bounds.maxY - bounds.minY);
// Show the target rect at ~half the canvas (margin 2.0 = target fills 50%)
const margin = 2.0;
const zoomW = boundsW / Math.max(1, w * margin);
const zoomH = boundsH / Math.max(1, h * margin);
this._view.zoom = Math.max(1, Math.min(8, Math.min(zoomW, zoomH)));
this._view.centerX = x + w / 2;
this._view.centerY = y + h / 2;
this.refresh();
}
_handlePointerDown(event: any) {
if (!this._workflow || event.button !== 0) return;
const view = this._renderInfo?.resolvedView;
if (!view?.renderScale) return;
event.preventDefault?.();
this._canvas.setPointerCapture?.(event.pointerId);
this._drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
centerX: Number(this._view.centerX ?? view.centerX),
centerY: Number(this._view.centerY ?? view.centerY),
scale: Number(view.renderScale) || 1,
moved: false,
};
const onMove = (moveEvent: any) => {
if (!this._drag || moveEvent.pointerId !== this._drag.pointerId) return;
const dx = moveEvent.clientX - this._drag.startX;
const dy = moveEvent.clientY - this._drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) this._drag.moved = true;
this._view.centerX = this._drag.centerX - dx / this._drag.scale;
this._view.centerY = this._drag.centerY - dy / this._drag.scale;
this._renderCanvas();
this._renderDetails();
};
const onUp = (upEvent: any) => {
if (!this._drag || upEvent.pointerId !== this._drag.pointerId) return;
this._canvas.releasePointerCapture?.(upEvent.pointerId);
const win = _getElementWindow(this._canvas);
win.removeEventListener("pointermove", onMove);
win.removeEventListener("pointerup", onUp);
win.removeEventListener("pointercancel", onUp);
win.setTimeout(() => {
this._drag = null;
}, 0);
};
const win = _getElementWindow(this._canvas);
win.addEventListener("pointermove", onMove);
win.addEventListener("pointerup", onUp);
win.addEventListener("pointercancel", onUp);
}
}
function _getElementWindow(el: any) {
return el?.ownerDocument?.defaultView || window;
}
function _replaceChildren(parent: any, ...children: any[]) {
if (!parent) return;
while (parent.firstChild) parent.removeChild(parent.firstChild);
for (const child of children) parent.appendChild(child);
}
function _formatValue(value: any) {
if (value == null) return "";
if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function _normalizePreviewAsset(asset: any) {
if (!asset || typeof asset !== "object") return asset;
const candidates = Array.isArray(asset.previewCandidates) ? asset.previewCandidates : [];
const url = candidates.find((value: any) => String(value || "").trim()) || asset.url || "";
const type = String(asset.type || "").toLowerCase();
const kind = String(asset.kind || "").toLowerCase();
const filename = String(asset.filename || asset.name || "");
const inferredKind =
kind ||
(asset.isVideo || type === "video" || /\.(mp4|mov|webm|mkv|avi)$/i.test(filename)
? "video"
: asset.isAudio || type === "audio" || /\.(mp3|wav|flac|ogg|m4a|aac|opus|wma)$/i.test(filename)
? "audio"
: asset.isModel3d || type === "model3d"
? "model3d"
: "");
return {
...asset,
...(url ? { url } : null),
...(inferredKind ? { kind: inferredKind, asset_type: inferredKind } : null),
};
}
function _getPreviewKey(asset: any) {
if (!asset || typeof asset !== "object") return "";
return JSON.stringify({
url: String(asset.url || ""),
filename: String(asset.filename || asset.name || ""),
kind: String(asset.kind || asset.asset_type || asset.type || ""),
subfolder: String(asset.subfolder || ""),
id: asset.id ?? "",
});
}