1974 lines
68 KiB
JavaScript
1974 lines
68 KiB
JavaScript
const state = {
|
|
nodes: [],
|
|
edges: [],
|
|
selectedId: null,
|
|
connectMode: false,
|
|
connectFrom: null,
|
|
pan: { x: 0, y: 0, scale: 1 },
|
|
clickTimer: null,
|
|
draggingNodeId: null,
|
|
draggingOffset: null,
|
|
draggingMoved: false,
|
|
draggingJustEnded: false,
|
|
meta: { imports: [] },
|
|
};
|
|
|
|
const nodeTypeOrder = [
|
|
"data",
|
|
"filter",
|
|
"transform",
|
|
"loader",
|
|
"image_tower",
|
|
"metadata_tower",
|
|
"bridge",
|
|
"classifier",
|
|
];
|
|
|
|
const typeMeta = {
|
|
data: { color: "#2f5d62", label: "Data" },
|
|
filter: { color: "#8d6b94", label: "Filter" },
|
|
transform: { color: "#f6c453", label: "Transform" },
|
|
loader: { color: "#c84630", label: "Loader" },
|
|
image_tower: { color: "#1f4e79", label: "Tower" },
|
|
metadata_tower: { color: "#6b4226", label: "Tower" },
|
|
bridge: { color: "#2a2a72", label: "Bridge" },
|
|
classifier: { color: "#5a3e2b", label: "Classifier" },
|
|
};
|
|
|
|
const svg = document.getElementById("graph");
|
|
const jsonView = document.getElementById("json-view");
|
|
const nodeCount = document.getElementById("node-count");
|
|
const edgeCount = document.getElementById("edge-count");
|
|
const selectedNode = document.getElementById("selected-node");
|
|
const nodeLabelInput = document.getElementById("node-label");
|
|
const nodeSlotInput = document.getElementById("node-slot");
|
|
const nodeOutputInput = document.getElementById("node-output");
|
|
const selectionGeneric = document.getElementById("selection-generic");
|
|
const selectionLoader = document.getElementById("selection-loader");
|
|
const selectionFilter = document.getElementById("selection-filter");
|
|
const selectionTransform = document.getElementById("selection-transform");
|
|
const selectionTower = document.getElementById("selection-tower");
|
|
const selectionBridge = document.getElementById("selection-bridge");
|
|
const selectionClassifier = document.getElementById("selection-classifier");
|
|
const selectionData = document.getElementById("selection-data");
|
|
const loaderInputType = document.getElementById("loader-input-type");
|
|
const loaderInputIndex = document.getElementById("loader-input-index");
|
|
const loaderOutputType = document.getElementById("loader-output-type");
|
|
const dataOutputType = document.getElementById("data-output-type");
|
|
const filterInputType = document.getElementById("filter-input-type");
|
|
const filterOutputType = document.getElementById("filter-output-type");
|
|
const filterType = document.getElementById("filter-type");
|
|
const filterRegexFields = document.getElementById("filter-regex-fields");
|
|
const filterRegexPattern = document.getElementById("filter-regex-pattern");
|
|
const filterColumnFields = document.getElementById("filter-column-fields");
|
|
const filterColumnName = document.getElementById("filter-column-name");
|
|
const filterColumnOperator = document.getElementById("filter-column-operator");
|
|
const filterColumnValue = document.getElementById("filter-column-value");
|
|
const transformType = document.getElementById("transform-type");
|
|
const transformRoiFields = document.getElementById("transform-roi-fields");
|
|
const transformRoiMaskSource = document.getElementById("transform-roi-mask-source");
|
|
const transformRoiScale = document.getElementById("transform-roi-scale");
|
|
const transformRoiTarget = document.getElementById("transform-roi-target");
|
|
const transformRoiFallback = document.getElementById("transform-roi-fallback");
|
|
const transformCenterFields = document.getElementById("transform-center-fields");
|
|
const transformCenterSize = document.getElementById("transform-center-size");
|
|
const transformJitterFields = document.getElementById("transform-jitter-fields");
|
|
const transformJitterHFlip = document.getElementById("transform-jitter-hflip");
|
|
const transformJitterVFlip = document.getElementById("transform-jitter-vflip");
|
|
const transformJitterRotation = document.getElementById("transform-jitter-rotation");
|
|
const transformJitterColorEnabled = document.getElementById("transform-jitter-color-enabled");
|
|
const transformJitterColor = document.getElementById("transform-jitter-color");
|
|
const transformResizeFields = document.getElementById("transform-resize-fields");
|
|
const transformResizeSize = document.getElementById("transform-resize-size");
|
|
const towerType = document.getElementById("tower-type");
|
|
const towerImageFields = document.getElementById("tower-image-fields");
|
|
const towerBackbone = document.getElementById("tower-backbone");
|
|
const towerFreezeRatio = document.getElementById("tower-freeze-ratio");
|
|
const towerAugment = document.getElementById("tower-augment");
|
|
const towerGeometryDim = document.getElementById("tower-geometry-dim");
|
|
const towerUseSe = document.getElementById("tower-use-se");
|
|
const towerSeReduction = document.getElementById("tower-se-reduction");
|
|
const towerSePreNorm = document.getElementById("tower-se-pre-norm");
|
|
const towerMdFields = document.getElementById("tower-md-fields");
|
|
const towerMdHidden = document.getElementById("tower-md-hidden");
|
|
const towerMdDropout = document.getElementById("tower-md-dropout");
|
|
const towerMdUseSe = document.getElementById("tower-md-use-se");
|
|
const towerMdSeReduction = document.getElementById("tower-md-se-reduction");
|
|
const towerMdSePreNorm = document.getElementById("tower-md-se-pre-norm");
|
|
const towerMdFreezeRatio = document.getElementById("tower-md-freeze-ratio");
|
|
const bridgeMethod = document.getElementById("bridge-method");
|
|
const bridgeFusionFields = document.getElementById("bridge-fusion-fields");
|
|
const bridgeFusionDim = document.getElementById("bridge-fusion-dim");
|
|
const bridgeUseSe = document.getElementById("bridge-use-se");
|
|
const bridgeSeReduction = document.getElementById("bridge-se-reduction");
|
|
const bridgeSePreNorm = document.getElementById("bridge-se-pre-norm");
|
|
const dataBrowseButton = document.getElementById("data-browse");
|
|
const dataSourceStatus = document.getElementById("data-source-status");
|
|
const duplicateDataButton = document.getElementById("duplicate-data");
|
|
const modalOverlay = document.getElementById("modal-overlay");
|
|
const modalList = document.getElementById("modal-list");
|
|
const modalClose = document.getElementById("modal-close");
|
|
const modalSelectDir = document.getElementById("modal-select-dir");
|
|
const modalSelectFile = document.getElementById("modal-select-file");
|
|
const modalMode = document.getElementById("modal-mode");
|
|
const breadcrumb = document.getElementById("breadcrumb");
|
|
const previewOverlay = document.getElementById("preview-overlay");
|
|
const previewClose = document.getElementById("preview-close");
|
|
const previewBody = document.getElementById("preview-body");
|
|
const previewSubtitle = document.getElementById("preview-subtitle");
|
|
const importClassSelect = document.getElementById("import-class-select");
|
|
const importClassBtn = document.getElementById("import-class-btn");
|
|
const presetSelect = document.getElementById("preset-select");
|
|
const presetApplyBtn = document.getElementById("preset-apply");
|
|
const presetSaveBtn = document.getElementById("preset-save");
|
|
const connectButton = document.getElementById("connect-mode");
|
|
const toggleJsonButton = document.getElementById("toggle-json");
|
|
|
|
const PRESET_STORAGE_KEY = "hypertower_v2_presets";
|
|
const selectionState = { nodeIds: new Set(), edgeIndices: new Set() };
|
|
let selectionBox = null;
|
|
|
|
function newNodeId() {
|
|
return `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
}
|
|
|
|
function createNodeTemplate(type) {
|
|
const node = {
|
|
id: newNodeId(),
|
|
type,
|
|
label: `${typeMeta[type]?.label || type}`,
|
|
inputKey: "",
|
|
outputKey: "",
|
|
inputType: "image",
|
|
inputIndex: "1",
|
|
outputType: type === "data" ? "" : "image",
|
|
source: null,
|
|
selectedPath: null,
|
|
filterType: type === "filter" ? "regex" : "",
|
|
regexPattern: "",
|
|
columnName: "",
|
|
columnOperator: ">",
|
|
columnValue: "",
|
|
transformType: type === "transform" ? "center_crop" : "",
|
|
roiMaskSource: "gt",
|
|
roiScale: 2.5,
|
|
roiTargetSize: 224,
|
|
roiFallback: true,
|
|
centerCropSize: 224,
|
|
jitterHFlip: true,
|
|
jitterVFlip: true,
|
|
jitterRotation: 15,
|
|
jitterColorEnabled: true,
|
|
jitterColor: "0.1,0.1,0.1,0.05",
|
|
resizeSize: 256,
|
|
};
|
|
if (type === "image_tower" || type === "metadata_tower") {
|
|
node.towerType = type === "metadata_tower" ? "metadata" : "image";
|
|
node.imageBackbone = "efficientnet_b0";
|
|
node.imageFreezeRatio = 0.0;
|
|
node.imageAugment = true;
|
|
node.imageGeometryDim = 0;
|
|
node.imageUseSe = false;
|
|
node.imageSeReduction = 16;
|
|
node.imageSePreNorm = true;
|
|
node.mdHiddenDim = 128;
|
|
node.mdDropout = 0.1;
|
|
node.mdUseSe = false;
|
|
node.mdSeReduction = 16;
|
|
node.mdSePreNorm = true;
|
|
node.mdFreezeRatio = 0.0;
|
|
}
|
|
if (type === "bridge") {
|
|
node.bridgeMethod = "fusion";
|
|
node.bridgeFusionDim = 256;
|
|
node.bridgeUseSe = true;
|
|
node.bridgeSeReduction = 16;
|
|
node.bridgeSePreNorm = true;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function addNode(type) {
|
|
const count = state.nodes.filter((n) => n.type === type).length + 1;
|
|
const node = createNodeTemplate(type);
|
|
node.label = `${typeMeta[type]?.label || type} ${count}`;
|
|
state.nodes.push(node);
|
|
selectNode(node.id);
|
|
autoLayout();
|
|
render();
|
|
return node;
|
|
}
|
|
|
|
function addDataSourceNode({ label, outputType }) {
|
|
const node = createNodeTemplate("data");
|
|
node.label = label;
|
|
node.inputType = "";
|
|
node.inputIndex = "";
|
|
node.outputType = outputType;
|
|
node.sourceRef = null;
|
|
state.nodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
async function importClassDefinition() {
|
|
const selection = importClassSelect.value;
|
|
if (!selection) return;
|
|
if (selection === "PapilaData") {
|
|
let payload = null;
|
|
try {
|
|
const res = await fetch("/api/import/papila");
|
|
const parsed = await parseJsonResponse(res);
|
|
payload = parsed.data;
|
|
if (!res.ok || !payload) {
|
|
throw new Error(payload?.error || parsed.text || "Import failed");
|
|
}
|
|
} catch (err) {
|
|
alert(`Papila import failed: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
const importId = `import_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
if (!state.meta) {
|
|
state.meta = { imports: [] };
|
|
}
|
|
if (!Array.isArray(state.meta.imports)) {
|
|
state.meta.imports = [];
|
|
}
|
|
state.meta.imports.push({
|
|
id: importId,
|
|
className: "PapilaData",
|
|
params: {
|
|
image_dir: "Papila/FundusImages",
|
|
clinical_dir: "Papila/ClinicalData",
|
|
label_col: payload.label_col || "Diagnosis",
|
|
cat_cols: ["Gender", "Phakic/Pseudophakic"],
|
|
},
|
|
});
|
|
|
|
const imageNode = addDataSourceNode({
|
|
label: "Papila Images",
|
|
outputType: "image",
|
|
});
|
|
imageNode.source = {
|
|
mode: "directory",
|
|
path: payload.image_dir,
|
|
count: null,
|
|
};
|
|
imageNode.sourceRef = {
|
|
importId,
|
|
slot: "image_dir",
|
|
mode: "directory",
|
|
};
|
|
|
|
const matrixNode = addDataSourceNode({
|
|
label: "Papila Metadata",
|
|
outputType: "matrix",
|
|
});
|
|
matrixNode.source = {
|
|
mode: "dataframe",
|
|
name: "clinical.df",
|
|
rows: payload.df_rows,
|
|
cols: payload.df_cols,
|
|
};
|
|
matrixNode.sourceRef = {
|
|
importId,
|
|
slot: "clinical_df",
|
|
mode: "dataframe",
|
|
};
|
|
const resizeTransform = addNode("transform");
|
|
resizeTransform.label = "Resize";
|
|
resizeTransform.transformType = "resize";
|
|
resizeTransform.resizeSize = 256;
|
|
|
|
const centerTransform = addNode("transform");
|
|
centerTransform.label = "Center Crop";
|
|
centerTransform.transformType = "center_crop";
|
|
centerTransform.centerCropSize = 224;
|
|
|
|
const jitterTransform = addNode("transform");
|
|
jitterTransform.label = "Jitter";
|
|
jitterTransform.transformType = "jitter_bundle";
|
|
jitterTransform.jitterHFlip = true;
|
|
jitterTransform.jitterVFlip = true;
|
|
jitterTransform.jitterRotation = 15;
|
|
jitterTransform.jitterColorEnabled = true;
|
|
jitterTransform.jitterColor = "0.1,0.1,0.1,0.05";
|
|
|
|
const imageLoader = addNode("loader");
|
|
imageLoader.label = "Image Loader";
|
|
imageLoader.inputType = "image";
|
|
imageLoader.inputIndex = "1";
|
|
imageLoader.outputType = "image";
|
|
imageLoader.inputKey = "image_1";
|
|
imageLoader.outputKey = "image_1";
|
|
|
|
const matrixLoader = addNode("loader");
|
|
matrixLoader.label = "Metadata Loader";
|
|
matrixLoader.inputType = "matrix";
|
|
matrixLoader.inputIndex = "1";
|
|
matrixLoader.outputType = "matrix";
|
|
matrixLoader.inputKey = "matrix_1";
|
|
matrixLoader.outputKey = "matrix_1";
|
|
|
|
const imageTower = addNode("image_tower");
|
|
imageTower.label = "Tower 1";
|
|
imageTower.towerType = "image";
|
|
imageTower.imageBackbone = "efficientnet_b0";
|
|
imageTower.imageFreezeRatio = 0.0;
|
|
imageTower.imageAugment = true;
|
|
imageTower.imageGeometryDim = 0;
|
|
imageTower.imageUseSe = false;
|
|
imageTower.imageSeReduction = 16;
|
|
imageTower.imageSePreNorm = true;
|
|
|
|
const metadataTower = addNode("metadata_tower");
|
|
metadataTower.label = "Tower 2";
|
|
metadataTower.towerType = "metadata";
|
|
metadataTower.mdHiddenDim = 128;
|
|
metadataTower.mdDropout = 0.1;
|
|
metadataTower.mdUseSe = false;
|
|
metadataTower.mdSeReduction = 16;
|
|
metadataTower.mdSePreNorm = true;
|
|
metadataTower.mdFreezeRatio = 0.0;
|
|
|
|
const bridgeNode = addNode("bridge");
|
|
bridgeNode.label = "Bridge";
|
|
bridgeNode.bridgeMethod = "fusion";
|
|
bridgeNode.bridgeFusionDim = 256;
|
|
bridgeNode.bridgeUseSe = true;
|
|
bridgeNode.bridgeSeReduction = 16;
|
|
bridgeNode.bridgeSePreNorm = true;
|
|
|
|
const classifierNode = addNode("classifier");
|
|
classifierNode.label = "Classifier";
|
|
|
|
state.edges.push({ from: imageNode.id, to: resizeTransform.id });
|
|
state.edges.push({ from: resizeTransform.id, to: centerTransform.id });
|
|
state.edges.push({ from: centerTransform.id, to: jitterTransform.id });
|
|
state.edges.push({ from: jitterTransform.id, to: imageLoader.id });
|
|
state.edges.push({ from: matrixNode.id, to: matrixLoader.id });
|
|
state.edges.push({ from: imageLoader.id, to: imageTower.id });
|
|
state.edges.push({ from: matrixLoader.id, to: metadataTower.id });
|
|
state.edges.push({ from: imageTower.id, to: bridgeNode.id });
|
|
state.edges.push({ from: metadataTower.id, to: bridgeNode.id });
|
|
state.edges.push({ from: bridgeNode.id, to: classifierNode.id });
|
|
|
|
autoLayout();
|
|
selectNode(imageNode.id);
|
|
render();
|
|
}
|
|
}
|
|
|
|
function selectNode(id) {
|
|
state.selectedId = id;
|
|
if (id) {
|
|
setSelection([id]);
|
|
} else {
|
|
setSelection([]);
|
|
}
|
|
const node = state.nodes.find((n) => n.id === id);
|
|
if (node) {
|
|
selectedNode.textContent = node.label;
|
|
selectedNode.classList.remove("muted");
|
|
nodeLabelInput.value = node.label;
|
|
nodeSlotInput.value = node.inputKey || "";
|
|
nodeOutputInput.value = node.outputKey || "";
|
|
selectionGeneric.classList.toggle("hidden", node.type === "loader" || node.type === "data");
|
|
selectionLoader.classList.toggle("hidden", node.type !== "loader");
|
|
selectionFilter.classList.toggle("hidden", node.type !== "filter");
|
|
selectionTransform.classList.toggle("hidden", node.type !== "transform");
|
|
const isTower = node.type === "image_tower" || node.type === "metadata_tower";
|
|
selectionTower.classList.toggle("hidden", !isTower);
|
|
selectionBridge.classList.toggle("hidden", node.type !== "bridge");
|
|
selectionClassifier.classList.toggle("hidden", node.type !== "classifier");
|
|
selectionData.classList.toggle("hidden", node.type !== "data");
|
|
|
|
if (node.type === "loader") {
|
|
loaderInputType.value = node.inputType || "image";
|
|
loaderInputIndex.value = node.inputIndex || "1";
|
|
loaderOutputType.value = node.outputType || loaderInputType.value;
|
|
}
|
|
if (node.type === "filter") {
|
|
filterInputType.value = node.inputType || "image";
|
|
filterOutputType.value = node.outputType || filterInputType.value;
|
|
filterType.value = node.filterType || "regex";
|
|
filterRegexPattern.value = node.regexPattern || "";
|
|
filterColumnName.value = node.columnName || "";
|
|
filterColumnOperator.value = node.columnOperator || ">";
|
|
filterColumnValue.value = node.columnValue || "";
|
|
updateFilterFieldVisibility(filterType.value);
|
|
}
|
|
if (node.type === "transform") {
|
|
transformType.value = node.transformType || "center_crop";
|
|
updateTransformFieldVisibility(transformType.value);
|
|
transformRoiMaskSource.value = node.roiMaskSource || "gt";
|
|
transformRoiScale.value =
|
|
node.roiScale === null || node.roiScale === undefined ? "" : node.roiScale;
|
|
transformRoiTarget.value =
|
|
node.roiTargetSize === null || node.roiTargetSize === undefined ? "" : node.roiTargetSize;
|
|
transformRoiFallback.checked = node.roiFallback !== false;
|
|
transformCenterSize.value =
|
|
node.centerCropSize === null || node.centerCropSize === undefined
|
|
? ""
|
|
: node.centerCropSize;
|
|
transformJitterHFlip.checked = node.jitterHFlip !== false;
|
|
transformJitterVFlip.checked = node.jitterVFlip !== false;
|
|
transformJitterRotation.value =
|
|
node.jitterRotation === null || node.jitterRotation === undefined
|
|
? ""
|
|
: node.jitterRotation;
|
|
transformJitterColorEnabled.checked = node.jitterColorEnabled !== false;
|
|
transformJitterColor.value = node.jitterColor || "";
|
|
transformResizeSize.value =
|
|
node.resizeSize === null || node.resizeSize === undefined ? "" : node.resizeSize;
|
|
}
|
|
if (node.type === "bridge") {
|
|
bridgeMethod.value = node.bridgeMethod || "fusion";
|
|
updateBridgeFieldVisibility(bridgeMethod.value);
|
|
bridgeFusionDim.value =
|
|
node.bridgeFusionDim === null || node.bridgeFusionDim === undefined
|
|
? ""
|
|
: node.bridgeFusionDim;
|
|
bridgeUseSe.checked = node.bridgeUseSe !== false;
|
|
bridgeSeReduction.value =
|
|
node.bridgeSeReduction === null || node.bridgeSeReduction === undefined
|
|
? ""
|
|
: node.bridgeSeReduction;
|
|
bridgeSePreNorm.checked = node.bridgeSePreNorm !== false;
|
|
}
|
|
if (node.type === "image_tower" || node.type === "metadata_tower") {
|
|
const towerKind = node.type === "metadata_tower" ? "metadata" : "image";
|
|
towerType.value = node.towerType || towerKind;
|
|
updateTowerFieldVisibility(towerType.value);
|
|
towerBackbone.value = node.imageBackbone || "efficientnet_b0";
|
|
towerFreezeRatio.value =
|
|
node.imageFreezeRatio === null || node.imageFreezeRatio === undefined
|
|
? ""
|
|
: node.imageFreezeRatio;
|
|
towerAugment.checked = node.imageAugment !== false;
|
|
towerGeometryDim.value =
|
|
node.imageGeometryDim === null || node.imageGeometryDim === undefined
|
|
? ""
|
|
: node.imageGeometryDim;
|
|
towerUseSe.checked = node.imageUseSe === true;
|
|
towerSeReduction.value =
|
|
node.imageSeReduction === null || node.imageSeReduction === undefined
|
|
? ""
|
|
: node.imageSeReduction;
|
|
towerSePreNorm.checked = node.imageSePreNorm !== false;
|
|
towerMdHidden.value =
|
|
node.mdHiddenDim === null || node.mdHiddenDim === undefined ? "" : node.mdHiddenDim;
|
|
towerMdDropout.value =
|
|
node.mdDropout === null || node.mdDropout === undefined ? "" : node.mdDropout;
|
|
towerMdUseSe.checked = node.mdUseSe === true;
|
|
towerMdSeReduction.value =
|
|
node.mdSeReduction === null || node.mdSeReduction === undefined
|
|
? ""
|
|
: node.mdSeReduction;
|
|
towerMdSePreNorm.checked = node.mdSePreNorm !== false;
|
|
towerMdFreezeRatio.value =
|
|
node.mdFreezeRatio === null || node.mdFreezeRatio === undefined
|
|
? ""
|
|
: node.mdFreezeRatio;
|
|
}
|
|
if (node.type === "data") {
|
|
dataOutputType.value = node.outputType || "";
|
|
updateDataBrowseVisibility(node.outputType || "");
|
|
updateDataSourceStatus(node);
|
|
}
|
|
} else {
|
|
clearSelectionUI();
|
|
}
|
|
}
|
|
|
|
function clearSelectionUI() {
|
|
selectedNode.textContent = "None";
|
|
selectedNode.classList.add("muted");
|
|
nodeLabelInput.value = "";
|
|
nodeSlotInput.value = "";
|
|
nodeOutputInput.value = "";
|
|
selectionGeneric.classList.remove("hidden");
|
|
selectionLoader.classList.add("hidden");
|
|
selectionFilter.classList.add("hidden");
|
|
selectionTransform.classList.add("hidden");
|
|
selectionTower.classList.add("hidden");
|
|
selectionBridge.classList.add("hidden");
|
|
selectionClassifier.classList.add("hidden");
|
|
selectionData.classList.add("hidden");
|
|
}
|
|
|
|
function applyNodeEdits() {
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (!node) return;
|
|
node.label = nodeLabelInput.value.trim() || node.label;
|
|
node.inputKey = nodeSlotInput.value.trim();
|
|
node.outputKey = nodeOutputInput.value.trim();
|
|
if (node.type === "loader") {
|
|
node.inputType = loaderInputType.value;
|
|
node.inputIndex = loaderInputIndex.value;
|
|
node.outputType = node.inputType;
|
|
}
|
|
if (node.type === "filter") {
|
|
node.inputType = filterInputType.value;
|
|
node.outputType = node.inputType;
|
|
node.filterType = filterType.value;
|
|
node.regexPattern = filterRegexPattern.value.trim();
|
|
node.columnName = filterColumnName.value.trim();
|
|
node.columnOperator = filterColumnOperator.value;
|
|
node.columnValue = filterColumnValue.value.trim();
|
|
filterOutputType.value = node.outputType;
|
|
}
|
|
if (node.type === "transform") {
|
|
node.transformType = transformType.value;
|
|
node.roiMaskSource = transformRoiMaskSource.value;
|
|
node.roiScale = transformRoiScale.value === "" ? null : Number(transformRoiScale.value);
|
|
node.roiTargetSize =
|
|
transformRoiTarget.value === "" ? null : Number(transformRoiTarget.value);
|
|
node.roiFallback = transformRoiFallback.checked;
|
|
node.centerCropSize =
|
|
transformCenterSize.value === "" ? null : Number(transformCenterSize.value);
|
|
node.jitterHFlip = transformJitterHFlip.checked;
|
|
node.jitterVFlip = transformJitterVFlip.checked;
|
|
node.jitterRotation =
|
|
transformJitterRotation.value === "" ? null : Number(transformJitterRotation.value);
|
|
node.jitterColorEnabled = transformJitterColorEnabled.checked;
|
|
node.jitterColor = transformJitterColor.value.trim();
|
|
node.resizeSize =
|
|
transformResizeSize.value === "" ? null : Number(transformResizeSize.value);
|
|
}
|
|
if (node.type === "bridge") {
|
|
node.bridgeMethod = bridgeMethod.value;
|
|
node.bridgeFusionDim =
|
|
bridgeFusionDim.value === "" ? null : Number(bridgeFusionDim.value);
|
|
node.bridgeUseSe = bridgeUseSe.checked;
|
|
node.bridgeSeReduction =
|
|
bridgeSeReduction.value === "" ? null : Number(bridgeSeReduction.value);
|
|
node.bridgeSePreNorm = bridgeSePreNorm.checked;
|
|
}
|
|
if (node.type === "image_tower" || node.type === "metadata_tower") {
|
|
const desiredType = towerType.value === "metadata" ? "metadata_tower" : "image_tower";
|
|
if (node.type !== desiredType) {
|
|
const oldType = node.type;
|
|
node.type = desiredType;
|
|
const oldLabelBase = typeMeta[oldType]?.label;
|
|
const newLabelBase = typeMeta[desiredType]?.label;
|
|
if (oldLabelBase && newLabelBase && node.label.startsWith(oldLabelBase)) {
|
|
node.label = node.label.replace(oldLabelBase, newLabelBase);
|
|
}
|
|
}
|
|
node.towerType = towerType.value;
|
|
node.imageBackbone = towerBackbone.value;
|
|
node.imageFreezeRatio =
|
|
towerFreezeRatio.value === "" ? null : Number(towerFreezeRatio.value);
|
|
node.imageAugment = towerAugment.checked;
|
|
node.imageGeometryDim =
|
|
towerGeometryDim.value === "" ? null : Number(towerGeometryDim.value);
|
|
node.imageUseSe = towerUseSe.checked;
|
|
node.imageSeReduction =
|
|
towerSeReduction.value === "" ? null : Number(towerSeReduction.value);
|
|
node.imageSePreNorm = towerSePreNorm.checked;
|
|
node.mdHiddenDim = towerMdHidden.value === "" ? null : Number(towerMdHidden.value);
|
|
node.mdDropout = towerMdDropout.value === "" ? null : Number(towerMdDropout.value);
|
|
node.mdUseSe = towerMdUseSe.checked;
|
|
node.mdSeReduction =
|
|
towerMdSeReduction.value === "" ? null : Number(towerMdSeReduction.value);
|
|
node.mdSePreNorm = towerMdSePreNorm.checked;
|
|
node.mdFreezeRatio =
|
|
towerMdFreezeRatio.value === "" ? null : Number(towerMdFreezeRatio.value);
|
|
}
|
|
if (node.type === "data") {
|
|
node.outputType = dataOutputType.value;
|
|
updateDataSourceStatus(node);
|
|
}
|
|
render();
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!state.selectedId) return;
|
|
state.nodes = state.nodes.filter((n) => n.id !== state.selectedId);
|
|
state.edges = state.edges.filter(
|
|
(e) => e.from !== state.selectedId && e.to !== state.selectedId
|
|
);
|
|
state.selectedId = null;
|
|
selectNode(null);
|
|
render();
|
|
}
|
|
|
|
function toggleConnectMode() {
|
|
state.connectMode = !state.connectMode;
|
|
state.connectFrom = null;
|
|
connectButton.textContent = state.connectMode ? "Connecting..." : "Connect";
|
|
connectButton.classList.toggle("danger", state.connectMode);
|
|
}
|
|
|
|
function connectNodes(fromId, toId) {
|
|
if (!fromId || !toId || fromId === toId) return;
|
|
const exists = state.edges.some((e) => e.from === fromId && e.to === toId);
|
|
if (!exists) {
|
|
const toNode = state.nodes.find((n) => n.id === toId);
|
|
if (toNode && toNode.type === "bridge") {
|
|
const incoming = state.edges.filter((e) => e.to === toId);
|
|
if (incoming.length >= 2) {
|
|
alert("Bridge nodes require exactly two inputs. Remove an edge first.");
|
|
return;
|
|
}
|
|
}
|
|
state.edges.push({ from: fromId, to: toId });
|
|
if (toNode && (toNode.type === "image_tower" || toNode.type === "metadata_tower")) {
|
|
autoSetTowerType(toNode);
|
|
if (state.selectedId === toNode.id) {
|
|
selectNode(toNode.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeSelectedEdges() {
|
|
if (!state.selectedId) return;
|
|
state.edges = state.edges.filter(
|
|
(e) => e.from !== state.selectedId && e.to !== state.selectedId
|
|
);
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (node && (node.type === "image_tower" || node.type === "metadata_tower")) {
|
|
autoSetTowerType(node);
|
|
}
|
|
render();
|
|
}
|
|
|
|
function autoLayout() {
|
|
const rows = {};
|
|
nodeTypeOrder.forEach((type, idx) => {
|
|
rows[type] = { y: 760 - idx * 130, nodes: [] };
|
|
});
|
|
state.nodes.forEach((node) => {
|
|
if (!rows[node.type]) {
|
|
rows[node.type] = { y: 760 - nodeTypeOrder.length * 130, nodes: [] };
|
|
}
|
|
rows[node.type].nodes.push(node);
|
|
});
|
|
Object.values(rows).forEach((row) => {
|
|
row.nodes.forEach((node, index) => {
|
|
node.x = 160 + index * 180;
|
|
node.y = row.y;
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateDataBrowseVisibility(outputType) {
|
|
dataBrowseButton.disabled = outputType === "";
|
|
modalSelectDir.disabled = outputType !== "image";
|
|
modalSelectFile.disabled = outputType !== "matrix";
|
|
}
|
|
|
|
function updateFilterFieldVisibility(type) {
|
|
filterRegexFields.classList.toggle("hidden", type !== "regex");
|
|
filterColumnFields.classList.toggle("hidden", type !== "column");
|
|
}
|
|
|
|
function updateTransformFieldVisibility(type) {
|
|
transformRoiFields.classList.toggle("hidden", type !== "roi_crop");
|
|
transformCenterFields.classList.toggle("hidden", type !== "center_crop");
|
|
transformJitterFields.classList.toggle("hidden", type !== "jitter_bundle");
|
|
transformResizeFields.classList.toggle("hidden", type !== "resize");
|
|
}
|
|
|
|
function updateTowerFieldVisibility(type) {
|
|
towerImageFields.classList.toggle("hidden", type !== "image");
|
|
towerMdFields.classList.toggle("hidden", type !== "metadata");
|
|
}
|
|
|
|
function updateBridgeFieldVisibility(method) {
|
|
bridgeFusionFields.classList.toggle("hidden", method !== "fusion");
|
|
}
|
|
|
|
function setSelection(nodeIds) {
|
|
selectionState.nodeIds = new Set(nodeIds);
|
|
selectionState.edgeIndices = new Set();
|
|
state.edges.forEach((edge, idx) => {
|
|
if (selectionState.nodeIds.has(edge.from) && selectionState.nodeIds.has(edge.to)) {
|
|
selectionState.edgeIndices.add(idx);
|
|
}
|
|
});
|
|
}
|
|
|
|
function autoSetTowerType(towerNode) {
|
|
if (!towerNode) return;
|
|
const incoming = state.edges.filter((e) => e.to === towerNode.id);
|
|
const fromNodes = incoming
|
|
.map((edge) => state.nodes.find((n) => n.id === edge.from))
|
|
.filter(Boolean);
|
|
const loader = fromNodes.find((n) => n.type === "loader");
|
|
if (!loader) return;
|
|
const desired = loader.inputType === "matrix" ? "metadata" : "image";
|
|
if (towerNode.towerType !== desired) {
|
|
towerNode.towerType = desired;
|
|
towerNode.type = desired === "metadata" ? "metadata_tower" : "image_tower";
|
|
const labelBase = typeMeta[towerNode.type]?.label || "Tower";
|
|
if (!towerNode.label || towerNode.label.startsWith("Tower")) {
|
|
const count = state.nodes.filter((n) => n.type === towerNode.type).length;
|
|
towerNode.label = `${labelBase} ${count + 1}`;
|
|
} else if (towerNode.label.startsWith("Tower")) {
|
|
towerNode.label = towerNode.label.replace(/^Tower/, labelBase);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveUpstreamData(startId) {
|
|
const visited = new Set();
|
|
const filters = [];
|
|
let currentId = startId;
|
|
let dataNode = null;
|
|
let ambiguous = false;
|
|
while (currentId) {
|
|
if (visited.has(currentId)) break;
|
|
visited.add(currentId);
|
|
const incoming = state.edges.filter((e) => e.to === currentId);
|
|
if (!incoming.length) break;
|
|
if (incoming.length > 1) ambiguous = true;
|
|
const fromId = incoming[0].from;
|
|
const fromNode = state.nodes.find((n) => n.id === fromId);
|
|
if (!fromNode) break;
|
|
if (fromNode.type === "filter") {
|
|
filters.push(fromNode);
|
|
currentId = fromNode.id;
|
|
continue;
|
|
}
|
|
if (fromNode.type === "data") {
|
|
dataNode = fromNode;
|
|
break;
|
|
}
|
|
currentId = fromNode.id;
|
|
}
|
|
return { dataNode, filters, ambiguous };
|
|
}
|
|
|
|
function formatFilterLabel(filter) {
|
|
if (filter.filterType === "column") {
|
|
const name = filter.columnName || "?";
|
|
const op = filter.columnOperator || "?";
|
|
const value = filter.columnValue || "?";
|
|
return `col:${name} ${op} ${value}`;
|
|
}
|
|
const pattern = filter.regexPattern || "";
|
|
return `regex:${pattern || "?"}`;
|
|
}
|
|
|
|
function summarizeFilters(filters) {
|
|
if (!filters.length) return "";
|
|
return filters.map(formatFilterLabel).join(", ");
|
|
}
|
|
|
|
function compileRegex(pattern) {
|
|
if (!pattern) return { regex: null, error: null };
|
|
try {
|
|
return { regex: new RegExp(pattern), error: null };
|
|
} catch (err) {
|
|
return { regex: null, error: err.message || "invalid regex" };
|
|
}
|
|
}
|
|
|
|
function applyRegexFilters(entries, filters) {
|
|
let filtered = entries;
|
|
const warnings = [];
|
|
filters.forEach((filter) => {
|
|
if (filter.filterType !== "regex") return;
|
|
const pattern = (filter.regexPattern || "").trim();
|
|
if (!pattern) return;
|
|
const { regex, error } = compileRegex(pattern);
|
|
if (error || !regex) {
|
|
warnings.push(`Invalid regex "${pattern}": ${error || "invalid"}`);
|
|
return;
|
|
}
|
|
filtered = filtered.filter((entry) => regex.test(entry.name));
|
|
});
|
|
return { filtered, warnings };
|
|
}
|
|
|
|
function compareCell(cell, rawValue, operator) {
|
|
const cellStr = cell == null ? "" : String(cell).trim();
|
|
const valueStr = rawValue == null ? "" : String(rawValue).trim();
|
|
const cellNorm = cellStr.toLowerCase();
|
|
const valueNorm = valueStr.toLowerCase();
|
|
if (operator === "=") return cellNorm === valueNorm;
|
|
if (operator === "!=") return cellNorm !== valueNorm;
|
|
const cellNum = Number.parseFloat(cellStr);
|
|
const valueNum = Number.parseFloat(valueStr);
|
|
if (!Number.isFinite(cellNum) || !Number.isFinite(valueNum)) return false;
|
|
switch (operator) {
|
|
case ">":
|
|
return cellNum > valueNum;
|
|
case ">=":
|
|
return cellNum >= valueNum;
|
|
case "<":
|
|
return cellNum < valueNum;
|
|
case "<=":
|
|
return cellNum <= valueNum;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function applyColumnFilters(header, rows, filters) {
|
|
let filteredRows = rows;
|
|
const warnings = [];
|
|
filters.forEach((filter) => {
|
|
if (filter.filterType !== "column") return;
|
|
const columnName = (filter.columnName || "").trim();
|
|
if (!columnName) {
|
|
warnings.push("Column filter missing column name.");
|
|
return;
|
|
}
|
|
let colIndex = header.indexOf(columnName);
|
|
if (colIndex === -1) {
|
|
const lower = columnName.toLowerCase();
|
|
const matches = header
|
|
.map((col, idx) => ({ col, idx }))
|
|
.filter((item) => String(item.col).toLowerCase() === lower);
|
|
if (matches.length) {
|
|
colIndex = matches[0].idx;
|
|
if (matches.length > 1) {
|
|
warnings.push(`Column "${columnName}" matched multiple headers; using "${matches[0].col}".`);
|
|
}
|
|
}
|
|
}
|
|
if (colIndex === -1) {
|
|
warnings.push(`Column "${columnName}" not found.`);
|
|
return;
|
|
}
|
|
const rawValue = filter.columnValue;
|
|
if (rawValue === "" || rawValue == null) {
|
|
warnings.push(`Column filter "${columnName}" missing value.`);
|
|
return;
|
|
}
|
|
const op = filter.columnOperator || "=";
|
|
filteredRows = filteredRows.filter((row) =>
|
|
compareCell(row[colIndex], rawValue, op)
|
|
);
|
|
});
|
|
return { filteredRows, warnings };
|
|
}
|
|
|
|
function renderPreviewWarnings(warnings) {
|
|
if (!warnings.length) return null;
|
|
const wrap = document.createElement("div");
|
|
warnings.forEach((msg) => {
|
|
const line = document.createElement("div");
|
|
line.className = "hint";
|
|
line.textContent = msg;
|
|
wrap.appendChild(line);
|
|
});
|
|
return wrap;
|
|
}
|
|
|
|
function renderPreviewCount(label, shown, total, truncated, note = "") {
|
|
const line = document.createElement("div");
|
|
line.className = "hint";
|
|
const truncation = truncated ? " (showing first 200)" : "";
|
|
const suffix = note ? ` ${note}` : "";
|
|
line.textContent = `${label}: ${shown} / ${total}${truncation}${suffix}`;
|
|
return line;
|
|
}
|
|
|
|
function updateDataSourceStatus(node) {
|
|
if (!node || !node.source) {
|
|
dataSourceStatus.textContent = "No source selected.";
|
|
return;
|
|
}
|
|
if (node.source.mode === "directory") {
|
|
const count = node.source.count === null ? "?" : node.source.count;
|
|
dataSourceStatus.textContent = `Directory: ${node.source.path} (${count} files)`;
|
|
} else if (node.source.mode === "file") {
|
|
dataSourceStatus.textContent = `File: ${node.source.path} (${node.source.size} bytes)`;
|
|
} else if (node.source.mode === "dataframe") {
|
|
dataSourceStatus.textContent = `DataFrame: ${node.source.name} (${node.source.rows}x${node.source.cols})`;
|
|
} else {
|
|
dataSourceStatus.textContent = "Source loaded.";
|
|
}
|
|
}
|
|
|
|
async function parseJsonResponse(res) {
|
|
const text = await res.text();
|
|
try {
|
|
return { data: JSON.parse(text), text };
|
|
} catch (err) {
|
|
return { data: null, text };
|
|
}
|
|
}
|
|
|
|
async function fetchDirectory(pathValue = "") {
|
|
const res = await fetch(`/api/fs?path=${encodeURIComponent(pathValue)}`);
|
|
const { data, text } = await parseJsonResponse(res);
|
|
if (!res.ok || !data) {
|
|
const detail = data?.error || text || `HTTP ${res.status}`;
|
|
throw new Error(detail);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function fetchFile(pathValue = "", limit = null) {
|
|
const limitParam = limit == null ? "" : `&limit=${encodeURIComponent(limit)}`;
|
|
const res = await fetch(
|
|
`/api/file?path=${encodeURIComponent(pathValue)}${limitParam}`
|
|
);
|
|
const { data, text } = await parseJsonResponse(res);
|
|
if (!res.ok || !data) {
|
|
const detail = data?.error || text || `HTTP ${res.status}`;
|
|
throw new Error(detail);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function screenToWorld(clientX, clientY) {
|
|
const ctm = svg.getScreenCTM();
|
|
if (!ctm) {
|
|
return { x: 0, y: 0 };
|
|
}
|
|
const pt = svg.createSVGPoint();
|
|
pt.x = clientX;
|
|
pt.y = clientY;
|
|
const svgPoint = pt.matrixTransform(ctm.inverse());
|
|
const x = (svgPoint.x - state.pan.x) / state.pan.scale;
|
|
const y = (svgPoint.y - state.pan.y) / state.pan.scale;
|
|
return { x, y };
|
|
}
|
|
|
|
function render() {
|
|
nodeCount.textContent = `${state.nodes.length} nodes`;
|
|
edgeCount.textContent = `${state.edges.length} edges`;
|
|
jsonView.textContent = JSON.stringify(
|
|
{
|
|
nodes: state.nodes.map(serializeNode),
|
|
edges: state.edges,
|
|
meta: {
|
|
generated_at: new Date().toISOString(),
|
|
...(state.meta || {}),
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
|
|
svg.innerHTML = "";
|
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
g.setAttribute(
|
|
"transform",
|
|
`translate(${state.pan.x}, ${state.pan.y}) scale(${state.pan.scale})`
|
|
);
|
|
|
|
state.edges.forEach((edge, idx) => {
|
|
const from = state.nodes.find((n) => n.id === edge.from);
|
|
const to = state.nodes.find((n) => n.id === edge.to);
|
|
if (!from || !to) return;
|
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
const startY = from.y - 30;
|
|
const endY = to.y + 30;
|
|
const midY = (startY + endY) / 2;
|
|
const d = `M ${from.x} ${startY} C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${endY}`;
|
|
path.setAttribute("d", d);
|
|
path.setAttribute("fill", "none");
|
|
const isSelected = selectionState.edgeIndices.has(idx);
|
|
path.setAttribute("stroke", isSelected ? "#c84630" : "#1c1b1a");
|
|
path.setAttribute("stroke-width", isSelected ? "3" : "2");
|
|
path.setAttribute("opacity", isSelected ? "0.8" : "0.4");
|
|
g.appendChild(path);
|
|
});
|
|
|
|
state.nodes.forEach((node) => {
|
|
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
group.setAttribute("class", "node");
|
|
group.setAttribute("transform", `translate(${node.x}, ${node.y})`);
|
|
group.style.cursor = "pointer";
|
|
|
|
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
rect.setAttribute("x", "-60");
|
|
rect.setAttribute("y", "-30");
|
|
rect.setAttribute("width", "120");
|
|
rect.setAttribute("height", "60");
|
|
rect.setAttribute("rx", "14");
|
|
rect.setAttribute("fill", typeMeta[node.type]?.color || "#999");
|
|
rect.setAttribute(
|
|
"opacity",
|
|
node.id === state.selectedId || selectionState.nodeIds.has(node.id) ? "0.9" : "0.75"
|
|
);
|
|
rect.setAttribute(
|
|
"stroke",
|
|
selectionState.nodeIds.has(node.id) ? "#1c1b1a" : "none"
|
|
);
|
|
rect.setAttribute(
|
|
"stroke-width",
|
|
selectionState.nodeIds.has(node.id) ? "2" : "0"
|
|
);
|
|
|
|
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
text.setAttribute("text-anchor", "middle");
|
|
text.setAttribute("y", "5");
|
|
text.setAttribute("fill", "#fff");
|
|
text.setAttribute("font-size", "12");
|
|
text.setAttribute("font-family", "Space Grotesk, sans-serif");
|
|
text.textContent = node.label;
|
|
|
|
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
tag.setAttribute("text-anchor", "middle");
|
|
tag.setAttribute("y", "24");
|
|
tag.setAttribute("fill", "#fff");
|
|
tag.setAttribute("font-size", "10");
|
|
tag.textContent = node.inputKey ? `in: ${node.inputKey}` : "";
|
|
|
|
group.appendChild(rect);
|
|
group.appendChild(text);
|
|
group.appendChild(tag);
|
|
|
|
group.addEventListener("click", () => {
|
|
if (state.draggingJustEnded) {
|
|
state.draggingJustEnded = false;
|
|
return;
|
|
}
|
|
if (state.connectMode) {
|
|
if (!state.connectFrom) {
|
|
state.connectFrom = node.id;
|
|
} else {
|
|
connectNodes(state.connectFrom, node.id);
|
|
state.connectFrom = null;
|
|
state.connectMode = false;
|
|
connectButton.textContent = "Connect";
|
|
connectButton.classList.remove("danger");
|
|
}
|
|
render();
|
|
return;
|
|
}
|
|
if (state.clickTimer) {
|
|
clearTimeout(state.clickTimer);
|
|
}
|
|
state.clickTimer = setTimeout(() => {
|
|
selectNode(node.id);
|
|
render();
|
|
state.clickTimer = null;
|
|
}, 250);
|
|
});
|
|
|
|
group.addEventListener("dblclick", (event) => {
|
|
event.preventDefault();
|
|
if (state.clickTimer) {
|
|
clearTimeout(state.clickTimer);
|
|
state.clickTimer = null;
|
|
}
|
|
if (node.type === "data") {
|
|
openPreview(node);
|
|
} else if (node.type === "loader") {
|
|
openLoaderPreview(node);
|
|
}
|
|
});
|
|
|
|
group.addEventListener("mousedown", (event) => {
|
|
event.stopPropagation();
|
|
state.draggingNodeId = node.id;
|
|
state.draggingMoved = false;
|
|
const world = screenToWorld(event.clientX, event.clientY);
|
|
state.draggingOffset = { x: world.x - node.x, y: world.y - node.y };
|
|
});
|
|
|
|
g.appendChild(group);
|
|
});
|
|
|
|
svg.appendChild(g);
|
|
|
|
if (selectionBox) {
|
|
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
const minX = Math.min(selectionBox.x1, selectionBox.x2);
|
|
const minY = Math.min(selectionBox.y1, selectionBox.y2);
|
|
const width = Math.abs(selectionBox.x2 - selectionBox.x1);
|
|
const height = Math.abs(selectionBox.y2 - selectionBox.y1);
|
|
rect.setAttribute("x", `${minX}`);
|
|
rect.setAttribute("y", `${minY}`);
|
|
rect.setAttribute("width", `${width}`);
|
|
rect.setAttribute("height", `${height}`);
|
|
rect.setAttribute("fill", "rgba(200,70,48,0.12)");
|
|
rect.setAttribute("stroke", "#c84630");
|
|
rect.setAttribute("stroke-width", "1");
|
|
g.appendChild(rect);
|
|
}
|
|
}
|
|
|
|
function serializeNode(node) {
|
|
if (!node) return node;
|
|
const base = { ...node };
|
|
if (node.type !== "bridge") {
|
|
delete base.bridgeMethod;
|
|
delete base.bridgeFusionDim;
|
|
delete base.bridgeUseSe;
|
|
delete base.bridgeSeReduction;
|
|
delete base.bridgeSePreNorm;
|
|
} else if (base.bridgeMethod !== "fusion") {
|
|
delete base.bridgeFusionDim;
|
|
delete base.bridgeUseSe;
|
|
delete base.bridgeSeReduction;
|
|
delete base.bridgeSePreNorm;
|
|
}
|
|
if (node.type !== "filter") {
|
|
delete base.filterType;
|
|
delete base.regexPattern;
|
|
delete base.columnName;
|
|
delete base.columnOperator;
|
|
delete base.columnValue;
|
|
}
|
|
if (node.type !== "transform") {
|
|
delete base.transformType;
|
|
delete base.roiMaskSource;
|
|
delete base.roiScale;
|
|
delete base.roiTargetSize;
|
|
delete base.roiFallback;
|
|
delete base.centerCropSize;
|
|
delete base.jitterHFlip;
|
|
delete base.jitterVFlip;
|
|
delete base.jitterRotation;
|
|
delete base.jitterColorEnabled;
|
|
delete base.jitterColor;
|
|
delete base.resizeSize;
|
|
}
|
|
const isTower = node.type === "image_tower" || node.type === "metadata_tower";
|
|
if (isTower) {
|
|
const towerType = node.towerType || (node.type === "metadata_tower" ? "metadata" : "image");
|
|
base.towerType = towerType;
|
|
if (towerType === "image") {
|
|
delete base.mdHiddenDim;
|
|
delete base.mdDropout;
|
|
delete base.mdUseSe;
|
|
delete base.mdSeReduction;
|
|
delete base.mdSePreNorm;
|
|
delete base.mdFreezeRatio;
|
|
} else {
|
|
delete base.imageBackbone;
|
|
delete base.imageFreezeRatio;
|
|
delete base.imageAugment;
|
|
delete base.imageGeometryDim;
|
|
delete base.imageUseSe;
|
|
delete base.imageSeReduction;
|
|
delete base.imageSePreNorm;
|
|
}
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function resetGraph() {
|
|
state.nodes = [];
|
|
state.edges = [];
|
|
state.selectedId = null;
|
|
state.meta = { imports: [] };
|
|
setSelection([]);
|
|
render();
|
|
}
|
|
|
|
function saveLocal() {
|
|
localStorage.setItem("hypertower_v2_builder", JSON.stringify(state));
|
|
}
|
|
|
|
function loadLocal() {
|
|
const raw = localStorage.getItem("hypertower_v2_builder");
|
|
if (!raw) return;
|
|
const loaded = JSON.parse(raw);
|
|
state.nodes = loaded.nodes || [];
|
|
state.edges = loaded.edges || [];
|
|
state.meta = loaded.meta || { imports: [] };
|
|
state.selectedId = null;
|
|
setSelection([]);
|
|
autoLayout();
|
|
render();
|
|
}
|
|
|
|
function exportJson() {
|
|
const blob = new Blob([jsonView.textContent], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = "hypertower_v2_config.json";
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function importJson(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const data = JSON.parse(event.target.result);
|
|
state.nodes = data.nodes || [];
|
|
state.edges = data.edges || [];
|
|
state.meta = data.meta || { imports: [] };
|
|
state.selectedId = null;
|
|
setSelection([]);
|
|
autoLayout();
|
|
render();
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
let browseState = { path: "", entries: [], selected: null };
|
|
|
|
function openModal() {
|
|
modalOverlay.classList.remove("hidden");
|
|
browseState = { path: "", entries: [], selected: null };
|
|
loadDirectory("");
|
|
}
|
|
|
|
function closeModal() {
|
|
modalOverlay.classList.add("hidden");
|
|
}
|
|
|
|
function openPreview(node) {
|
|
previewOverlay.classList.remove("hidden");
|
|
previewBody.innerHTML = "";
|
|
if (!node.source) {
|
|
previewSubtitle.textContent = "No source selected.";
|
|
previewBody.innerHTML = "<div class='hint'>Select a source first.</div>";
|
|
return;
|
|
}
|
|
if (node.outputType === "image" && node.source.mode === "directory") {
|
|
previewSubtitle.textContent = `Images in ${node.source.path}`;
|
|
loadPreviewDirectory(node.source.path);
|
|
return;
|
|
}
|
|
if (node.outputType === "matrix" && node.source.mode === "file") {
|
|
previewSubtitle.textContent = `Matrix preview: ${node.source.path}`;
|
|
loadPreviewFile(node.source.path);
|
|
return;
|
|
}
|
|
if (node.outputType === "matrix" && node.source.mode === "dataframe") {
|
|
previewSubtitle.textContent = `Matrix preview: ${node.source.name}`;
|
|
loadPreviewPapilaDf();
|
|
return;
|
|
}
|
|
previewSubtitle.textContent = "Unsupported source type.";
|
|
previewBody.innerHTML = "<div class='hint'>Select a valid source.</div>";
|
|
}
|
|
|
|
function openLoaderPreview(node) {
|
|
previewOverlay.classList.remove("hidden");
|
|
previewBody.innerHTML = "";
|
|
const loaderType = node.inputType || node.outputType || "";
|
|
const { dataNode, filters, ambiguous } = resolveUpstreamData(node.id);
|
|
const relevantFilters = filters
|
|
.filter((filter) => filter.inputType === loaderType)
|
|
.reverse();
|
|
const useFullData = relevantFilters.length > 0 ? 0 : null;
|
|
const summary = summarizeFilters(relevantFilters);
|
|
const ambiguityNote = ambiguous ? " (multiple inputs: first path)" : "";
|
|
previewSubtitle.textContent = `Loader: ${node.label}${ambiguityNote}`;
|
|
if (summary) {
|
|
previewSubtitle.textContent += ` | Filters: ${summary}`;
|
|
} else if (filters.length > 0) {
|
|
previewSubtitle.textContent += " | Filters: none applied (check input type)";
|
|
}
|
|
if (!dataNode) {
|
|
previewBody.innerHTML = "<div class='hint'>No upstream data source found.</div>";
|
|
return;
|
|
}
|
|
if (!dataNode.source) {
|
|
previewBody.innerHTML = "<div class='hint'>Upstream data source has no file selected.</div>";
|
|
return;
|
|
}
|
|
if (loaderType === "image") {
|
|
if (dataNode.source.mode !== "directory") {
|
|
previewBody.innerHTML = "<div class='hint'>Upstream source is not a directory.</div>";
|
|
return;
|
|
}
|
|
loadPreviewDirectory(dataNode.source.path, relevantFilters);
|
|
return;
|
|
}
|
|
if (loaderType === "matrix") {
|
|
if (dataNode.source.mode === "file") {
|
|
loadPreviewFile(dataNode.source.path, relevantFilters, useFullData);
|
|
return;
|
|
}
|
|
if (dataNode.source.mode === "dataframe") {
|
|
loadPreviewPapilaDf(relevantFilters, useFullData);
|
|
return;
|
|
}
|
|
previewBody.innerHTML = "<div class='hint'>Upstream source is not a matrix file.</div>";
|
|
return;
|
|
}
|
|
previewBody.innerHTML = "<div class='hint'>Loader input type is not set.</div>";
|
|
}
|
|
|
|
async function loadPreviewDirectory(pathValue, filters = []) {
|
|
previewBody.innerHTML = "<div class='hint'>Loading…</div>";
|
|
try {
|
|
const data = await fetchDirectory(pathValue);
|
|
const list = document.createElement("div");
|
|
list.className = "preview-list";
|
|
const allImages = data.entries.filter((entry) =>
|
|
/\.(png|jpg|jpeg|tif|tiff)$/i.test(entry.name)
|
|
);
|
|
let files = allImages;
|
|
const { filtered, warnings } = applyRegexFilters(allImages, filters);
|
|
files = filtered;
|
|
files.slice(0, 200).forEach((entry) => {
|
|
const item = document.createElement("div");
|
|
item.className = "preview-item";
|
|
item.textContent = entry.name;
|
|
list.appendChild(item);
|
|
});
|
|
previewBody.innerHTML = "";
|
|
const warningEl = renderPreviewWarnings(warnings);
|
|
if (warningEl) previewBody.appendChild(warningEl);
|
|
const countLine = renderPreviewCount(
|
|
"Files",
|
|
files.length,
|
|
allImages.length,
|
|
files.length > 200
|
|
);
|
|
previewBody.appendChild(countLine);
|
|
if (!files.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "hint";
|
|
empty.textContent = "No image files matched the filter.";
|
|
previewBody.appendChild(empty);
|
|
return;
|
|
}
|
|
previewBody.appendChild(list);
|
|
} catch (err) {
|
|
previewBody.innerHTML = `<div class='hint'>${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadPreviewFile(pathValue, filters = [], limit = null) {
|
|
previewBody.innerHTML = "<div class='hint'>Loading…</div>";
|
|
try {
|
|
const data = await fetchFile(pathValue, limit);
|
|
if (!data || !data.header || !data.rows) {
|
|
previewBody.innerHTML = "<div class='hint'>No table data.</div>";
|
|
return;
|
|
}
|
|
const { filteredRows, warnings } = applyColumnFilters(data.header, data.rows, filters);
|
|
const totalRows = Number.isFinite(data.rows_total) ? data.rows_total : data.rows.length;
|
|
const isSampled = totalRows > data.rows.length;
|
|
const previewRows = filteredRows.slice(0, 200);
|
|
const table = document.createElement("table");
|
|
table.className = "preview-table";
|
|
const thead = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
data.header.forEach((col) => {
|
|
const th = document.createElement("th");
|
|
th.textContent = col;
|
|
headRow.appendChild(th);
|
|
});
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
const tbody = document.createElement("tbody");
|
|
previewRows.forEach((row) => {
|
|
const tr = document.createElement("tr");
|
|
row.forEach((cell) => {
|
|
const td = document.createElement("td");
|
|
td.textContent = cell;
|
|
tr.appendChild(td);
|
|
});
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
previewBody.innerHTML = "";
|
|
const warningEl = renderPreviewWarnings(warnings);
|
|
if (warningEl) previewBody.appendChild(warningEl);
|
|
const countLine = renderPreviewCount(
|
|
"Rows",
|
|
filteredRows.length,
|
|
totalRows,
|
|
filteredRows.length > 200,
|
|
isSampled ? "(preview sample)" : ""
|
|
);
|
|
previewBody.appendChild(countLine);
|
|
if (!filteredRows.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "hint";
|
|
empty.textContent = "No rows matched the filter.";
|
|
previewBody.appendChild(empty);
|
|
return;
|
|
}
|
|
previewBody.appendChild(table);
|
|
} catch (err) {
|
|
previewBody.innerHTML = `<div class='hint'>${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadPreviewPapilaDf(filters = [], limit = null) {
|
|
previewBody.innerHTML = "<div class='hint'>Loading…</div>";
|
|
try {
|
|
const limitParam = limit == null ? "" : `?limit=${encodeURIComponent(limit)}`;
|
|
const res = await fetch(`/api/papila/df${limitParam}`);
|
|
const parsed = await parseJsonResponse(res);
|
|
const data = parsed.data;
|
|
if (!res.ok || !data) {
|
|
throw new Error(data?.error || parsed.text || "Failed to load dataframe preview");
|
|
}
|
|
if (!data.header || !data.rows) {
|
|
previewBody.innerHTML = "<div class='hint'>No table data.</div>";
|
|
return;
|
|
}
|
|
const { filteredRows, warnings } = applyColumnFilters(data.header, data.rows, filters);
|
|
const totalRows = Number.isFinite(data.rows_total) ? data.rows_total : data.rows.length;
|
|
const isSampled = totalRows > data.rows.length;
|
|
const previewRows = filteredRows.slice(0, 200);
|
|
const table = document.createElement("table");
|
|
table.className = "preview-table";
|
|
const thead = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
data.header.forEach((col) => {
|
|
const th = document.createElement("th");
|
|
th.textContent = col;
|
|
headRow.appendChild(th);
|
|
});
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
const tbody = document.createElement("tbody");
|
|
previewRows.forEach((row) => {
|
|
const tr = document.createElement("tr");
|
|
row.forEach((cell) => {
|
|
const td = document.createElement("td");
|
|
td.textContent = cell;
|
|
tr.appendChild(td);
|
|
});
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
previewBody.innerHTML = "";
|
|
const warningEl = renderPreviewWarnings(warnings);
|
|
if (warningEl) previewBody.appendChild(warningEl);
|
|
const countLine = renderPreviewCount(
|
|
"Rows",
|
|
filteredRows.length,
|
|
totalRows,
|
|
filteredRows.length > 200,
|
|
isSampled ? "(preview sample)" : ""
|
|
);
|
|
previewBody.appendChild(countLine);
|
|
if (!filteredRows.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "hint";
|
|
empty.textContent = "No rows matched the filter.";
|
|
previewBody.appendChild(empty);
|
|
return;
|
|
}
|
|
previewBody.appendChild(table);
|
|
} catch (err) {
|
|
previewBody.innerHTML = `<div class='hint'>${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadDirectory(pathValue) {
|
|
modalList.innerHTML = "Loading…";
|
|
try {
|
|
const data = await fetchDirectory(pathValue);
|
|
browseState.path = data.path || "";
|
|
browseState.entries = data.entries || [];
|
|
browseState.selected = null;
|
|
renderBreadcrumb();
|
|
renderModalList();
|
|
} catch (err) {
|
|
modalList.innerHTML = `<div class="hint">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderBreadcrumb() {
|
|
breadcrumb.innerHTML = "";
|
|
const parts = browseState.path.split("/").filter(Boolean);
|
|
const root = document.createElement("span");
|
|
root.textContent = "repo root";
|
|
root.addEventListener("click", () => loadDirectory(""));
|
|
breadcrumb.appendChild(root);
|
|
let acc = "";
|
|
parts.forEach((part) => {
|
|
acc = acc ? `${acc}/${part}` : part;
|
|
const sep = document.createElement("span");
|
|
sep.textContent = " / ";
|
|
breadcrumb.appendChild(sep);
|
|
const crumb = document.createElement("span");
|
|
crumb.textContent = part;
|
|
crumb.addEventListener("click", () => loadDirectory(acc));
|
|
breadcrumb.appendChild(crumb);
|
|
});
|
|
}
|
|
|
|
function renderModalList() {
|
|
modalList.innerHTML = "";
|
|
browseState.entries.forEach((entry) => {
|
|
const item = document.createElement("div");
|
|
item.className = "modal-item";
|
|
item.innerHTML = `<span>${entry.name}</span><span class="muted">${entry.type}</span>`;
|
|
item.addEventListener("click", () => {
|
|
browseState.selected = entry;
|
|
document.querySelectorAll(".modal-item").forEach((el) => el.classList.remove("selected"));
|
|
item.classList.add("selected");
|
|
});
|
|
item.addEventListener("dblclick", () => {
|
|
if (entry.type === "dir") {
|
|
loadDirectory(entry.relPath);
|
|
} else {
|
|
browseState.selected = entry;
|
|
if (dataOutputType.value === "matrix") {
|
|
selectFileFromModal();
|
|
}
|
|
}
|
|
});
|
|
modalList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function selectDirectoryFromModal() {
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (!node) return;
|
|
if (!browseState.path) {
|
|
node.source = { mode: "directory", path: ".", count: countImages(browseState.entries) };
|
|
} else {
|
|
node.source = {
|
|
mode: "directory",
|
|
path: browseState.path,
|
|
count: countImages(browseState.entries),
|
|
};
|
|
}
|
|
updateDataSourceStatus(node);
|
|
closeModal();
|
|
render();
|
|
}
|
|
|
|
function selectFileFromModal() {
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (!node || !browseState.selected || browseState.selected.type !== "file") return;
|
|
node.source = {
|
|
mode: "file",
|
|
path: browseState.selected.relPath,
|
|
size: browseState.selected.size,
|
|
};
|
|
updateDataSourceStatus(node);
|
|
closeModal();
|
|
render();
|
|
}
|
|
|
|
function countImages(entries) {
|
|
return entries.filter((entry) => entry.type === "file" && /\.(png|jpg|jpeg|tif|tiff)$/i.test(entry.name))
|
|
.length;
|
|
}
|
|
|
|
function duplicateSelectedDataSource() {
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (!node || node.type !== "data") return;
|
|
node.outputType = dataOutputType.value || node.outputType;
|
|
const copy = {
|
|
...node,
|
|
id: `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
label: `${node.label} Copy`,
|
|
source: node.source ? { ...node.source } : null,
|
|
x: (node.x || 140) + 40,
|
|
y: (node.y || 120) + 120,
|
|
};
|
|
state.nodes.push(copy);
|
|
selectNode(copy.id);
|
|
render();
|
|
}
|
|
|
|
function loadCustomPresets() {
|
|
try {
|
|
const raw = localStorage.getItem(PRESET_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch (err) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveCustomPresets(presets) {
|
|
localStorage.setItem(PRESET_STORAGE_KEY, JSON.stringify(presets, null, 2));
|
|
}
|
|
|
|
function buildBuiltinPresets() {
|
|
return {
|
|
roi_center_jitter: {
|
|
label: "ROI → CenterCrop → Jitter",
|
|
nodes: [
|
|
{
|
|
id: "roi",
|
|
type: "transform",
|
|
label: "ROI Crop",
|
|
transformType: "roi_crop",
|
|
roiMaskSource: "gt",
|
|
roiScale: 2.5,
|
|
roiTargetSize: 224,
|
|
roiFallback: true,
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
{
|
|
id: "center",
|
|
type: "transform",
|
|
label: "Center Crop",
|
|
transformType: "center_crop",
|
|
centerCropSize: 224,
|
|
x: 0,
|
|
y: -120,
|
|
},
|
|
{
|
|
id: "jitter",
|
|
type: "transform",
|
|
label: "Jitter Bundle",
|
|
transformType: "jitter_bundle",
|
|
jitterHFlip: true,
|
|
jitterVFlip: true,
|
|
jitterRotation: 15,
|
|
jitterColorEnabled: true,
|
|
jitterColor: "0.1,0.1,0.1,0.05",
|
|
x: 0,
|
|
y: -240,
|
|
},
|
|
],
|
|
edges: [
|
|
{ from: "roi", to: "center" },
|
|
{ from: "center", to: "jitter" },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function refreshPresetSelect() {
|
|
const custom = loadCustomPresets();
|
|
const builtins = buildBuiltinPresets();
|
|
presetSelect.innerHTML = "";
|
|
const placeholder = document.createElement("option");
|
|
placeholder.value = "";
|
|
placeholder.textContent = "Select…";
|
|
presetSelect.appendChild(placeholder);
|
|
Object.entries(builtins).forEach(([key, preset]) => {
|
|
const option = document.createElement("option");
|
|
option.value = `builtin:${key}`;
|
|
option.textContent = preset.label || key;
|
|
presetSelect.appendChild(option);
|
|
});
|
|
Object.entries(custom).forEach(([key, preset]) => {
|
|
const option = document.createElement("option");
|
|
option.value = `custom:${key}`;
|
|
option.textContent = `Custom: ${preset.label || key}`;
|
|
presetSelect.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function buildPresetFromSelection(name) {
|
|
const selectedIds = Array.from(selectionState.nodeIds);
|
|
if (!selectedIds.length) return null;
|
|
const nodes = state.nodes.filter((n) => selectionState.nodeIds.has(n.id));
|
|
const edges = state.edges.filter(
|
|
(e) => selectionState.nodeIds.has(e.from) && selectionState.nodeIds.has(e.to)
|
|
);
|
|
const xs = nodes.map((n) => n.x ?? 0);
|
|
const ys = nodes.map((n) => n.y ?? 0);
|
|
const minX = Math.min(...xs);
|
|
const minY = Math.min(...ys);
|
|
const normalizedNodes = nodes.map((node) => {
|
|
const { id, ...rest } = node;
|
|
return {
|
|
id,
|
|
...rest,
|
|
x: (node.x ?? 0) - minX,
|
|
y: (node.y ?? 0) - minY,
|
|
};
|
|
});
|
|
return {
|
|
label: name,
|
|
nodes: normalizedNodes,
|
|
edges: edges.map((edge) => ({ ...edge })),
|
|
};
|
|
}
|
|
|
|
function applyPreset(preset) {
|
|
if (!preset || !preset.nodes || !preset.nodes.length) return;
|
|
const svgRect = svg.getBoundingClientRect();
|
|
const center = screenToWorld(
|
|
svgRect.left + svgRect.width / 2,
|
|
svgRect.top + svgRect.height / 2
|
|
);
|
|
const xs = preset.nodes.map((n) => n.x ?? 0);
|
|
const ys = preset.nodes.map((n) => n.y ?? 0);
|
|
const minX = Math.min(...xs);
|
|
const minY = Math.min(...ys);
|
|
const maxX = Math.max(...xs);
|
|
const maxY = Math.max(...ys);
|
|
const width = maxX - minX;
|
|
const height = maxY - minY;
|
|
const offsetX = center.x - width / 2 - minX;
|
|
const offsetY = center.y - height / 2 - minY;
|
|
|
|
const idMap = new Map();
|
|
const newNodes = preset.nodes.map((node, idx) => {
|
|
const base = createNodeTemplate(node.type || "transform");
|
|
const { id, x, y, ...rest } = node;
|
|
Object.assign(base, rest);
|
|
base.id = newNodeId();
|
|
base.label = node.label || base.label;
|
|
base.x = (x ?? idx * 80) + offsetX;
|
|
base.y = (y ?? idx * -80) + offsetY;
|
|
idMap.set(id ?? `idx_${idx}`, base.id);
|
|
return base;
|
|
});
|
|
const newEdges = (preset.edges || [])
|
|
.map((edge) => ({
|
|
from: idMap.get(edge.from),
|
|
to: idMap.get(edge.to),
|
|
}))
|
|
.filter((edge) => edge.from && edge.to);
|
|
state.nodes.push(...newNodes);
|
|
state.edges.push(...newEdges);
|
|
setSelection(newNodes.map((n) => n.id));
|
|
if (newNodes.length === 1) {
|
|
selectNode(newNodes[0].id);
|
|
} else {
|
|
state.selectedId = null;
|
|
clearSelectionUI();
|
|
}
|
|
render();
|
|
}
|
|
|
|
function setupPanZoom() {
|
|
let dragging = false;
|
|
let last = { x: 0, y: 0 };
|
|
svg.addEventListener("mousedown", (e) => {
|
|
if (e.target.closest(".node")) return;
|
|
if (e.shiftKey) {
|
|
const world = screenToWorld(e.clientX, e.clientY);
|
|
selectionBox = { x1: world.x, y1: world.y, x2: world.x, y2: world.y };
|
|
render();
|
|
return;
|
|
}
|
|
dragging = true;
|
|
last = { x: e.clientX, y: e.clientY };
|
|
});
|
|
window.addEventListener("mousemove", (e) => {
|
|
if (selectionBox) {
|
|
const world = screenToWorld(e.clientX, e.clientY);
|
|
selectionBox.x2 = world.x;
|
|
selectionBox.y2 = world.y;
|
|
render();
|
|
return;
|
|
}
|
|
if (state.draggingNodeId) {
|
|
const node = state.nodes.find((n) => n.id === state.draggingNodeId);
|
|
if (!node || !state.draggingOffset) return;
|
|
const world = screenToWorld(e.clientX, e.clientY);
|
|
node.x = world.x - state.draggingOffset.x;
|
|
node.y = world.y - state.draggingOffset.y;
|
|
if (!state.draggingMoved) {
|
|
state.draggingMoved = true;
|
|
}
|
|
render();
|
|
return;
|
|
}
|
|
if (!dragging) return;
|
|
const dx = e.clientX - last.x;
|
|
const dy = e.clientY - last.y;
|
|
state.pan.x += dx;
|
|
state.pan.y += dy;
|
|
last = { x: e.clientX, y: e.clientY };
|
|
render();
|
|
});
|
|
window.addEventListener("mouseup", () => {
|
|
dragging = false;
|
|
if (state.draggingNodeId) {
|
|
state.draggingJustEnded = state.draggingMoved;
|
|
state.draggingNodeId = null;
|
|
state.draggingOffset = null;
|
|
state.draggingMoved = false;
|
|
}
|
|
if (selectionBox) {
|
|
const minX = Math.min(selectionBox.x1, selectionBox.x2);
|
|
const maxX = Math.max(selectionBox.x1, selectionBox.x2);
|
|
const minY = Math.min(selectionBox.y1, selectionBox.y2);
|
|
const maxY = Math.max(selectionBox.y1, selectionBox.y2);
|
|
const selected = state.nodes
|
|
.filter((node) => node.x >= minX && node.x <= maxX && node.y >= minY && node.y <= maxY)
|
|
.map((node) => node.id);
|
|
setSelection(selected);
|
|
if (selected.length === 1) {
|
|
selectNode(selected[0]);
|
|
} else {
|
|
state.selectedId = null;
|
|
clearSelectionUI();
|
|
}
|
|
selectionBox = null;
|
|
render();
|
|
}
|
|
});
|
|
svg.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY < 0 ? 1.05 : 0.95;
|
|
state.pan.scale = Math.max(0.5, Math.min(2.0, state.pan.scale * delta));
|
|
render();
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll("[data-add]").forEach((btn) => {
|
|
btn.addEventListener("click", () => addNode(btn.dataset.add));
|
|
});
|
|
|
|
document.getElementById("apply-node").addEventListener("click", applyNodeEdits);
|
|
document.getElementById("delete-node").addEventListener("click", deleteSelected);
|
|
document.getElementById("connect-mode").addEventListener("click", toggleConnectMode);
|
|
document.getElementById("disconnect-selected").addEventListener("click", removeSelectedEdges);
|
|
document.getElementById("auto-layout").addEventListener("click", () => {
|
|
autoLayout();
|
|
render();
|
|
});
|
|
document.getElementById("reset-graph").addEventListener("click", resetGraph);
|
|
document.getElementById("save-local").addEventListener("click", saveLocal);
|
|
document.getElementById("load-local").addEventListener("click", loadLocal);
|
|
document.getElementById("export-json").addEventListener("click", exportJson);
|
|
document.getElementById("import-json").addEventListener("change", (e) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
importJson(e.target.files[0]);
|
|
}
|
|
});
|
|
importClassBtn.addEventListener("click", importClassDefinition);
|
|
presetApplyBtn.addEventListener("click", () => {
|
|
const value = presetSelect.value;
|
|
if (!value) return;
|
|
const [kind, key] = value.split(":");
|
|
if (kind === "builtin") {
|
|
const preset = buildBuiltinPresets()[key];
|
|
applyPreset(preset);
|
|
return;
|
|
}
|
|
if (kind === "custom") {
|
|
const custom = loadCustomPresets();
|
|
const preset = custom[key];
|
|
applyPreset(preset);
|
|
}
|
|
});
|
|
presetSaveBtn.addEventListener("click", () => {
|
|
if (!selectionState.nodeIds.size) {
|
|
alert("Select nodes to save as a preset (Shift + drag on the graph).");
|
|
return;
|
|
}
|
|
const name = window.prompt("Preset name:");
|
|
if (!name) return;
|
|
const preset = buildPresetFromSelection(name);
|
|
if (!preset) return;
|
|
const custom = loadCustomPresets();
|
|
custom[name] = preset;
|
|
saveCustomPresets(custom);
|
|
refreshPresetSelect();
|
|
presetSelect.value = `custom:${name}`;
|
|
});
|
|
|
|
loaderInputType.addEventListener("change", () => {
|
|
loaderOutputType.value = loaderInputType.value;
|
|
applyNodeEdits();
|
|
});
|
|
|
|
loaderInputIndex.addEventListener("change", applyNodeEdits);
|
|
|
|
filterInputType.addEventListener("change", () => {
|
|
filterOutputType.value = filterInputType.value;
|
|
applyNodeEdits();
|
|
});
|
|
|
|
filterType.addEventListener("change", () => {
|
|
updateFilterFieldVisibility(filterType.value);
|
|
applyNodeEdits();
|
|
});
|
|
|
|
filterRegexPattern.addEventListener("change", applyNodeEdits);
|
|
filterColumnName.addEventListener("change", applyNodeEdits);
|
|
filterColumnOperator.addEventListener("change", applyNodeEdits);
|
|
filterColumnValue.addEventListener("change", applyNodeEdits);
|
|
|
|
transformType.addEventListener("change", () => {
|
|
updateTransformFieldVisibility(transformType.value);
|
|
applyNodeEdits();
|
|
});
|
|
transformRoiMaskSource.addEventListener("change", applyNodeEdits);
|
|
transformRoiScale.addEventListener("change", applyNodeEdits);
|
|
transformRoiTarget.addEventListener("change", applyNodeEdits);
|
|
transformRoiFallback.addEventListener("change", applyNodeEdits);
|
|
transformCenterSize.addEventListener("change", applyNodeEdits);
|
|
transformJitterHFlip.addEventListener("change", applyNodeEdits);
|
|
transformJitterVFlip.addEventListener("change", applyNodeEdits);
|
|
transformJitterRotation.addEventListener("change", applyNodeEdits);
|
|
transformJitterColorEnabled.addEventListener("change", applyNodeEdits);
|
|
transformJitterColor.addEventListener("change", applyNodeEdits);
|
|
transformResizeSize.addEventListener("change", applyNodeEdits);
|
|
|
|
towerType.addEventListener("change", () => {
|
|
updateTowerFieldVisibility(towerType.value);
|
|
applyNodeEdits();
|
|
});
|
|
towerBackbone.addEventListener("change", applyNodeEdits);
|
|
towerFreezeRatio.addEventListener("change", applyNodeEdits);
|
|
towerAugment.addEventListener("change", applyNodeEdits);
|
|
towerGeometryDim.addEventListener("change", applyNodeEdits);
|
|
towerUseSe.addEventListener("change", applyNodeEdits);
|
|
towerSeReduction.addEventListener("change", applyNodeEdits);
|
|
towerSePreNorm.addEventListener("change", applyNodeEdits);
|
|
towerMdHidden.addEventListener("change", applyNodeEdits);
|
|
towerMdDropout.addEventListener("change", applyNodeEdits);
|
|
towerMdUseSe.addEventListener("change", applyNodeEdits);
|
|
towerMdSeReduction.addEventListener("change", applyNodeEdits);
|
|
towerMdSePreNorm.addEventListener("change", applyNodeEdits);
|
|
towerMdFreezeRatio.addEventListener("change", applyNodeEdits);
|
|
|
|
bridgeMethod.addEventListener("change", () => {
|
|
updateBridgeFieldVisibility(bridgeMethod.value);
|
|
applyNodeEdits();
|
|
});
|
|
bridgeFusionDim.addEventListener("change", applyNodeEdits);
|
|
bridgeUseSe.addEventListener("change", applyNodeEdits);
|
|
bridgeSeReduction.addEventListener("change", applyNodeEdits);
|
|
bridgeSePreNorm.addEventListener("change", applyNodeEdits);
|
|
|
|
dataOutputType.addEventListener("change", () => {
|
|
const node = state.nodes.find((n) => n.id === state.selectedId);
|
|
if (node) {
|
|
node.outputType = dataOutputType.value;
|
|
node.source = null;
|
|
}
|
|
updateDataBrowseVisibility(dataOutputType.value);
|
|
updateDataSourceStatus(node);
|
|
render();
|
|
});
|
|
|
|
dataBrowseButton.addEventListener("click", () => {
|
|
if (dataOutputType.value) {
|
|
modalMode.textContent =
|
|
dataOutputType.value === "image"
|
|
? "Select an image directory."
|
|
: "Select a CSV/TSV/TXT file.";
|
|
modalSelectDir.disabled = dataOutputType.value !== "image";
|
|
modalSelectFile.disabled = dataOutputType.value !== "matrix";
|
|
openModal();
|
|
}
|
|
});
|
|
|
|
duplicateDataButton.addEventListener("click", duplicateSelectedDataSource);
|
|
|
|
modalClose.addEventListener("click", closeModal);
|
|
modalSelectDir.addEventListener("click", selectDirectoryFromModal);
|
|
modalSelectFile.addEventListener("click", selectFileFromModal);
|
|
|
|
modalOverlay.addEventListener("click", (e) => {
|
|
if (e.target === modalOverlay) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
previewClose.addEventListener("click", () => {
|
|
previewOverlay.classList.add("hidden");
|
|
});
|
|
|
|
previewOverlay.addEventListener("click", (e) => {
|
|
if (e.target === previewOverlay) {
|
|
previewOverlay.classList.add("hidden");
|
|
}
|
|
});
|
|
|
|
toggleJsonButton.addEventListener("click", () => {
|
|
jsonView.classList.toggle("collapsed");
|
|
toggleJsonButton.textContent = jsonView.classList.contains("collapsed")
|
|
? "Expand"
|
|
: "Collapse";
|
|
});
|
|
|
|
setupPanZoom();
|
|
resetGraph();
|
|
refreshPresetSelect();
|