Use Custom Icons in D3 Force Graph


I have a set of custom svg icons stored on a public github ( os-threat/images ( that I want to use in a cybersecurity force graph.

I have tried a couple of different approaches, but none of them work. What am i doing wrong?

I have tried:

  1. Just building the github direct path, since the data carries the name key, and the rest is a static string addition. But it wont work because of CORS Try 1 / brettforbes | Observable (
  2. Loading the images as files directly into the notebook and trying to cross-reference it using the key data in each of the data objects, but this doesn’t work either

How should it work? What is the easiest way to externally host images I want to use in force graphs? Can someone advise please?

1 Like

Strangely, the system limits newbies too only two links per post, so the second observable example is here Try 2 / brettforbes | Observable (

You’re nesting the images inside the circles, which is invalid and causes them to not get rendered. Instead you’ll want to create a group for each circle and image.

For a large number of files, I would consider zipping the directory. When you extract on the Observable side, you can set up a Map object to retrieve by filename. Something like so:

images = {
  let zipped_images = await FileAttachment("").zip();
  let filenames = zipped_images.filenames.filter((s) => s.slice(-4) == ".svg");
  let images = await Promise.all( => zipped_images.file(f).image())
  return new Map(, images));

There are variations though and I did it a bit differently in the notebook linked below.

To address @mootari’s point, you might build your nodes as a group with a circle and nested SVG like so:

  .join(function (enter) {
    let g = enter
      .attr("class", "node-container")
      .attr("transform", `scale(${s})`);
      .attr("r", 500)
      .attr("cx", `${0.5 * w}`)
      .attr("cy", `${0.5 * h}`)
      .attr("fill", "lightgray")
      .attr("stroke", "black")
      .attr("stroke-width", 40);
    g.append((d) => svg`${images.get(icon_to_image.get(d.label))}`);

I’m not sure that works with D3 V5 but I guess you should use the current version anyway.

I’ve put that all together in this notebook:

1 Like

that’s amazing advice, thank you so much. I love the idea of the zip.

Funny, but my inexperience caused the circle, i actually just want the icons

thanks a lot!!

For notebooks there’s also an importable helper here:

I have struck problems trying to implement your code.

the fundamental problem is that i am not too good on JS, and hence have cobbled together slabs of code copied from other code bases. Whereas i look at your code, and it is really nicely organised, but mine is a serious Frankenstein.

There is a lot of very subtle things going on in your code, and i fear i have not been able to copy it that well.

Why does my variation, only produce a single image? Try 1 / brettforbes | Observable (

The aim of the prefix variable is to be able to swap between round, rectangular and normal icons with the same name (but different prefixes). In a future version i will setup a radio button for that functionality.

But why is it all going wrong with the loading of the images? Apparently i have the same as you, but it does not work.

Also how do i get rid of the circle and just have the icon? Your clarity over how d3operates is really great, please help

I’m glad you found the code informative. I’ve only got a few minutes but here are some thoughts on your questions.

If you use your browser’s inspector, you should notice that you’ve actually got a bunch of images; they just happen to all be placed at the exact same spot. The reason is that you’re adding each as a group containing a circle and then an SVG. Conceptually, each image looks like so:

<g transform="translate(x,y) scale(r)>
  <circle r=500></circle>

Note that the circle has no cx or cy attribute specified; thus, they default to zero,zero or the upper left corner of the figure. The placement is specified by the group transform. You’re attempting to place the figures using cx and cy in your ticked function, though. I guess that’s a result of attempting to merge the two code blocks.

That part is easy using the code I provided:

In the data join, simply remove the portion that generates the circle, i.e. the commented portion below:

  .join(function (enter) {
    let g = enter
      .attr("class", "node-container")
      .attr("transform", `scale(${s})`);
    //  .attr("r", 500)
    //  .attr("cx", `${0.5 * w}`)
    //  .attr("cy", `${0.5 * h}`)
    //  .attr("fill", "lightgray")
    //  .attr("stroke", "black")
    //  .attr("fill-opacity", 0.4)
    //  .attr("stroke-opacity", 0.2)
    //  .attr("stroke-width", 40);
    g.append((d) => svg`${images.get(icon_to_image.get(d.label))}`);

You might also experiment with opacity. If you take another look at the example I embedded earlier, you’ll notice that the circles are there but are somewhat transparent.

1 Like