Skip to content

Instantly share code, notes, and snippets.

@vuldin
Created April 2, 2022 15:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vuldin/9deb279712ac6e81272d05df2e0422a3 to your computer and use it in GitHub Desktop.
Save vuldin/9deb279712ac6e81272d05df2e0422a3 to your computer and use it in GitHub Desktop.
A react component driven by a mobx data source, with a custom D3 visualization.
import {
arc,
axisBottom,
axisLeft,
extent,
line,
pointer,
scaleLinear,
scaleTime,
select,
} from 'd3'
import { isWithinInterval } from 'date-fns'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import useResizeObserver from '../lib/useResizeObserver'
import { useStore } from '../lib/StoreProvider'
export default observer(function GaugeChart() {
const store = useStore()
const {
currentAsset: asset,
currentGauge: gauge,
currentReading,
dateRange: [start, end],
setCurrentReading,
} = store
const wrapperRef = useRef()
const svgRef = useRef()
const dimensions = useResizeObserver(wrapperRef)
const [title, setTitle] = useState(' ')
const margin = {
top: 30,
right: 16,
bottom: 54,
left: 54,
}
const originalOpacity = 0.5
const originalColor = '#9CA3AF'
const unverifiedColor = '#3B82F6'
const circleRadius = 6
const criticalColor = '#EF4444'
const warningColor = '#FCD34D'
const svg = select(svgRef.current)
useEffect(() => {
if (!dimensions) return
let { height, width } = dimensions
height = height - margin.top - margin.bottom
width = width - margin.left - margin.right
svg
.select('.left-block rect')
.style('transform', `translate(${margin.left}px, ${margin.top}px)`)
.attr('x', -margin.left)
.attr('y', 0)
.attr('width', margin.left)
.attr('height', height)
.attr('fill', 'white')
svg
.select('.right-block rect')
.style('transform', `translate(${margin.left + width}px, ${margin.top}px)`)
.attr('x', 0)
.attr('y', 0)
.attr('width', margin.right)
.attr('height', height)
.attr('fill', 'white')
// TODO add top/bottom blocks
}, [dimensions])
useEffect(() => {
if (!dimensions) return
let { height, width } = dimensions
height = height - margin.top - margin.bottom
width = width - margin.left - margin.right
const legend = svg
.select('.legend')
.style('transform', `translate(${margin.left}px, ${margin.top / 2}px)`)
legend.select('text').attr('y', '5px').text('Legend:')
let x = 90
legend.select('.unverified').style('transform', `translate(${x}px, 0px)`)
legend.select('.unverified circle').attr('r', circleRadius).style('fill', unverifiedColor)
legend.select('.unverified text').attr('x', '10px').attr('y', '5px').text('Unverified')
x += 110
legend.select('.incorrect').style('transform', `translate(${x}px, 0px)`)
legend
.select('.incorrect circle')
.attr('r', circleRadius - 1)
.attr('opacity', originalOpacity)
.style('fill', originalColor)
legend.select('.incorrect text').attr('x', '10px').attr('y', '5px').text('Incorrect')
x += 145
legend.select('.normal').style('transform', `translate(${x}px, 0px)`)
legend.select('.normal circle').attr('r', circleRadius).style('fill', 'green')
legend.select('.normal text').attr('x', '10px').attr('y', '5px').text('Normal')
x += 95
legend.select('.warning').style('transform', `translate(${x}px, 0px)`)
legend.select('.warning circle').attr('r', circleRadius).style('fill', warningColor)
legend.select('.warning text').attr('x', '10px').attr('y', '5px').text('Warning')
x += 105
legend.select('.critical').style('transform', `translate(${x}px, 0px)`)
legend.select('.critical circle').attr('r', circleRadius).style('fill', criticalColor)
legend.select('.critical text').attr('x', '10px').attr('y', '5px').text('Critical')
}, [dimensions])
useEffect(() => {
if (!dimensions) return
let { height, width } = dimensions
height = height - margin.top - margin.bottom
width = width - margin.left - margin.right
svg.select('.data').style('transform', `translate(${margin.left}px, ${margin.top}px)`)
if (!gauge) {
setTitle('Gauge Chart')
svg
.select('.data text')
.attr('text-anchor', 'middle')
.attr('class', 'grid place-items-center')
.attr('x', width / 2)
.attr('y', 35)
.text('Select a gauge to view readings.')
return
}
setTitle(`${gauge.materialName} ${gauge.gaugeName}, ${asset.assetName}`)
svg.select('.data text').attr('class', 'invisible')
const readings = gauge.readings.filter(({ readingDate }) =>
isWithinInterval(readingDate, { start, end })
)
const mlReadings = readings.map(({ reading }) => reading)
const userReadings = readings.map((reading) =>
reading.correctedReading ? reading.correctedReading : reading.reading
)
function colorScale(val) {
if (val > gauge.criticalHigh || val < gauge.criticalLow) return criticalColor
if (val > gauge.warningHigh || val < gauge.warningLow) return warningColor
return 'green'
}
function handlePointClick(_, thisReading) {
setCurrentReading(thisReading)
}
function handleNonPointClick() {
setCurrentReading()
}
const xScale = scaleTime()
.domain(extent([start, end]))
.rangeRound([0, width])
const minMax = extent([...mlReadings, ...userReadings])
const range = minMax[1] - minMax[0]
const minBuffer = 5
const buffer = Math.max(range / 10, minBuffer)
const yMin = Math.min(...[...mlReadings, ...userReadings]) - buffer
const yMax = Math.max(...[...mlReadings, ...userReadings]) + buffer
const yScale = scaleLinear().domain([yMin, yMax]).range([height, 0])
const xAxis = axisBottom(xScale).ticks(9)
svg
.select('.x-axis')
.style('transform', `translate(${margin.left}px, ${height + margin.top}px)`)
.call(xAxis)
const yAxis = axisLeft(yScale)
svg
.select('.y-axis')
.style('transform', `translate(${margin.left}px, ${margin.top}px)`)
.call(yAxis)
function handleMouseDown(e) {
const coordinates = pointer(e)
//console.log(coordinates[0])
//console.log(xScale.domain())
}
function handleMouseMove(e) {
//console.log('handleMouseMove')
}
function handleMouseUp(e) {
const coordinates = pointer(e)
//console.log(coordinates[0])
}
svg
.select('.x-axis text')
.attr('text-anchor', 'middle')
.attr('x', width / 2)
.attr('y', 40)
.text('Time')
svg
.select('.y-axis text')
.attr('text-anchor', 'start')
.attr('transform', 'rotate(-90)')
.attr('x', -height / 2)
.attr('y', -30)
.text(gauge.unit)
const xAxisGrid = axisBottom(xScale).tickSize(height)
const xAxisGridGroup = svg
.select('.x-axis-grid')
.style('transform', `translate(${margin.left}px, ${margin.top}px)`)
xAxisGridGroup.call(xAxisGrid)
xAxisGridGroup.selectAll('.tick line').attr('opacity', 0.1)
xAxisGridGroup.call((g) => {
g.select('.domain').remove()
g.selectAll('.tick text').remove()
})
const yAxisGrid = axisLeft(yScale).tickSize(-width)
const yAxisGridGroup = svg
.select('.y-axis-grid')
.style('transform', `translate(${margin.left}px, ${margin.top}px)`)
yAxisGridGroup.call(yAxisGrid)
yAxisGridGroup.selectAll('.tick line').attr('opacity', 0.1)
yAxisGridGroup.call((g) => {
g.select('.domain').remove()
g.selectAll('.tick text').remove()
})
const originalLine = line()
.x((d) => xScale(d.readingDate))
.y((d) => yScale(d.reading))
const updatedLine = line()
.x((d) => xScale(d.readingDate))
.y((d) => (d.correctedReading ? yScale(d.correctedReading) : yScale(d.reading)))
function getYPercentage(reading) {
return yScale(reading) / yScale(yMin)
}
const criticalLowPercentage = getYPercentage(gauge.criticalLow)
const warningLowPercentage = getYPercentage(gauge.warningLow)
const warningHighPercentage = getYPercentage(gauge.warningHigh)
const criticalHighPercentage = getYPercentage(gauge.criticalHigh)
const gradientData = [
{ offset: '0', color: criticalColor },
{ offset: `${criticalHighPercentage}`, color: criticalColor },
{ offset: `${criticalHighPercentage}`, color: warningColor },
{ offset: `${warningHighPercentage}`, color: warningColor },
{ offset: `${warningHighPercentage}`, color: 'black' },
{ offset: `${warningLowPercentage}`, color: 'black' },
{ offset: `${warningLowPercentage}`, color: warningColor },
{ offset: `${criticalLowPercentage}`, color: warningColor },
{ offset: `${criticalLowPercentage}`, color: criticalColor },
{ offset: '1', color: criticalColor },
]
/*
svg
.select('.data .line-gradient')
.attr('id', 'line-gradient')
.attr('gradientUnits', 'userSpaceOnUse')
//.attr('gradientTransform', `rotate(180, ${width / 2}, ${height / 2})`)
.attr('x1', '0%')
.attr('x2', '0%')
.attr('y1', '0%')
.attr('y2', height - margin.top - margin.bottom)
.selectAll('stop')
.data(gradientData)
.join('stop')
.attr('offset', (d) => d.offset)
.attr('stop-color', (d) => d.color)
*/
svg
.select('.data .background-gradient')
.attr('id', 'background-gradient')
.attr('gradientUnits', 'userSpaceOnUse')
.attr('x1', '0%')
.attr('x2', '0%')
.attr('y1', '0%')
.attr('y2', height)
.selectAll('stop')
.data(gradientData)
.join('stop')
.attr('offset', (d) => d.offset)
.attr('stop-color', (d) => (d.color === 'black' ? 'green' : d.color))
svg
.select('.data rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', height)
.attr('fill', 'url(#background-gradient)')
.on('click', handleNonPointClick)
.on('mousedown', handleMouseDown)
.on('mousemove', handleMouseMove)
.on('mouseup', handleMouseUp)
.attr('opacity', 0.1)
svg
.select('.data .original path')
.datum(gauge.readings)
.attr('fill', 'none')
.attr('stroke', originalColor)
.attr('stroke-width', 1)
.attr('opacity', originalOpacity)
.attr('d', originalLine)
svg
.select('.data .original')
.selectAll('circle')
.data(readings)
.join('circle')
.attr('r', circleRadius - 1)
.style('fill', originalColor)
.attr('opacity', originalOpacity)
.attr('cx', (d) => xScale(d.readingDate))
.attr('cy', (d) => yScale(d.reading))
svg
.select('.data .updated path')
.datum(gauge.readings)
.attr('fill', 'none')
.attr('stroke-width', 1)
//.attr('stroke', 'url(#line-gradient)')
.attr('stroke', 'black')
.attr('d', updatedLine)
function getPointColor(d) {
//console.log('getPointColor')
//console.log(d.status)
if (d.status === 'Unverified') return unverifiedColor
return d.correctedReading ? colorScale(d.correctedReading) : colorScale(d.reading)
}
svg
.select('.data .updated')
.selectAll('circle')
.data(readings)
.join('circle')
.attr('r', circleRadius)
.attr('class', 'cursor-pointer')
.on('click', handlePointClick)
.attr('cx', (d) => xScale(d.readingDate))
.attr('cy', (d) => (d.correctedReading ? yScale(d.correctedReading) : yScale(d.reading)))
.style('fill', getPointColor)
const reading = readings.find((reading) => currentReading?.readingId === reading.readingId)
if (!reading) {
//console.log('no reading found, not handling selected point')
svg.select('.target path').attr('d', null).attr('style', null)
return
}
const pointSelect = arc()
.innerRadius(7)
.outerRadius(10)
.startAngle(100)
.endAngle(2 * 180)
svg
.select('.target')
.data([reading])
.join('g')
.attr(
'transform',
(d) =>
`translate(${margin.left + xScale(d.readingDate)}, ${
d.correctedReading
? margin.top + yScale(d.correctedReading)
: margin.top + yScale(d.reading)
})`
)
.select('path')
.style('fill', getPointColor)
.attr('d', pointSelect)
}, [start, end, gauge, currentReading?.readingId, currentReading?.correctedReading, currentReading?.status, dimensions])
return (
<div className="flex flex-col">
<h3 style={{ minHeight: '48px' }} className="py-3 pl-3 font-bold ">
{title}
</h3>
<hr className="mx-1" />
<div ref={wrapperRef} className="flex flex-grow">
<svg ref={svgRef} style={{ minHeight: '400px', minWidth: '100%' }}>
<g className="legend">
<text />
<g className="unverified">
<circle />
<text />
</g>
<g className="normal">
<circle />
<text />
</g>
<g className="warning">
<circle />
<text />
</g>
<g className="critical">
<circle />
<text />
</g>
<g className="incorrect">
<circle />
<text />
</g>
</g>
<g className="x-axis-grid" />
<g className="y-axis-grid" />
<g className="data">
<text></text>
<linearGradient className="background-gradient" />
{/*
<linearGradient className="line-gradient" />
*/}
<rect />
<g className="original">
<path />
</g>
<g className="updated">
<path />
</g>
</g>
<g className="left-block">
<rect />
</g>
<g className="right-block">
<rect />
</g>
<g className="x-axis">
<text className="text-lg font-bold" fill="black" />
</g>
<g className="y-axis">
<text className="text-lg font-bold" fill="black" />
</g>
<g className="target">
<path />
</g>
</svg>
</div>
</div>
)
})
@vuldin
Copy link
Author

vuldin commented Apr 2, 2022

This component is an example of the type of component being discussed in the following issue: redpanda-data/console#339

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment