Add graph and vector search features
This commit is contained in:
670
static/graph.js
Normal file
670
static/graph.js
Normal file
@@ -0,0 +1,670 @@
|
||||
(() => {
|
||||
const global = window;
|
||||
const GraphUI = (global.GraphUI = global.GraphUI || {});
|
||||
GraphUI.ready = false;
|
||||
const form = document.getElementById("graphForm");
|
||||
const videoInput = document.getElementById("graphVideoId");
|
||||
const depthInput = document.getElementById("graphDepth");
|
||||
const maxNodesInput = document.getElementById("graphMaxNodes");
|
||||
const labelSizeInput = document.getElementById("graphLabelSize");
|
||||
const statusEl = document.getElementById("graphStatus");
|
||||
const container = document.getElementById("graphContainer");
|
||||
const isEmbedded =
|
||||
container && container.dataset && container.dataset.embedded === "true";
|
||||
|
||||
if (!form || !videoInput || !depthInput || !maxNodesInput || !labelSizeInput || !container) {
|
||||
console.error("Graph: required DOM elements missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const color = d3.scaleOrdinal(d3.schemeTableau10);
|
||||
const colorRange = typeof color.range === "function" ? color.range() : [];
|
||||
const paletteSizeDefault = colorRange.length || 10;
|
||||
const PATTERN_TYPES = [
|
||||
{ key: "none", legendClass: "none" },
|
||||
{ key: "diag-forward", legendClass: "diag-forward" },
|
||||
{ key: "diag-back", legendClass: "diag-back" },
|
||||
{ key: "cross", legendClass: "cross" },
|
||||
{ key: "dots", legendClass: "dots" },
|
||||
];
|
||||
const ADDITIONAL_PATTERNS = PATTERN_TYPES.filter((pattern) => pattern.key !== "none");
|
||||
|
||||
const sanitizeDepth = (value) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) return 1;
|
||||
return Math.max(0, Math.min(parsed, 3));
|
||||
};
|
||||
|
||||
const sanitizeMaxNodes = (value) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) return 200;
|
||||
return Math.max(10, Math.min(parsed, 400));
|
||||
};
|
||||
|
||||
const LABEL_SIZE_VALUES = ["off", "tiny", "small", "normal", "medium", "large", "xlarge"];
|
||||
const LABEL_FONT_SIZES = {
|
||||
tiny: "7px",
|
||||
small: "8px",
|
||||
normal: "9px",
|
||||
medium: "10px",
|
||||
large: "11px",
|
||||
xlarge: "13px",
|
||||
};
|
||||
const DEFAULT_LABEL_SIZE = "tiny";
|
||||
const isValidLabelSize = (value) => LABEL_SIZE_VALUES.includes(value);
|
||||
|
||||
const getLabelSize = () => {
|
||||
if (!labelSizeInput) return DEFAULT_LABEL_SIZE;
|
||||
const value = labelSizeInput.value;
|
||||
return isValidLabelSize(value) ? value : DEFAULT_LABEL_SIZE;
|
||||
};
|
||||
|
||||
function setLabelSizeInput(value) {
|
||||
if (!labelSizeInput) return;
|
||||
labelSizeInput.value = isValidLabelSize(value) ? value : DEFAULT_LABEL_SIZE;
|
||||
}
|
||||
|
||||
const getChannelLabel = (node) =>
|
||||
(node && (node.channel_name || node.channel_id)) || "Unknown";
|
||||
|
||||
function appendPatternContent(pattern, baseColor, patternKey) {
|
||||
pattern.append("rect").attr("width", 8).attr("height", 8).attr("fill", baseColor);
|
||||
|
||||
const strokeColor = "#1f1f1f";
|
||||
const strokeOpacity = 0.35;
|
||||
|
||||
const addForward = () => {
|
||||
pattern
|
||||
.append("path")
|
||||
.attr("d", "M-2,6 L2,2 M0,8 L8,0 M6,10 L10,4")
|
||||
.attr("stroke", strokeColor)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-opacity", strokeOpacity)
|
||||
.attr("fill", "none");
|
||||
};
|
||||
|
||||
const addBackward = () => {
|
||||
pattern
|
||||
.append("path")
|
||||
.attr("d", "M-2,2 L2,6 M0,0 L8,8 M6,-2 L10,2")
|
||||
.attr("stroke", strokeColor)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-opacity", strokeOpacity)
|
||||
.attr("fill", "none");
|
||||
};
|
||||
|
||||
switch (patternKey) {
|
||||
case "diag-forward":
|
||||
addForward();
|
||||
break;
|
||||
case "diag-back":
|
||||
addBackward();
|
||||
break;
|
||||
case "cross":
|
||||
addForward();
|
||||
addBackward();
|
||||
break;
|
||||
case "dots":
|
||||
pattern
|
||||
.append("circle")
|
||||
.attr("cx", 4)
|
||||
.attr("cy", 4)
|
||||
.attr("r", 1.5)
|
||||
.attr("fill", strokeColor)
|
||||
.attr("fill-opacity", strokeOpacity);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function createChannelStyle(label, baseColor, patternKey) {
|
||||
const patternInfo =
|
||||
PATTERN_TYPES.find((pattern) => pattern.key === patternKey) || PATTERN_TYPES[0];
|
||||
return {
|
||||
baseColor,
|
||||
hatch: patternInfo ? patternInfo.key : "none",
|
||||
legendClass: patternInfo ? patternInfo.legendClass : "none",
|
||||
};
|
||||
}
|
||||
|
||||
let currentGraphData = null;
|
||||
let currentChannelStyles = new Map();
|
||||
let currentDepth = sanitizeDepth(depthInput.value);
|
||||
let currentMaxNodes = sanitizeMaxNodes(maxNodesInput.value);
|
||||
let currentSimulation = null;
|
||||
|
||||
function setStatus(message, isError = false) {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = message;
|
||||
if (isError) {
|
||||
statusEl.classList.add("error");
|
||||
} else {
|
||||
statusEl.classList.remove("error");
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return (value || "").trim();
|
||||
}
|
||||
|
||||
async function fetchGraph(videoId, depth, maxNodes) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("video_id", videoId);
|
||||
params.set("depth", String(depth));
|
||||
params.set("max_nodes", String(maxNodes));
|
||||
const response = await fetch(`/api/graph?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
errorPayload.error ||
|
||||
`Graph request failed (${response.status} ${response.statusText})`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function resizeContainer() {
|
||||
if (!container) return;
|
||||
const minHeight = 520;
|
||||
const viewportHeight = window.innerHeight;
|
||||
container.style.height = `${Math.max(minHeight, Math.round(viewportHeight * 0.6))}px`;
|
||||
}
|
||||
|
||||
function renderGraph(data, labelSize = "normal") {
|
||||
if (!container) return;
|
||||
|
||||
if (currentSimulation) {
|
||||
currentSimulation.stop();
|
||||
currentSimulation = null;
|
||||
}
|
||||
container.innerHTML = "";
|
||||
|
||||
const width = container.clientWidth || 900;
|
||||
const height = container.clientHeight || 600;
|
||||
|
||||
const svg = d3
|
||||
.select(container)
|
||||
.append("svg")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.attr("width", "100%")
|
||||
.attr("height", height);
|
||||
|
||||
const defs = svg.append("defs");
|
||||
|
||||
defs
|
||||
.append("marker")
|
||||
.attr("id", "arrow-references")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 18)
|
||||
.attr("refY", 0)
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#6c83c7");
|
||||
|
||||
defs
|
||||
.append("marker")
|
||||
.attr("id", "arrow-referenced-by")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 18)
|
||||
.attr("refY", 0)
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#c76c6c");
|
||||
|
||||
const contentGroup = svg.append("g").attr("class", "graph-content");
|
||||
const linkGroup = contentGroup.append("g").attr("class", "graph-links");
|
||||
const nodeGroup = contentGroup.append("g").attr("class", "graph-nodes");
|
||||
const labelGroup = contentGroup.append("g").attr("class", "graph-labels");
|
||||
|
||||
const links = data.links || [];
|
||||
const nodes = data.nodes || [];
|
||||
|
||||
currentChannelStyles = new Map();
|
||||
const uniqueChannels = [];
|
||||
nodes.forEach((node) => {
|
||||
const label = getChannelLabel(node);
|
||||
if (!currentChannelStyles.has(label)) {
|
||||
uniqueChannels.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
const additionalPatternCount = ADDITIONAL_PATTERNS.length;
|
||||
uniqueChannels.forEach((label, idx) => {
|
||||
const baseColor = color(label);
|
||||
let patternKey = "none";
|
||||
if (idx >= paletteSizeDefault && additionalPatternCount > 0) {
|
||||
const patternInfo =
|
||||
ADDITIONAL_PATTERNS[(idx - paletteSizeDefault) % additionalPatternCount];
|
||||
patternKey = patternInfo.key;
|
||||
}
|
||||
const style = createChannelStyle(label, baseColor, patternKey);
|
||||
currentChannelStyles.set(label, style);
|
||||
});
|
||||
|
||||
const linkSelection = linkGroup
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke-width", 1.2)
|
||||
.attr("stroke", (d) =>
|
||||
d.relation === "references" ? "#6c83c7" : "#c76c6c"
|
||||
)
|
||||
.attr("stroke-opacity", 0.7)
|
||||
.attr("marker-end", (d) =>
|
||||
d.relation === "references" ? "url(#arrow-references)" : "url(#arrow-referenced-by)"
|
||||
);
|
||||
|
||||
let nodePatternCounter = 0;
|
||||
const nodePatternRefs = new Map();
|
||||
|
||||
const getNodeFill = (node) => {
|
||||
const style = currentChannelStyles.get(getChannelLabel(node));
|
||||
if (!style) {
|
||||
return color(getChannelLabel(node));
|
||||
}
|
||||
if (!style.hatch || style.hatch === "none") {
|
||||
return style.baseColor;
|
||||
}
|
||||
const patternId = `node-pattern-${nodePatternCounter++}`;
|
||||
const pattern = defs
|
||||
.append("pattern")
|
||||
.attr("id", patternId)
|
||||
.attr("patternUnits", "userSpaceOnUse")
|
||||
.attr("width", 8)
|
||||
.attr("height", 8);
|
||||
appendPatternContent(pattern, style.baseColor, style.hatch);
|
||||
pattern.attr("patternTransform", "translate(0,0)");
|
||||
nodePatternRefs.set(node.id, pattern);
|
||||
return `url(#${patternId})`;
|
||||
};
|
||||
|
||||
const nodeSelection = nodeGroup
|
||||
.selectAll("circle")
|
||||
.data(nodes, (d) => d.id)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("r", (d) => (d.is_root ? 10 : 7))
|
||||
.attr("fill", (d) => getNodeFill(d))
|
||||
.attr("stroke", "#1f1f1f")
|
||||
.attr("stroke-width", (d) => (d.is_root ? 2 : 1))
|
||||
.call(
|
||||
d3
|
||||
.drag()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
})
|
||||
)
|
||||
.on("click", (event, d) => {
|
||||
if (d.url) {
|
||||
window.open(d.url, "_blank", "noopener");
|
||||
}
|
||||
})
|
||||
.on("contextmenu", (event, d) => {
|
||||
event.preventDefault();
|
||||
loadGraph(d.id, currentDepth, currentMaxNodes, { updateInputs: true });
|
||||
});
|
||||
|
||||
nodeSelection
|
||||
.append("title")
|
||||
.text((d) => {
|
||||
const parts = [];
|
||||
parts.push(d.title || d.id);
|
||||
if (d.channel_name) {
|
||||
parts.push(`Channel: ${d.channel_name}`);
|
||||
}
|
||||
if (d.date) {
|
||||
parts.push(`Date: ${d.date}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
});
|
||||
|
||||
const labelSelection = labelGroup
|
||||
.selectAll("text")
|
||||
.data(nodes, (d) => d.id)
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("class", "graph-node-label")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#1f1f1f")
|
||||
.attr("pointer-events", "none")
|
||||
.text((d) => d.title || d.id);
|
||||
|
||||
applyLabelAppearance(labelSelection, labelSize);
|
||||
|
||||
const simulation = d3
|
||||
.forceSimulation(nodes)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink(links)
|
||||
.id((d) => d.id)
|
||||
.distance(120)
|
||||
.strength(0.8)
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(-320))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force(
|
||||
"collide",
|
||||
d3.forceCollide().radius((d) => (d.is_root ? 20 : 14)).iterations(2)
|
||||
);
|
||||
|
||||
simulation.on("tick", () => {
|
||||
linkSelection
|
||||
.attr("x1", (d) => d.source.x)
|
||||
.attr("y1", (d) => d.source.y)
|
||||
.attr("x2", (d) => d.target.x)
|
||||
.attr("y2", (d) => d.target.y);
|
||||
|
||||
nodeSelection.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
|
||||
|
||||
labelSelection.attr("x", (d) => d.x).attr("y", (d) => d.y - (d.is_root ? 14 : 12));
|
||||
|
||||
nodeSelection.each(function (d) {
|
||||
const pattern = nodePatternRefs.get(d.id);
|
||||
if (pattern) {
|
||||
const safeX = Number.isFinite(d.x) ? d.x : 0;
|
||||
const safeY = Number.isFinite(d.y) ? d.y : 0;
|
||||
pattern.attr("patternTransform", `translate(${safeX}, ${safeY})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const zoomBehavior = d3
|
||||
.zoom()
|
||||
.scaleExtent([0.3, 3])
|
||||
.on("zoom", (event) => {
|
||||
contentGroup.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoomBehavior);
|
||||
currentSimulation = simulation;
|
||||
}
|
||||
|
||||
async function loadGraph(videoId, depth, maxNodes, { updateInputs = false } = {}) {
|
||||
const sanitizedId = sanitizeId(videoId);
|
||||
if (!sanitizedId) {
|
||||
setStatus("Please enter a video ID.", true);
|
||||
return;
|
||||
}
|
||||
const safeDepth = sanitizeDepth(depth);
|
||||
const safeMaxNodes = sanitizeMaxNodes(maxNodes);
|
||||
|
||||
if (updateInputs) {
|
||||
videoInput.value = sanitizedId;
|
||||
depthInput.value = String(safeDepth);
|
||||
maxNodesInput.value = String(safeMaxNodes);
|
||||
}
|
||||
|
||||
setStatus("Loading graph…");
|
||||
try {
|
||||
const data = await fetchGraph(sanitizedId, safeDepth, safeMaxNodes);
|
||||
if (!data.nodes || data.nodes.length === 0) {
|
||||
setStatus("No nodes returned for this video.", true);
|
||||
container.innerHTML = "";
|
||||
currentGraphData = null;
|
||||
currentChannelStyles = new Map();
|
||||
renderLegend([]);
|
||||
return;
|
||||
}
|
||||
currentGraphData = data;
|
||||
currentDepth = safeDepth;
|
||||
currentMaxNodes = safeMaxNodes;
|
||||
renderGraph(data, getLabelSize());
|
||||
renderLegend(data.nodes);
|
||||
setStatus(
|
||||
`Showing ${data.nodes.length} nodes and ${data.links.length} links (depth ${data.depth})`
|
||||
);
|
||||
updateUrlState(sanitizedId, safeDepth, safeMaxNodes, getLabelSize());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus(err.message || "Failed to build graph.", true);
|
||||
container.innerHTML = "";
|
||||
currentGraphData = null;
|
||||
currentChannelStyles = new Map();
|
||||
renderLegend([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
await loadGraph(videoInput.value, depthInput.value, maxNodesInput.value, {
|
||||
updateInputs: true,
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend(nodes) {
|
||||
let legend = document.getElementById("graphLegend");
|
||||
if (!legend) {
|
||||
legend = document.createElement("div");
|
||||
legend.id = "graphLegend";
|
||||
legend.className = "graph-legend";
|
||||
if (statusEl && statusEl.parentNode) {
|
||||
statusEl.insertAdjacentElement("afterend", legend);
|
||||
} else {
|
||||
container.parentElement?.insertBefore(legend, container);
|
||||
}
|
||||
}
|
||||
|
||||
legend.innerHTML = "";
|
||||
|
||||
const edgesSection = document.createElement("div");
|
||||
edgesSection.className = "graph-legend-section";
|
||||
|
||||
const edgesTitle = document.createElement("div");
|
||||
edgesTitle.className = "graph-legend-title";
|
||||
edgesTitle.textContent = "Edges";
|
||||
edgesSection.appendChild(edgesTitle);
|
||||
|
||||
const createEdgeRow = (swatchClass, text) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "graph-legend-row";
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = `graph-legend-swatch ${swatchClass}`;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = text;
|
||||
row.appendChild(swatch);
|
||||
row.appendChild(label);
|
||||
return row;
|
||||
};
|
||||
|
||||
edgesSection.appendChild(
|
||||
createEdgeRow(
|
||||
"graph-legend-swatch--references",
|
||||
"Outgoing reference (video references other)"
|
||||
)
|
||||
);
|
||||
edgesSection.appendChild(
|
||||
createEdgeRow(
|
||||
"graph-legend-swatch--referenced",
|
||||
"Incoming reference (other video references this)"
|
||||
)
|
||||
);
|
||||
legend.appendChild(edgesSection);
|
||||
|
||||
const channelSection = document.createElement("div");
|
||||
channelSection.className = "graph-legend-section";
|
||||
const channelTitle = document.createElement("div");
|
||||
channelTitle.className = "graph-legend-title";
|
||||
channelTitle.textContent = "Channels in view";
|
||||
channelSection.appendChild(channelTitle);
|
||||
|
||||
const channelList = document.createElement("div");
|
||||
channelList.className = "graph-legend-channel-list";
|
||||
|
||||
const channelEntries = Array.from(currentChannelStyles.entries()).sort((a, b) =>
|
||||
a[0].localeCompare(b[0], undefined, { sensitivity: "base" })
|
||||
);
|
||||
const maxChannelItems = 20;
|
||||
|
||||
channelEntries.slice(0, maxChannelItems).forEach(([label, style]) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = `graph-legend-channel graph-legend-channel--${
|
||||
style.legendClass || "none"
|
||||
}`;
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "graph-legend-swatch graph-legend-channel-swatch";
|
||||
swatch.style.backgroundColor = style.baseColor;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = label;
|
||||
item.appendChild(swatch);
|
||||
item.appendChild(text);
|
||||
channelList.appendChild(item);
|
||||
});
|
||||
|
||||
const totalChannels = channelEntries.length;
|
||||
if (channelList.childElementCount) {
|
||||
channelSection.appendChild(channelList);
|
||||
if (totalChannels > maxChannelItems) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "graph-legend-note";
|
||||
note.textContent = `+${totalChannels - maxChannelItems} more channels`;
|
||||
channelSection.appendChild(note);
|
||||
}
|
||||
} else {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "graph-legend-note";
|
||||
empty.textContent = "No channel data available.";
|
||||
channelSection.appendChild(empty);
|
||||
}
|
||||
|
||||
legend.appendChild(channelSection);
|
||||
}
|
||||
|
||||
function applyLabelAppearance(selection, labelSize) {
|
||||
if (labelSize === "off") {
|
||||
selection.style("display", "none");
|
||||
} else {
|
||||
selection
|
||||
.style("display", null)
|
||||
.attr("font-size", LABEL_FONT_SIZES[labelSize] || LABEL_FONT_SIZES.normal);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlState(videoId, depth, maxNodes, labelSize) {
|
||||
if (isEmbedded) {
|
||||
return;
|
||||
}
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set("video_id", videoId);
|
||||
next.searchParams.set("depth", String(depth));
|
||||
next.searchParams.set("max_nodes", String(maxNodes));
|
||||
if (labelSize && labelSize !== "normal") {
|
||||
next.searchParams.set("label_size", labelSize);
|
||||
} else {
|
||||
next.searchParams.delete("label_size");
|
||||
}
|
||||
history.replaceState({}, "", next.toString());
|
||||
}
|
||||
|
||||
function initFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const videoId = sanitizeId(params.get("video_id"));
|
||||
const depth = sanitizeDepth(params.get("depth") || "");
|
||||
const maxNodes = sanitizeMaxNodes(params.get("max_nodes") || "");
|
||||
const labelSizeParam = params.get("label_size");
|
||||
if (videoId) {
|
||||
videoInput.value = videoId;
|
||||
}
|
||||
depthInput.value = String(depth);
|
||||
maxNodesInput.value = String(maxNodes);
|
||||
if (labelSizeParam && isValidLabelSize(labelSizeParam)) {
|
||||
setLabelSizeInput(labelSizeParam);
|
||||
} else {
|
||||
setLabelSizeInput(getLabelSize());
|
||||
}
|
||||
if (!videoId || isEmbedded) {
|
||||
return;
|
||||
}
|
||||
loadGraph(videoId, depth, maxNodes, { updateInputs: false });
|
||||
}
|
||||
|
||||
resizeContainer();
|
||||
window.addEventListener("resize", resizeContainer);
|
||||
form.addEventListener("submit", handleSubmit);
|
||||
labelSizeInput.addEventListener("change", () => {
|
||||
const size = getLabelSize();
|
||||
if (currentGraphData) {
|
||||
renderGraph(currentGraphData, size);
|
||||
renderLegend(currentGraphData.nodes);
|
||||
}
|
||||
updateUrlState(
|
||||
sanitizeId(videoInput.value),
|
||||
currentDepth,
|
||||
currentMaxNodes,
|
||||
size
|
||||
);
|
||||
});
|
||||
initFromQuery();
|
||||
|
||||
Object.assign(GraphUI, {
|
||||
load(videoId, depth, maxNodes, options = {}) {
|
||||
const targetDepth = depth != null ? depth : currentDepth;
|
||||
const targetMax = maxNodes != null ? maxNodes : currentMaxNodes;
|
||||
return loadGraph(videoId, targetDepth, targetMax, {
|
||||
updateInputs: options.updateInputs !== false,
|
||||
});
|
||||
},
|
||||
setLabelSize(size) {
|
||||
if (!labelSizeInput || !size) return;
|
||||
setLabelSizeInput(size);
|
||||
labelSizeInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
},
|
||||
setDepth(value) {
|
||||
if (!depthInput) return;
|
||||
const safe = sanitizeDepth(value);
|
||||
depthInput.value = String(safe);
|
||||
currentDepth = safe;
|
||||
},
|
||||
setMaxNodes(value) {
|
||||
if (!maxNodesInput) return;
|
||||
const safe = sanitizeMaxNodes(value);
|
||||
maxNodesInput.value = String(safe);
|
||||
currentMaxNodes = safe;
|
||||
},
|
||||
focusInput() {
|
||||
if (videoInput) {
|
||||
videoInput.focus();
|
||||
videoInput.select();
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
if (currentSimulation) {
|
||||
currentSimulation.stop();
|
||||
currentSimulation = null;
|
||||
}
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
depth: currentDepth,
|
||||
maxNodes: currentMaxNodes,
|
||||
labelSize: getLabelSize(),
|
||||
nodes: currentGraphData ? currentGraphData.nodes.slice() : [],
|
||||
links: currentGraphData ? currentGraphData.links.slice() : [],
|
||||
};
|
||||
},
|
||||
isEmbedded,
|
||||
});
|
||||
GraphUI.ready = true;
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("graph-ui-ready"));
|
||||
}, 0);
|
||||
})();
|
||||
Reference in New Issue
Block a user