function renderGraph(graphData, svgId, tooltipId) {
const width = 600;
const height = 300;
const marginTop = 30;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 30;
const nodeRadius = 3;
const svg = d3
.select(svgId)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("style", "max-width: 100%; height: auto; font: 8px sans-serif;");
const tooltip = d3.select(tooltipId);
let { nodes, edges } = graphData;
if (nodes.length === 0) {
svg.selectAll("*").remove();
svg
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.attr("fill", "#666")
.text("No nodes to display");
return;
}
const xDomain = d3.extent(nodes, (d) => d.x);
const yDomain = d3.extent(nodes, (d) => d.y);
const xPadding = 2;
const yPadding = 2;
const xScale = d3
.scaleLinear()
.domain([xDomain[0] - xPadding, xDomain[1] + xPadding])
.nice()
.range([marginLeft, width - marginRight]);
const yScale = d3
.scaleLinear()
.domain([yDomain[0] - yPadding, yDomain[1] + yPadding])
.nice()
.range([height - marginBottom, marginTop]);
svg.selectAll("*").remove();
// X axis
svg
.append("g")
.attr("transform", `translate(0, ${height - marginBottom})`)
.call(d3.axisBottom(xScale).ticks(width / 80))
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.append("text")
.attr("x", width)
.attr("y", marginBottom - 4)
.attr("fill", "currentColor")
.attr("text-anchor", "end")
.text("Semantic dimension 1")
);
// Y axis
svg
.append("g")
.attr("transform", `translate(${marginLeft}, 0)`)
.call(d3.axisLeft(yScale))
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("Semantic dimension 2")
);
// Grid
svg
.append("g")
.attr("stroke", "#cccccc")
.attr("stroke-opacity", 0.5)
.call((g) =>
g
.append("g")
.selectAll("line")
.data(xScale.ticks())
.join("line")
.attr("x1", (d) => 0.5 + xScale(d))
.attr("x2", (d) => 0.5 + xScale(d))
.attr("y1", marginTop)
.attr("y2", height - marginBottom)
)
.call((g) =>
g
.append("g")
.selectAll("line")
.data(yScale.ticks())
.join("line")
.attr("y1", (d) => 0.5 + yScale(d))
.attr("y2", (d) => 0.5 + yScale(d))
.attr("x1", marginLeft)
.attr("x2", width - marginRight)
);
// Edges
svg
.append("g")
.selectAll("line")
.data(edges)
.join("line")
.attr("stroke", "#666")
.attr("stroke-opacity", 0.5)
.attr(
"x1",
(d) =>
xScale(d.source.x) +
(d.source.x < d.target.x ? 1.3 * nodeRadius : -1.3 * nodeRadius)
)
.attr("y1", (d) => yScale(d.source.y))
.attr(
"x2",
(d) =>
xScale(d.target.x) +
(d.source.x > d.target.x ? 1.3 * nodeRadius : -1.3 * nodeRadius)
)
.attr("y2", (d) => yScale(d.target.y))
.style("stroke-dasharray", (d) =>
d.target.type === "add" ? "3,3" : ""
);
// Nodes
svg
.append("g")
.attr("stroke-width", 2.5)
.attr("stroke-opacity", 0.5)
.attr("fill", "none")
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("stroke", (d) =>
d.type === "add" ? "green" : d.type === "input" ? "#666" : "red"
)
.attr("cx", (d) => xScale(d.x))
.attr("cy", (d) => yScale(d.y))
.attr("r", nodeRadius);
// Labels
svg
.append("g")
.attr("font-family", "sans-serif")
.attr("text-opacity", 0.5)
.attr("font-size", 8)
.selectAll("text")
.data(nodes)
.join("text")
.attr("dy", "0.35em")
.attr("x", (d) => xScale(d.x) + 5)
.attr("y", (d) => yScale(d.y))
.text((d) => d.label)
.on("mousemove", function (event, d) {
d3.select(this)
.transition()
.duration(50)
.attr("text-opacity", 1.0)
.attr("stroke", "white")
.attr("stroke-width", 3)
.style("paint-order", "stroke fill")
.attr(
"fill",
d.type === "add"
? "green"
: d.type === "input"
? "black"
: "red"
);
tooltip.transition().duration(50).style("opacity", 1);
tooltip
.html(`${d.label}:
${d.text}`)
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY + 10 + "px");
})
.on("mouseout", function () {
d3.select(this)
.transition()
.duration(50)
.attr("text-opacity", 0.5)
.attr("stroke-width", 0)
.style("paint-order", "fill")
.attr("fill", "black");
tooltip.transition().duration(50).style("opacity", 0);
});
}
function generateGraph(recommendations) {
const rec = recommendations;
let i = 0,
j = 0;
const graphData = { nodes: [], edges: [] };
// Input sentences
if (rec.input && rec.input.length > 0) {
graphData.nodes.push({
id: 0,
x: Number(rec.input[0].x),
y: Number(rec.input[0].y),
text: rec.input[0].sentence,
label: "S1",
type: "input",
});
for (i = 1; i < rec.input.length; i++) {
graphData.nodes.push({
id: i,
x: Number(rec.input[i].x),
y: Number(rec.input[i].y),
text: rec.input[i].sentence,
label: `S${i + 1}`,
type: "input",
});
graphData.edges.push({ source: i - 1, target: i, type: "input" });
}
}
// “Add” recommendations
if (rec.add && rec.add.length > 0) {
for (j = 0; j < rec.add.length; j++) {
graphData.nodes.push({
id: i + j,
x: Number(rec.add[j].x),
y: Number(rec.add[j].y),
text: rec.add[j].prompt,
label: rec.add[j].value,
type: "add",
});
graphData.edges.push({
source: i - 1,
target: i + j,
type: "add",
});
}
}
// “Remove” recommendation (first only)
if (rec.remove && rec.remove.length > 0) {
graphData.nodes.push({
id: i + j,
x: Number(rec.remove[0].x),
y: Number(rec.remove[0].y),
text: rec.remove[0].closest_harmful_sentence,
label: rec.remove[0].value,
type: "remove",
});
}
graphData.edges = graphData.edges.map((e) => ({
source: graphData.nodes.find((n) => n.id === e.source),
target: graphData.nodes.find((n) => n.id === e.target),
type: e.type,
}));
return graphData;
}
function appendUserTurn(turn, chatId) {
const bubble = $("