771 lines
23 KiB
JavaScript
771 lines
23 KiB
JavaScript
(() => {
|
|
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 fullGraphToggle = document.getElementById("graphFullToggle");
|
|
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;
|
|
let currentFullGraph = false;
|
|
|
|
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();
|
|
}
|
|
|
|
function isFullGraphMode(forceValue) {
|
|
if (typeof forceValue === "boolean") {
|
|
return forceValue;
|
|
}
|
|
return fullGraphToggle ? !!fullGraphToggle.checked : false;
|
|
}
|
|
|
|
function applyFullGraphState(forceValue) {
|
|
const enabled = isFullGraphMode(forceValue);
|
|
if (typeof forceValue === "boolean" && fullGraphToggle) {
|
|
fullGraphToggle.checked = forceValue;
|
|
}
|
|
if (depthInput) {
|
|
depthInput.disabled = enabled;
|
|
}
|
|
if (videoInput) {
|
|
if (enabled) {
|
|
videoInput.removeAttribute("required");
|
|
} else {
|
|
videoInput.setAttribute("required", "required");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchGraph(videoId, depth, maxNodes, fullGraphMode = false) {
|
|
const params = new URLSearchParams();
|
|
if (videoId) {
|
|
params.set("video_id", videoId);
|
|
}
|
|
if (fullGraphMode) {
|
|
params.set("full_graph", "1");
|
|
} else {
|
|
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, fullGraph } = {}
|
|
) {
|
|
const wantsFull = isFullGraphMode(
|
|
typeof fullGraph === "boolean" ? fullGraph : undefined
|
|
);
|
|
const sanitizedId = sanitizeId(videoId);
|
|
if (!wantsFull && !sanitizedId) {
|
|
setStatus("Please enter a video ID.", true);
|
|
return;
|
|
}
|
|
const safeDepth = wantsFull ? 0 : sanitizeDepth(depth);
|
|
const safeMaxNodes = sanitizeMaxNodes(maxNodes);
|
|
|
|
if (updateInputs) {
|
|
videoInput.value = sanitizedId;
|
|
depthInput.value = String(wantsFull ? currentDepth || 1 : safeDepth);
|
|
maxNodesInput.value = String(safeMaxNodes);
|
|
applyFullGraphState(wantsFull);
|
|
} else {
|
|
applyFullGraphState();
|
|
}
|
|
|
|
setStatus(wantsFull ? "Loading full reference graph…" : "Loading graph…");
|
|
try {
|
|
const data = await fetchGraph(
|
|
sanitizedId,
|
|
safeDepth,
|
|
safeMaxNodes,
|
|
wantsFull
|
|
);
|
|
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;
|
|
currentFullGraph = wantsFull;
|
|
renderGraph(data, getLabelSize());
|
|
renderLegend(data.nodes);
|
|
setStatus(
|
|
`Showing ${data.nodes.length} nodes and ${data.links.length} links (${
|
|
data.meta?.mode === "full" ? "full graph" : `depth ${data.depth}`
|
|
})`
|
|
);
|
|
updateUrlState(
|
|
sanitizedId,
|
|
safeDepth,
|
|
safeMaxNodes,
|
|
getLabelSize(),
|
|
wantsFull
|
|
);
|
|
} 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,
|
|
fullGraph: isFullGraphMode(),
|
|
});
|
|
}
|
|
|
|
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, fullGraphMode) {
|
|
if (isEmbedded) {
|
|
return;
|
|
}
|
|
const next = new URL(window.location.href);
|
|
if (videoId) {
|
|
next.searchParams.set("video_id", videoId);
|
|
} else {
|
|
next.searchParams.delete("video_id");
|
|
}
|
|
if (fullGraphMode) {
|
|
next.searchParams.set("full_graph", "1");
|
|
next.searchParams.delete("depth");
|
|
} else {
|
|
next.searchParams.set("depth", String(depth));
|
|
next.searchParams.delete("full_graph");
|
|
}
|
|
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");
|
|
const fullGraphParam = params.get("full_graph");
|
|
const viewFull =
|
|
fullGraphParam && ["1", "true", "yes"].includes(fullGraphParam.toLowerCase());
|
|
if (videoId) {
|
|
videoInput.value = videoId;
|
|
}
|
|
depthInput.value = String(depth);
|
|
maxNodesInput.value = String(maxNodes);
|
|
if (fullGraphToggle) {
|
|
fullGraphToggle.checked = !!viewFull;
|
|
}
|
|
applyFullGraphState();
|
|
if (labelSizeParam && isValidLabelSize(labelSizeParam)) {
|
|
setLabelSizeInput(labelSizeParam);
|
|
} else {
|
|
setLabelSizeInput(getLabelSize());
|
|
}
|
|
if ((isEmbedded && !viewFull) || (!videoId && !viewFull)) {
|
|
return;
|
|
}
|
|
loadGraph(videoId, depth, maxNodes, {
|
|
updateInputs: false,
|
|
fullGraph: viewFull,
|
|
});
|
|
}
|
|
|
|
resizeContainer();
|
|
window.addEventListener("resize", resizeContainer);
|
|
form.addEventListener("submit", handleSubmit);
|
|
if (fullGraphToggle) {
|
|
fullGraphToggle.addEventListener("change", () => {
|
|
applyFullGraphState();
|
|
});
|
|
}
|
|
labelSizeInput.addEventListener("change", () => {
|
|
const size = getLabelSize();
|
|
if (currentGraphData) {
|
|
renderGraph(currentGraphData, size);
|
|
renderLegend(currentGraphData.nodes);
|
|
}
|
|
updateUrlState(
|
|
sanitizeId(videoInput.value),
|
|
currentDepth,
|
|
currentMaxNodes,
|
|
size,
|
|
currentFullGraph
|
|
);
|
|
});
|
|
initFromQuery();
|
|
|
|
Object.assign(GraphUI, {
|
|
load(videoId, depth, maxNodes, options = {}) {
|
|
const targetDepth = depth != null ? depth : currentDepth;
|
|
const targetMax = maxNodes != null ? maxNodes : currentMaxNodes;
|
|
const explicitFull =
|
|
typeof options.fullGraph === "boolean"
|
|
? options.fullGraph
|
|
: undefined;
|
|
if (fullGraphToggle && typeof explicitFull === "boolean") {
|
|
fullGraphToggle.checked = explicitFull;
|
|
}
|
|
applyFullGraphState(
|
|
typeof explicitFull === "boolean" ? explicitFull : undefined
|
|
);
|
|
const fullFlag =
|
|
typeof explicitFull === "boolean"
|
|
? explicitFull
|
|
: isFullGraphMode();
|
|
return loadGraph(videoId, targetDepth, targetMax, {
|
|
updateInputs: options.updateInputs !== false,
|
|
fullGraph: fullFlag,
|
|
});
|
|
},
|
|
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() : [],
|
|
fullGraph: currentFullGraph,
|
|
};
|
|
},
|
|
isEmbedded,
|
|
});
|
|
GraphUI.ready = true;
|
|
setTimeout(() => {
|
|
window.dispatchEvent(new CustomEvent("graph-ui-ready"));
|
|
}, 0);
|
|
})();
|