lamassu-server/packages/admin-ui/src/pages/Dashboard/SystemPerformance/Graphs/RefScatterplot.jsx
2025-05-13 07:30:38 +01:00

348 lines
9.2 KiB
JavaScript

import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, format, startOfWeek, startOfYear } from 'date-fns/fp'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { numberToFiatAmount } from '../../../../utils/number'
import { MINUTE, DAY, WEEK, MONTH } from '../../../../utils/time'
const Graph = ({ data, timeFrame, timezone }) => {
const ref = useRef(null)
const GRAPH_HEIGHT = 250
const GRAPH_WIDTH = 555
const GRAPH_MARGIN = useMemo(
() => ({
top: 20,
right: 3.5,
bottom: 27,
left: 33.5,
}),
[],
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
Day: [NOW - DAY, NOW],
Week: [NOW - WEEK, NOW],
Month: [NOW - MONTH, NOW],
}
const dataPoints = useMemo(
() => ({
Day: {
freq: 24,
step: 60 * 60 * 1000,
tick: d3.utcHour.every(4),
labelFormat: '%H:%M',
},
Week: {
freq: 7,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d',
},
Month: {
freq: 30,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(2),
labelFormat: '%d',
},
}),
[],
)
const filterDay = useCallback(
x => (timeFrame === 'Day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
[timeFrame],
)
const getPastAndCurrentDayLabels = useCallback(d => {
const currentDate = new Date(d)
const currentDateDay = currentDate.getUTCDate()
const currentDateWeekday = currentDate.getUTCDay()
const currentDateMonth = currentDate.getUTCMonth()
const previousDate = new Date(currentDate.getTime())
previousDate.setUTCDate(currentDateDay - 1)
const previousDateDay = previousDate.getUTCDate()
const previousDateWeekday = previousDate.getUTCDay()
const previousDateMonth = previousDate.getUTCMonth()
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
format('EEE', add({ days: i }, startOfWeek(new Date()))),
)
const months = Array.from(Array(12)).map((_, i) =>
format('LLL', add({ months: i }, startOfYear(new Date()))),
)
return {
previous:
currentDateMonth !== previousDateMonth
? months[previousDateMonth]
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
current:
currentDateMonth !== previousDateMonth
? months[currentDateMonth]
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`,
}
}, [])
const buildTicks = useCallback(
domain => {
const points = []
const roundDate = d => {
const step = dataPoints[timeFrame].step
return new Date(Math.ceil(d.valueOf() / step) * step)
}
for (let i = 0; i <= dataPoints[timeFrame].freq; i++) {
const stepDate = new Date(NOW - i * dataPoints[timeFrame].step)
if (roundDate(stepDate) > domain[1]) continue
if (stepDate < domain[0]) continue
points.push(roundDate(stepDate))
}
return points
},
[NOW, dataPoints, timeFrame],
)
const x = d3
.scaleUtc()
.domain(periodDomains[timeFrame])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
const y = d3
.scaleLinear()
.domain([
0,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.05,
])
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const buildBackground = useCallback(
g => {
g.append('rect')
.attr('x', 0)
.attr('y', GRAPH_MARGIN.top)
.attr('width', GRAPH_WIDTH)
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.top - GRAPH_MARGIN.bottom)
.attr('fill', 'var(--ghost)')
},
[GRAPH_MARGIN],
)
const buildXAxis = useCallback(
g =>
g
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`,
)
.call(
d3
.axisBottom(x)
.ticks(dataPoints[timeFrame].tick)
.tickFormat(d => {
return d3.timeFormat(dataPoints[timeFrame].labelFormat)(
d.getTime() + d.getTimezoneOffset() * MINUTE,
)
}),
)
.call(g => g.select('.domain').remove()),
[GRAPH_MARGIN, dataPoints, timeFrame, x],
)
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(
d3
.axisLeft(y)
.ticks(5)
.tickFormat(d => {
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
return numberToFiatAmount(d)
}),
)
.call(g => g.select('.domain').remove())
.selectAll('text')
.attr('dy', '-0.25rem'),
[GRAPH_MARGIN, y],
)
const buildGrid = useCallback(
g => {
g.attr('stroke', 'var(--zircon2)')
.attr('fill', 'var(--zircon2)')
// Vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()))
.join('line')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 1),
)
// Horizontal lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(d3.axisLeft(y).scale().ticks(5))
.join('line')
.attr('y1', d => 0.5 + y(d))
.attr('y2', d => 0.5 + y(d))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH),
)
// Thick vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()).filter(filterDay))
.join('line')
.attr('class', 'dateSeparator')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top - 10)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 2)
.join('text'),
)
// Left side breakpoint label
.call(g => {
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(filterDay)
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x - 7)
.attr('y', separator.y)
.attr('text-anchor', 'end')
.attr('dy', '.25em')
.text(labels.previous)
})
// Right side breakpoint label
.call(g => {
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(filterDay)
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x + 7)
.attr('y', separator.y)
.attr('text-anchor', 'start')
.attr('dy', '.25em')
.text(labels.current)
})
},
[GRAPH_MARGIN, buildTicks, getPastAndCurrentDayLabels, x, y, filterDay],
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', 'var(--comet)')
.style('fill', 'var(--comet)')
.style('stroke-width', 0)
.style('font-family', 'var(--museo)'),
[],
)
const formatText = useCallback(
() =>
d3
.selectAll('text')
.style('stroke', 'var(--comet)')
.style('fill', 'var(--comet)')
.style('stroke-width', 0)
.style('font-family', 'var(--museo)'),
[],
)
const formatTicks = useCallback(() => {
d3.selectAll('.tick line')
.style('stroke', 'transparent')
.style('fill', 'transparent')
}, [])
const drawData = useCallback(
g => {
g.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => {
const created = new Date(d.created)
return x(created.setTime(created.getTime() + offset))
})
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
.attr('fill', d =>
d.txClass === 'cashIn' ? 'var(--java)' : 'var(--neon)',
)
.attr('r', 3.5)
},
[data, offset, x, y],
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildBackground)
svg.append('g').call(buildGrid)
svg.append('g').call(buildXAxis)
svg.append('g').call(buildYAxis)
svg.append('g').call(formatTicksText)
svg.append('g').call(formatText)
svg.append('g').call(formatTicks)
svg.append('g').call(drawData)
return svg.node()
}, [
buildBackground,
buildGrid,
buildXAxis,
buildYAxis,
drawData,
formatText,
formatTicks,
formatTicksText,
])
useEffect(() => {
d3.select(ref.current).selectAll('*').remove()
drawChart()
}, [drawChart])
return <svg ref={ref} />
}
export default Graph