use std::fmt::Write;
pub(crate) struct TooltipField {
pub label: &'static str,
pub value_expr: &'static str,
}
pub(crate) struct D3GraphConfig {
pub svg_selector: &'static str,
pub tooltip_id: &'static str,
pub arrow_id: &'static str,
pub width_expr: &'static str,
pub height_expr: &'static str,
pub color_field: &'static str,
pub node_radius: u32,
pub text_dy: u32,
pub link_distance: u32,
pub charge_strength: i32,
pub collide_radius: u32,
pub tooltip_header_expr: &'static str,
pub tooltip_fields: &'static [TooltipField],
}
pub(crate) fn render_script(cfg: &D3GraphConfig) -> String {
let mut js = String::new();
js.push_str(
r#"function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
}
"#,
);
writeln!(
js,
"const colour = d3.scaleOrdinal(d3.schemeTableau10).domain([...new Set(nodes.map(d => d[{color_field:?}]))]);\n",
color_field = cfg.color_field
)
.unwrap();
writeln!(
js,
"const svg = d3.select({svg_selector:?});",
svg_selector = cfg.svg_selector
)
.unwrap();
writeln!(js, "const width = {};", cfg.width_expr).unwrap();
writeln!(js, "const height = {};", cfg.height_expr).unwrap();
js.push_str("svg.attr(\"viewBox\", [0, 0, width, height]);\n\n");
writeln!(js, "svg.append(\"defs\").append(\"marker\")").unwrap();
writeln!(js, " .attr(\"id\", {arrow_id:?})", arrow_id = cfg.arrow_id).unwrap();
js.push_str(
" .attr(\"viewBox\", \"0 -5 10 10\")\n .attr(\"refX\", 22).attr(\"refY\", 0)\n .attr(\"markerWidth\", 6).attr(\"markerHeight\", 6)\n .attr(\"orient\", \"auto\")\n .append(\"path\").attr(\"fill\", \"#aaa\").attr(\"d\", \"M0,-5L10,0L0,5\");\n\n",
);
writeln!(
js,
"const sim = d3.forceSimulation(nodes)\n .force(\"link\", d3.forceLink(links).id(d => d.id).distance({link_distance}))\n .force(\"charge\", d3.forceManyBody().strength({charge_strength}))\n .force(\"center\", d3.forceCenter(width / 2, height / 2))\n .force(\"collide\", d3.forceCollide({collide_radius}));\n",
link_distance = cfg.link_distance,
charge_strength = cfg.charge_strength,
collide_radius = cfg.collide_radius
)
.unwrap();
js.push('\n');
writeln!(
js,
"const link = svg.append(\"g\")\n .selectAll(\"line\")\n .data(links).join(\"line\")\n .attr(\"class\", \"link\")\n .attr(\"marker-end\", \"url(#{arrow_id})\");\n",
arrow_id = cfg.arrow_id
)
.unwrap();
js.push('\n');
js.push_str(
"const node = svg.append(\"g\")\n .selectAll(\"g\")\n .data(nodes).join(\"g\")\n .attr(\"class\", \"node\")\n .call(d3.drag()\n .on(\"start\", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })\n .on(\"drag\", (e, d) => { d.fx = e.x; d.fy = e.y; })\n .on(\"end\", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));\n\n",
);
writeln!(
js,
"node.append(\"circle\")\n .attr(\"r\", {node_radius})\n .attr(\"fill\", d => colour(d[{color_field:?}]));\n",
node_radius = cfg.node_radius,
color_field = cfg.color_field
)
.unwrap();
js.push('\n');
writeln!(
js,
"node.append(\"text\")\n .attr(\"dy\", {text_dy}).attr(\"text-anchor\", \"middle\")\n .text(d => d.id);\n",
text_dy = cfg.text_dy
)
.unwrap();
js.push('\n');
writeln!(
js,
"const tip = document.getElementById({tooltip_id:?});",
tooltip_id = cfg.tooltip_id
)
.unwrap();
js.push_str("node.on(\"mouseover\", (e, d) => {\n tip.style.display = \"block\";\n");
let mut pieces: Vec<String> = Vec::with_capacity(cfg.tooltip_fields.len() + 1);
pieces.push(format!(
"\"<b>\" + escHtml({header}) + \"</b>\"",
header = cfg.tooltip_header_expr
));
for field in cfg.tooltip_fields {
pieces.push(format!(
"{label:?} + escHtml({value_expr})",
label = format!("{}: ", field.label),
value_expr = field.value_expr
));
}
writeln!(js, " tip.innerHTML = {};", pieces.join(" + \"<br>\" + ")).unwrap();
js.push_str(
"}).on(\"mousemove\", e => {\n tip.style.left = (e.pageX + 12) + \"px\";\n tip.style.top = (e.pageY - 28) + \"px\";\n}).on(\"mouseout\", () => { tip.style.display = \"none\"; });\n\n",
);
js.push_str(
"sim.on(\"tick\", () => {\n link.attr(\"x1\", d => d.source.x).attr(\"y1\", d => d.source.y)\n .attr(\"x2\", d => d.target.x).attr(\"y2\", d => d.target.y);\n node.attr(\"transform\", d => `translate(${d.x},${d.y})`);\n});\n",
);
js
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_cfg() -> D3GraphConfig {
D3GraphConfig {
svg_selector: "#graph",
tooltip_id: "tooltip",
arrow_id: "arrow",
width_expr: "window.innerWidth",
height_expr: "window.innerHeight - 60",
color_field: "platform",
node_radius: 16,
text_dy: 28,
link_distance: 120,
charge_strength: -300,
collide_radius: 40,
tooltip_header_expr: "d.id",
tooltip_fields: &[
TooltipField {
label: "platform",
value_expr: "d.platform",
},
TooltipField {
label: "team",
value_expr: "d.team || \"-\"",
},
],
}
}
#[test]
fn every_tooltip_field_is_wrapped_in_esc_html() {
let script = render_script(&sample_cfg());
assert!(script.contains("function escHtml(s)"));
assert!(script.contains("escHtml(d.id)"));
assert!(script.contains("escHtml(d.platform)"));
assert!(script.contains(r#"escHtml(d.team || "-")"#));
let assign_line = script
.lines()
.find(|l| l.trim_start().starts_with("tip.innerHTML ="))
.expect("tip.innerHTML assignment present");
for raw in ["${d.id}", "${d.platform}", "${d.team"] {
assert!(
!assign_line.contains(raw),
"tooltip assignment must not interpolate {raw} unescaped: {assign_line}"
);
}
}
#[test]
fn config_values_flow_into_the_script_verbatim() {
let script = render_script(&sample_cfg());
assert!(script.contains("d3.select(\"#graph\")"));
assert!(script.contains("getElementById(\"tooltip\")"));
assert!(script.contains("url(#arrow)"));
assert!(script.contains(".attr(\"r\", 16)"));
assert!(script.contains(".attr(\"dy\", 28)"));
assert!(script.contains(".distance(120)"));
assert!(script.contains(".strength(-300)"));
assert!(script.contains("forceCollide(40)"));
assert!(script.contains("d[\"platform\"]"));
}
#[test]
fn malicious_label_or_field_values_cannot_reach_innerhtml_unescaped() {
let cfg = D3GraphConfig {
tooltip_fields: &[
TooltipField {
label: "repo",
value_expr: "d.repo",
},
TooltipField {
label: "dependencies",
value_expr: "d.dependencies",
},
TooltipField {
label: "dependents",
value_expr: "d.dependents",
},
],
..sample_cfg()
};
let script = render_script(&cfg);
let assign_line = script
.lines()
.find(|l| l.trim_start().starts_with("tip.innerHTML ="))
.expect("tip.innerHTML assignment present");
for expr in ["d.repo", "d.dependencies", "d.dependents", "d.id"] {
assert!(
assign_line.contains(&format!("escHtml({expr})")),
"{expr} must be wrapped in escHtml on the tooltip assignment: {assign_line}"
);
}
}
}