TLC-Search/static/graph.js
knight b0c9d319ef
Some checks failed
docker-build / build (push) Has been cancelled
Remove full graph node cap
2025-11-20 09:42:14 -05:00

790 lines
24 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;
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);
})();