I have a graph in d3js which is very dense in the centre, but not dense in the outer areas.
Currently, it looks like this:
My primary goal is that no nodes are overlapping and that the titles of the nodes somehow are positioned at places that they are not irritating and not overlapping either.
My secondary goal is that the size of the nodes and labels is big enough to read but zooming is possible to get more into details.
I am a bit conceptless here on how to approach these two things.
-
Is there a good algorithm in the framework which prevents nodes from overlapping while not changing their position to each other too much? Its important to me that a node which is north-west of another one will be drawn north-west of that one.
-
Does anybody have a smart idea on how to realise the issue of good readability while making sure its possible to zoom nicely if its getting bigger?
It feels a bit like its an unsolvable issue.
The current result is visible in here if somebody wants to have a look: https://bergle-test.privatevoid.eu/ (click on the map symbol in the top bar)
The code of the svg is:
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
const { width, height } = svgDimensions;
const mapNodes: MapNode[] = countries.map((country) => ({
id: country.code,
label: country.name,
latitude: country.latitude || 0,
longitude: country.longitude || 0,
neighbours: country.neighbours,
}));
const mapEdges = generateMapEdges(mapNodes);
const projection = d3
.geoMercator()
.fitExtent([[20, 20], [width - 20, height - 20]], {
type: "FeatureCollection",
features: mapNodes.map(nodeToFeature),
} as GeoJSON.FeatureCollection<GeoJSON.GeometryObject>);
const path = d3.geoPath().projection(projection);
svg.selectAll("*").remove();
const g = svg.append("g");
const zoom = d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.5, 8]).on("zoom", zoomed);
svg.call(zoom);
function zoomed(event: d3.D3ZoomEvent<SVGSVGElement, unknown>) {
g.attr("transform", event.transform.toString());
}
g.selectAll("path")
.data(mapEdges)
.enter()
.append("path")
.attr("d", (d) => {
const source = mapNodes.find((node) => node.id === d.source);
const target = mapNodes.find((node) => node.id === d.target);
if (source && target) {
return path({
type: "LineString",
coordinates: [
[source.longitude, source.latitude],
[target.longitude, target.latitude],
],
});
}
return null;
})
.attr("stroke", graphTheme.edge.stroke)
.attr("stroke-width", 1)
.attr("fill", "none");
const nodeGroup = g.selectAll(".node-group")
.data(mapNodes)
.enter()
.append("g")
.attr("class", "node-group");
nodeGroup.append("circle")
.attr("cx", (d) => projection([d.longitude, d.latitude])?.[0] || 0)
.attr("cy", (d) => projection([d.longitude, d.latitude])?.[1] || 0)
.attr("r", 5)
.attr("id", (d) => d.id) // Assigning ID to circles
.style("fill", (d) =>
d.label.toLowerCase() === country.name.toLowerCase() ? graphTheme.node.activeFill : graphTheme.node.fill
);
nodeGroup.append("text")
.attr("x", (d) => projection([d.longitude, d.latitude])?.[0] || 0)
.attr("y", (d) => projection([d.longitude, d.latitude])?.[1] || 0)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("dy", "1em") // Adjusting position to be closer to the node
.attr("font-size", "10px")
.attr("fill", graphTheme.node.label.color)
.text((d) => d.label);
colourNodes(country.name.toLowerCase(), guesses, mapNodes, g, projection);
}, [isOpen, country, guesses, svgDimensions]);
The overall style looks like this:
<Modal
isOpen={isOpen}
onRequestClose={close}
className="modal"
style={{
overlay: {
backgroundColor: "rgba(0, 0, 0, 0.75)",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
content: {
width: isMobileDevice() ? "100%" : "90%",
height: isMobileDevice() ? "100%" : "90%",
paddingBottom: "10px",
backgroundColor: graphTheme.canvas.background,
},
}}
onAfterOpen={() => {
const width = svgRef.current?.clientWidth || 0;
const height = svgRef.current?.clientHeight || 0;
setSvgDimensions({ width, height });
}}
>
<div className="flex flew-row justify-between margin-auto mb-0 pb-2">
<h1 className="margin-auto font-bold text-slate-100 p-4">
{t("mapTitle")}
</h1>
<button className="margin-auto p-4" onClick={close}>
❌
</button>
</div>
<p className="text-slate-100 px-4 pb-4">
{isMobileDevice() ? t("mapMobileWarning") : ""}
</p>
<svg ref={svgRef} style={{ width: "100%", height: "80vh" }}></svg>
</Modal>
const graphTheme = {
canvas: { background: "#0f172a" }, // Navy Blue
node: {
fill: "#f2a900", // Yellow
activeFill: "#1DE9AC",
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.2,
label: {
color: "#fff",
stroke: "#000000",
activeColor: "#1DE9AC",
},
subLabel: {
color: "#000000",
stroke: "transparent",
activeColor: "#1DE9AC",
},
},
lasso: {
border: "1px solid #55aaff",
background: "rgba(75, 160, 255, 0.1)",
},
ring: {
fill: "#D8E6EA",
activeFill: "#1DE9AC",
},
edge: {
stroke: "white",
strokeWidth: 1,
fill: "none",
},
arrow: {
fill: "#D8E6EA",
activeFill: "#1DE9AC",
},
cluster: {
stroke: "#D8E6EA",
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.1,
label: {
stroke: "#fff",
color: "#2A6475",
},
},
};
I am happy for anybody giving me a hint to improve this mess.