(() => { 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; let previousMaxNodesValue = maxNodesInput ? maxNodesInput.value : "200"; 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 (maxNodesInput) { if (enabled) { previousMaxNodesValue = maxNodesInput.value || previousMaxNodesValue || "200"; maxNodesInput.value = "0"; maxNodesInput.disabled = true; } else { if (maxNodesInput.disabled) { maxNodesInput.value = previousMaxNodesValue || "200"; } maxNodesInput.disabled = false; } } 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"); params.set("max_nodes", "0"); } 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 ? currentDepth || 1 : sanitizeDepth(depth); const safeMaxNodes = wantsFull ? 0 : 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"); next.searchParams.set("max_nodes", "0"); } 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 rawMaxNodes = params.get("max_nodes"); let maxNodes = sanitizeMaxNodes(rawMaxNodes || ""); if (rawMaxNodes && rawMaxNodes.trim() === "0") { maxNodes = 0; } 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(viewFull ? 0 : 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); })();