Skip to content

Commit

Permalink
re-instument optimized Sunburst with Sentry
Browse files Browse the repository at this point in the history
  • Loading branch information
nicholas-codecov committed Mar 3, 2025
1 parent fb36c5d commit a0b9798
Showing 1 changed file with 200 additions and 171 deletions.
371 changes: 200 additions & 171 deletions src/ui/SunburstChart/SunburstChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,26 @@ function SunburstChart({
const radius = width / 6

// Creates a function for creating arcs representing files and folders.
const drawArc = Sentry.startSpan({ name: 'SunburstChart.drawArc' }, () => {
return arc()
.startAngle((d) => d.x0)
.endAngle((d) => d.x1)
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
.padRadius(radius * 1.5)
.innerRadius((d) => d.y0 * radius)
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1))
})
const createDrawArcFunction = (parentSpan) =>
Sentry.startSpan({ name: 'SunburstChart.drawArc', parentSpan }, () => {
return arc()
.startAngle((d) => d.x0)
.endAngle((d) => d.x1)
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
.padRadius(radius * 1.5)
.innerRadius((d) => d.y0 * radius)
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1))
})

// A color function you can pass a number from 0-100 to and get a color back from the specified color range
// Ex color(10.4)
const color = Sentry.startSpan({ name: 'SunburstChart.color' }, () => {
return scaleSequential()
.domain([colorDomainMin, colorDomainMax])
.interpolator(colorRange)
.clamp(true)
})
const createColorFunction = (parentSpan) =>
Sentry.startSpan({ name: 'SunburstChart.color', parentSpan }, () => {
return scaleSequential()
.domain([colorDomainMin, colorDomainMax])
.interpolator(colorRange)
.clamp(true)
})

