I have a D3 force directed graph using D3 v6 and React, with a zoom function and draggable nodes. Now because the graph can get quite complex and big in size (dynamic data), I would like the user to be able to drag the element that wraps all the nodes, especially when the graph is zoomed in.
The group is calling the same functions that are being called on the individual nodes, so I don't understand why nothing happens with it. I have been using this code as a reference: https://observablehq.com/@d3/drag-zoom.
Please also disregard the TypeScript hell but types are poorly supported in D3, or I couldn't find much documentation so far.
const data = jsonFyStory(selectedVariable, stories)
const links = data.links.map((d) => d)
const nodes = data.nodes.map((d: any) => d)
const containerRect = container.getBoundingClientRect()
const height = containerRect.height
const width = containerRect.width
function dragstarted() {
// @ts-ignore
d3.select(this).classed('fixing', true)
setDisplayCta(false)
setDisplayNodeDescription(false)
setNodeData({})
}
function dragged(event: DragEvent, d: any) {
d.fx = event.x
d.fy = event.y
simulation.alpha(1).restart()
setDisplayNodeDescription(true)
d.class === 'story-node' && setDisplayCta(true)
setNodeData({
name: d.name as string,
class: d.class as string,
definition: d.definition as string,
summary: d.summary as string,
})
}
// dragended function in case we move away from sticky dragging!
function dragended(event: DragEvent, d: DNode) {
// @ts-ignore
d3.select(this).classed('fixed', true)
console.log(d)
}
function click(event: TouchEvent, d: DNode) {
delete d.fx
delete d.fy
console.log(d)
// @ts-ignore
d3.select(this).classed('fixed', false)
// @ts-ignore
d3.select(this).classed('fixing', false)
simulation.alpha(1).restart()
}
const simulation = d3
.forceSimulation(nodes as any[])
.force(
'link',
d3.forceLink(links).id((d: any) => d.id)
)
.force('charge', d3.forceManyBody().strength(isMobile ? -600 : -1300))
.force('collision', d3.forceCollide().radius(isMobile ? 5 : 20))
.force('x', d3.forceX())
.force('y', d3.forceY())
if (container.children) {
d3.select(container).selectAll('*').remove()
}
const zoom = d3
.zoom()
.on('zoom', (event) => {
group.attr('transform', event.transform)
})
.scaleExtent([0.2, 100])
const svg = d3
.select(container)
.append('svg')
.attr('viewBox', [-width / 2, -height / 2, width, height])
const group = svg
.append('g')
.attr('width', '100%')
.attr('height', '100%')
.call(
d3
.drag()
.on('start', dragstarted)
.on('drag', dragged as any)
.on('end', dragended as any) as any
)
// .call(zoom as any)
const link = group
.append('g')
.attr('stroke', '#1e1e1e')
.attr('stroke-opacity', 0.2)
.selectAll('line')
.data(links)
.join('line')
const node = group
.append('g')
.selectAll<SVGCircleElement, { x: number; y: number }>('g')
.data(nodes)
.join('g')
.classed('node', true)
.classed('fixed', (d: any) => d.fx !== undefined)
.attr('class', (d: any) => d.class as string)
.call(
d3
.drag()
.on('start', dragstarted)
.on('drag', dragged as any)
.on('end', dragended as any) as any
)
.on('click', click as any)
d3.selectAll('.category-node')
.append('circle')
.attr('fill', '#0083C5')
.attr('r', isMobile ? 4 : 7)
d3.selectAll('.tag-node')
.append('circle')
.attr('fill', '#FFC434')
.attr('r', isMobile ? 4 : 7)
d3.selectAll('.story-node')
.append('foreignObject')
.attr('height', isMobile ? 18 : 35)
.attr('width', isMobile ? 18 : 35)
.attr('x', isMobile ? -9 : -17)
.attr('y', isMobile ? -18 : -30)
.attr('r', isMobile ? 16 : 30)
.append('xhtml:div')
.attr('class', 'node-image')
.append('xhtml:img')
.attr('src', (d: any) => d.image)
.attr('transform-origin', 'center')
.attr('height', isMobile ? 18 : 35)
.attr('width', isMobile ? 18 : 35)
d3.selectAll('.main-story-node')
.append('foreignObject')
.attr('height', isMobile ? 50 : 100)
.attr('width', isMobile ? 50 : 100)
.attr('x', isMobile ? -25 : -50)
.attr('y', isMobile ? -25 : -50)
.attr('r', isMobile ? 50 : 100)
.append('xhtml:div')
.attr('class', 'node-image')
.append('xhtml:img')
.attr('src', (d: any) => d.image)
.attr('transform-origin', 'center')
.attr('height', isMobile ? 50 : 100)
.attr('width', isMobile ? 50 : 100)
node
.append('foreignObject')
.attr('height', (d: any) => (d.class === 'main-story-node' ? 65 : 55))
.attr('width', (d: any) =>
isMobile
? d.class === 'main-story-node'
? 80
: 50
: d.class === 'main-story-node'
? 120
: 70
)
.attr('x', (d: any) =>
isMobile
? d.class === 'main-story-node'
? -40
: -25
: d.class === 'main-story-node'
? -60
: -35
)
.attr('y', (d: any) =>
isMobile
? d.class === 'main-story-node'
? 32
: 7
: d.class === 'main-story-node'
? 60
: 12
)
.append('xhtml:p')
.attr('class', (d: any) => d.class)
.text((d: any) => d.name)
simulation.on('tick', () => {
link
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
node
.attr('cx', (d: any) => d.x as number)
.attr('cy', (d: any) => d.y as number)
.attr('transform', (d: any) => {
return `translate(${d.x},${d.y})`
})
})
function transition(zoomLevel: number) {
group
.transition()
.delay(100)
.duration(500)
.call(zoom.scaleBy as any, zoomLevel)
}
transition(0.7)
d3.selectAll('.zoom-button').on('click', function () {
// @ts-ignore
if (this && this.id === 'zoom-in') {
transition(1.2) // increase on 0.2 each time
}
// @ts-ignore
if (this.id === 'zoom-out') {
transition(0.8) // deacrease on 0.2 each time
}
// @ts-ignore
if (this.id === 'zoom-init') {
group
.transition()
.delay(100)
.duration(500)
.call(zoom.scaleTo as any, 0.7) // return to initial state
}
})
I've tried splitting the drag functions in two different ones, but nothings seems to happen in the <g>
element that is wrapping all the nodes.
After quite a bit of exploration I found the reason why I couldn't achieve the dragging movement of the whole graph (and not just the nodes).
I could achieve it by actually using the zoom function instead of the dragging one, since it's the zoom function that handles the transform coordinates of the whole graph in D3 (if you enable it).
So I created another zoom function to handle the zoom on the whole svg element:
const zoomSvg = d3.zoom().on('zoom', (event) => {
group.attr('transform', event.transform).on('wheel.zoom', null)
})
As you can see I'm also disabling the zoom on mousewheel movement, as that can be really disruptive. Finally, I called the new zoomSvg function on the svg element of the graph, like so:
const svg = d3
.select(container)
.append('svg')
.attr('viewBox', [-width / 2, -height / 2, width, height])
.call(zoomSvg as any)
Like this I can now both move the graph around, and drag the nodes with the dragging function. It's probably not the cleanest solution but it got the job done.