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.