// Tracks previous location for rendering .. in the breadcrumb.
let previous
Expand All @@ -97,167 +99,194 @@ function SunburstChart({
.append('g')
.attr('transform', `translate(${width / 2},${width / 2})`)

function renderArcs() {
const nodesToRender = selectedNode
.descendants()
.slice(1)
.filter((d) => d.depth <= selectedNode.depth + 2)

// Renders an arc per data point in the correct location. (Pieces of the circle that add up to a circular graph)
const path = g
.append('g')
.selectAll('path')
.data(nodesToRender)
.join('path')
.attr('fill', (d) => color(d?.data?.value || 0))
// If data point is a file fade the background color a bit.
.attr('fill-opacity', (d) => (d.children ? 1 : 0.6))
.attr('pointer-events', () => 'auto')
.attr('d', (d) => drawArc(d.current))

// Events for folders
path
.filter((d) => d.children)
.style('cursor', 'pointer')
.on('click', clickedFolder)
.on('mouseover', function (_event, p) {
select(this).attr('fill-opacity', 0.6)
reactHoverCallback({ target: p, type: 'folder' })
})
.on('mouseout', function (_event, _node) {
select(this).attr('fill-opacity', 1)
})

// Events for file
path
.filter((d) => !d.children)
.style('cursor', 'pointer')
.on('click', function (_event, node) {
reactClickCallback({ target: node, type: 'file' })
})
.on('mouseover', function (_event, node) {
select(this).attr('fill-opacity', 0.6)
reactHoverCallback({ target: node, type: 'file' })
})

// Create a11y label / mouse hover tooltip
const formatTitle = (d) => {
const coverage = formatData(d.data.value)
const filePath = d
.ancestors()
.map((d) => d.data.name)
.reverse()
.join('/')

return `${filePath}\n${coverage}% coverage`
}

path.append('title').text((d) => formatTitle(d))

// White circle in the middle. Act's as a "back"
g.append('circle')
.datum(selectedNode.parent)
.attr('r', radius)
.attr('class', 'fill-none')
.attr('fill', 'none')
.attr('pointer-events', 'all')
.attr('cursor', (d) => (d ? 'pointer' : 'default'))
.on('click', clickedFolder)
.on('mouseover', hoveredRoot)

g.append('text')
.datum(selectedNode.parent)
.text('..')
// if the parent exists (i.e. not root), show the text
.attr('fill-opacity', (d) => (d ? 1 : 0))
.attr('text-anchor', 'middle')
.attr('class', 'text-7xl fill-ds-gray-quinary select-none')
.attr('cursor', 'pointer')
.on('click', clickedFolder)
.on('mouseover', hoveredRoot)

function clickedFolder(_event, node) {
reactClickCallback({ target: node, type: 'folder' })
changeLocation(node)
}

function hoveredRoot(_event, node) {
if (previous) {
reactHoverCallback({ target: previous, type: 'folder' })
return
}
reactHoverCallback({ target: node, type: 'folder' })
}

function reactClickCallback({ target, type }) {
if (target?.ancestors) {
// Create a string from the root data down to the current item
const filePath = target
.ancestors()
.map((d) => d.data.name)
.slice(0, -1)
.reverse()
.join('/')

// callback to parent component with a path, the data node, and raw d3 data
// (just in case we need it for the second iteration to listen to location changes and direct to the correct folder.)
clickHandler.current({
path: filePath,
data: target.data,
target,
type,
})
}
}
const renderSunburst = () =>
Sentry.startSpan(
{ name: 'SunburstChart.renderSunburst' },
(renderSunburstSpan) => {
const nodesToRender = selectedNode
.descendants()
.slice(1)
.filter((d) => d.depth <= selectedNode.depth + 2)

const drawArc = createDrawArcFunction(renderSunburstSpan)
const color = createColorFunction(renderSunburstSpan)

// Renders an arc per data point in the correct location. (Pieces of the circle that add up to a circular graph)
const path = Sentry.startSpan(
{
name: 'SunburstChart.renderArcs',
parentSpan: renderSunburstSpan,
},
() =>
g
.append('g')
.selectAll('path')
.data(nodesToRender)
.join('path')
.attr('fill', (d) => color(d?.data?.value || 0))
// If data point is a file fade the background color a bit.
.attr('fill-opacity', (d) => (d.children ? 1 : 0.6))
.attr('pointer-events', () => 'auto')
.attr('d', (d) => drawArc(d.current))
)

function reactHoverCallback({ target, type }) {
if (target?.ancestors) {
// Create a string from the root data down to the current item
const filePath = target
.ancestors()
.map((d) => d.data.name)
.slice(0, -1)
.reverse()
.join('/')

// callback to parent component with a path, the data node, and raw d3 data
// (just in case we need it for the second iteration to listen to location changes and direct to the correct folder.)
hoverHandler.current({
path: filePath,
data: target.data,
target,
type,
})
}
}
// Events for folders
path
.filter((d) => d.children)
.style('cursor', 'pointer')
.on('click', clickedFolder)
.on('mouseover', function (_event, p) {
select(this).attr('fill-opacity', 0.6)
reactHoverCallback({ target: p, type: 'folder' })
})
.on('mouseout', function (_event, _node) {
select(this).attr('fill-opacity', 1)
})

function changeLocation(node) {
// Because you can move two layers at a time previous !== parent
previous = node

if (node) {
// Update the selected node
setSelectedNode(
node.each((d) => {
// determine x0 and y0
const x0Min = Math.min(1, (d.x0 - node.x0) / (node.x1 - node.x0))
const x0 = Math.max(0, x0Min) * 2 * Math.PI
const y0 = Math.max(0, d.y0 - node.depth)

// determine x1 and y1
const x1Min = Math.min(1, (d.x1 - node.x0) / (node.x1 - node.x0))
const x1 = Math.max(0, x1Min) * 2 * Math.PI
const y1 = Math.max(0, d.y1 - node.depth)

// update the cords for the node
d.current = { x0, y0, x1, y1 }
// Events for file
path
.filter((d) => !d.children)
.style('cursor', 'pointer')
.on('click', function (_event, node) {
reactClickCallback({ target: node, type: 'file' })
})
)
.on('mouseover', function (_event, node) {
select(this).attr('fill-opacity', 0.6)
reactHoverCallback({ target: node, type: 'file' })
})

// Create a11y label / mouse hover tooltip
const formatTitle = (d) => {
const coverage = formatData(d.data.value)
const filePath = d
.ancestors()
.map((d) => d.data.name)
.reverse()
.join('/')

return `${filePath}\n${coverage}% coverage`
}

path.append('title').text((d) => formatTitle(d))

// White circle in the middle. Act's as a "back"
g.append('circle')
.datum(selectedNode.parent)
.attr('r', radius)
.attr('class', 'fill-none')
.attr('fill', 'none')
.attr('pointer-events', 'all')
.attr('cursor', (d) => (d ? 'pointer' : 'default'))
.on('click', clickedFolder)
.on('mouseover', hoveredRoot)

g.append('text')
.datum(selectedNode.parent)
.text('..')
// if the parent exists (i.e. not root), show the text
.attr('fill-opacity', (d) => (d ? 1 : 0))
.attr('text-anchor', 'middle')
.attr('class', 'text-7xl fill-ds-gray-quinary select-none')
.attr('cursor', 'pointer')
.on('click', clickedFolder)
.on('mouseover', hoveredRoot)

function clickedFolder(_event, node) {
reactClickCallback({ target: node, type: 'folder' })
changeLocation(node)
}

function hoveredRoot(_event, node) {
if (previous) {
reactHoverCallback({ target: previous, type: 'folder' })
return
}
reactHoverCallback({ target: node, type: 'folder' })
}

function reactClickCallback({ target, type }) {
if (target?.ancestors) {
// Create a string from the root data down to the current item
const filePath = target
.ancestors()
.map((d) => d.data.name)
.slice(0, -1)
.reverse()
.join('/')

// callback to parent component with a path, the data node, and raw d3 data
// (just in case we need it for the second iteration to listen to location changes and direct to the correct folder.)
clickHandler.current({
path: filePath,
data: target.data,
target,
type,
})
}
}

function reactHoverCallback({ target, type }) {
if (target?.ancestors) {
// Create a string from the root data down to the current item
const filePath = target
.ancestors()
.map((d) => d.data.name)
.slice(0, -1)
.reverse()
.join('/')

// callback to parent component with a path, the data node, and raw d3 data
// (just in case we need it for the second iteration to listen to location changes and direct to the correct folder.)
hoverHandler.current({
path: filePath,
data: target.data,
target,
type,
})
}
}

const changeLocation = (node) =>
Sentry.startSpan(
{
name: 'SunburstChart.changeLocation',
parentSpan: renderSunburstSpan,
},
() => {
// Because you can move two layers at a time previous !== parent
previous = node

if (node) {
// Update the selected node
setSelectedNode(
node.each((d) => {
// determine x0 and y0
const x0Min = Math.min(
1,
(d.x0 - node.x0) / (node.x1 - node.x0)
)
const x0 = Math.max(0, x0Min) * 2 * Math.PI
const y0 = Math.max(0, d.y0 - node.depth)

// determine x1 and y1
const x1Min = Math.min(
1,
(d.x1 - node.x0) / (node.x1 - node.x0)
)
const x1 = Math.max(0, x1Min) * 2 * Math.PI
const y1 = Math.max(0, d.y1 - node.depth)

// update the cords for the node
d.current = { x0, y0, x1, y1 }
})
)
}
}
)
}
}
}
)

renderArcs()
renderSunburst()

return () => {
// On cleanup remove the root DOM generated by D3
Expand Down

0 comments on commit a0b9798

Please sign in to comment.