Created
April 2, 2022 15:42
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This component is an example of the type of component being discussed in the following issue: redpanda-data/console#339