Merge pull request #911 from chaotixkilla/feat-analytics-screen-pt2
Analytics screen
This commit is contained in:
commit
9ec871e163
16 changed files with 2332 additions and 2 deletions
332
new-lamassu-admin/src/pages/Analytics/Analytics.js
Normal file
332
new-lamassu-admin/src/pages/Analytics/Analytics.js
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { Select } from 'src/components/inputs'
|
||||||
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
import { Info2, P } from 'src/components/typography'
|
||||||
|
import { ReactComponent as DownIcon } from 'src/styling/icons/dashboard/down.svg'
|
||||||
|
import { ReactComponent as EqualIcon } from 'src/styling/icons/dashboard/equal.svg'
|
||||||
|
import { ReactComponent as UpIcon } from 'src/styling/icons/dashboard/up.svg'
|
||||||
|
import { fromNamespace } from 'src/utils/config'
|
||||||
|
import { DAY, WEEK, MONTH } from 'src/utils/time'
|
||||||
|
|
||||||
|
import styles from './Analytics.styles'
|
||||||
|
import LegendEntry from './components/LegendEntry'
|
||||||
|
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
|
||||||
|
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
|
||||||
|
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
|
||||||
|
const REPRESENTING_OPTIONS = [
|
||||||
|
{ code: 'overTime', display: 'Over time' },
|
||||||
|
{ code: 'topMachines', display: 'Top Machines' },
|
||||||
|
{ code: 'hourOfTheDay', display: 'Hour of the day' }
|
||||||
|
]
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ code: 'day', display: 'Last 24 hours' },
|
||||||
|
{ code: 'week', display: 'Last 7 days' },
|
||||||
|
{ code: 'month', display: 'Last 30 days' }
|
||||||
|
]
|
||||||
|
const TIME_OPTIONS = {
|
||||||
|
day: DAY,
|
||||||
|
week: WEEK,
|
||||||
|
month: MONTH
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET_TRANSACTIONS = gql`
|
||||||
|
query transactions($limit: Int, $from: Date, $until: Date) {
|
||||||
|
transactions(limit: $limit, from: $from, until: $until) {
|
||||||
|
id
|
||||||
|
txClass
|
||||||
|
txHash
|
||||||
|
toAddress
|
||||||
|
commissionPercentage
|
||||||
|
expired
|
||||||
|
machineName
|
||||||
|
operatorCompleted
|
||||||
|
sendConfirmed
|
||||||
|
dispense
|
||||||
|
hasError: error
|
||||||
|
deviceId
|
||||||
|
fiat
|
||||||
|
cashInFee
|
||||||
|
fiatCode
|
||||||
|
cryptoAtoms
|
||||||
|
cryptoCode
|
||||||
|
toAddress
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const GET_DATA = gql`
|
||||||
|
query getData {
|
||||||
|
config
|
||||||
|
machines {
|
||||||
|
name
|
||||||
|
deviceId
|
||||||
|
}
|
||||||
|
fiatRates {
|
||||||
|
code
|
||||||
|
name
|
||||||
|
rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const OverviewEntry = ({ label, value, oldValue, currency }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue
|
||||||
|
const growthRate = ((value - oldValue) * 100) / _oldValue
|
||||||
|
|
||||||
|
const growthClasses = {
|
||||||
|
[classes.growthPercentage]: true,
|
||||||
|
[classes.growth]: R.gt(value, oldValue),
|
||||||
|
[classes.decline]: R.gt(oldValue, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.overviewEntry}>
|
||||||
|
<P noMargin>{label}</P>
|
||||||
|
<Info2 noMargin className={classes.overviewFieldWrapper}>
|
||||||
|
<span>
|
||||||
|
{value.toLocaleString('en-US', { maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
{!!currency && ` ${currency}`}
|
||||||
|
</Info2>
|
||||||
|
<span className={classes.overviewGrowth}>
|
||||||
|
{R.gt(growthRate, 0) && <UpIcon height={10} />}
|
||||||
|
{R.lt(growthRate, 0) && <DownIcon height={10} />}
|
||||||
|
{R.equals(growthRate, 0) && <EqualIcon height={10} />}
|
||||||
|
<P noMargin className={classnames(growthClasses)}>
|
||||||
|
{growthRate.toLocaleString('en-US', { maximumFractionDigits: 2 })}%
|
||||||
|
</P>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Analytics = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS)
|
||||||
|
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||||
|
|
||||||
|
const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0])
|
||||||
|
const [period, setPeriod] = useState(PERIOD_OPTIONS[0])
|
||||||
|
const [machine, setMachine] = useState(MACHINE_OPTIONS[0])
|
||||||
|
|
||||||
|
const loading = txLoading || configLoading
|
||||||
|
|
||||||
|
const transactions = R.path(['transactions'])(txResponse) ?? []
|
||||||
|
const machines = R.path(['machines'])(configResponse) ?? []
|
||||||
|
const config = R.path(['config'])(configResponse) ?? []
|
||||||
|
const rates = R.path(['fiatRates'])(configResponse) ?? []
|
||||||
|
const fiatLocale = fromNamespace('locale')(config).fiatCurrency
|
||||||
|
|
||||||
|
const timezone = config?.locale_timezone
|
||||||
|
|
||||||
|
const convertFiatToLocale = item => {
|
||||||
|
if (item.fiatCode === fiatLocale) return item
|
||||||
|
const itemRate = R.find(R.propEq('code', item.fiatCode))(rates)
|
||||||
|
const localeRate = R.find(R.propEq('code', fiatLocale))(rates)
|
||||||
|
const multiplier = localeRate?.rate / itemRate?.rate
|
||||||
|
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
R.map(convertFiatToLocale)(
|
||||||
|
transactions?.filter(
|
||||||
|
tx =>
|
||||||
|
(!tx.dispensed || !tx.expired) && (tx.sendConfirmed || tx.dispense)
|
||||||
|
)
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
const machineOptions = R.clone(MACHINE_OPTIONS)
|
||||||
|
|
||||||
|
R.forEach(
|
||||||
|
m => machineOptions.push({ code: m.deviceId, display: m.name }),
|
||||||
|
machines
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineTxs = R.filter(
|
||||||
|
tx => (machine.code === 'all' ? true : tx.deviceId === machine.code),
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredData = timeInterval => ({
|
||||||
|
current:
|
||||||
|
machineTxs.filter(
|
||||||
|
d => new Date(d.created) >= Date.now() - TIME_OPTIONS[timeInterval]
|
||||||
|
) ?? [],
|
||||||
|
previous:
|
||||||
|
machineTxs.filter(
|
||||||
|
d =>
|
||||||
|
new Date(d.created) < Date.now() - TIME_OPTIONS[timeInterval] &&
|
||||||
|
new Date(d.created) >= Date.now() - 2 * TIME_OPTIONS[timeInterval]
|
||||||
|
) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const txs = {
|
||||||
|
current: filteredData(period.code).current.length,
|
||||||
|
previous: filteredData(period.code).previous.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgAmount = {
|
||||||
|
current:
|
||||||
|
R.sum(R.map(d => d.fiat, filteredData(period.code).current)) /
|
||||||
|
(txs.current === 0 ? 1 : txs.current),
|
||||||
|
previous:
|
||||||
|
R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) /
|
||||||
|
(txs.previous === 0 ? 1 : txs.previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
const txVolume = {
|
||||||
|
current: R.sum(R.map(d => d.fiat, filteredData(period.code).current)),
|
||||||
|
previous: R.sum(R.map(d => d.fiat, filteredData(period.code).previous))
|
||||||
|
}
|
||||||
|
|
||||||
|
const commissions = {
|
||||||
|
current: R.sum(
|
||||||
|
R.map(
|
||||||
|
d => d.fiat * d.commissionPercentage,
|
||||||
|
filteredData(period.code).current
|
||||||
|
)
|
||||||
|
),
|
||||||
|
previous: R.sum(
|
||||||
|
R.map(
|
||||||
|
d => d.fiat * d.commissionPercentage,
|
||||||
|
filteredData(period.code).previous
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGraphInfo = representing => {
|
||||||
|
switch (representing.code) {
|
||||||
|
case 'overTime':
|
||||||
|
return (
|
||||||
|
<OverTimeWrapper
|
||||||
|
title="Transactions over time"
|
||||||
|
representing={representing}
|
||||||
|
period={period}
|
||||||
|
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||||
|
machines={machineOptions}
|
||||||
|
selectedMachine={machine}
|
||||||
|
handleMachineChange={setMachine}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'topMachines':
|
||||||
|
return (
|
||||||
|
<TopMachinesWrapper
|
||||||
|
title="Transactions over time"
|
||||||
|
representing={representing}
|
||||||
|
period={period}
|
||||||
|
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||||
|
machines={machineOptions}
|
||||||
|
selectedMachine={machine}
|
||||||
|
handleMachineChange={setMachine}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'hourOfTheDay':
|
||||||
|
return (
|
||||||
|
<HourOfDayWrapper
|
||||||
|
title="Avg. transactions per hour of the day"
|
||||||
|
representing={representing}
|
||||||
|
period={period}
|
||||||
|
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||||
|
machines={machineOptions}
|
||||||
|
selectedMachine={machine}
|
||||||
|
handleMachineChange={setMachine}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw new Error(`There's no graph info to represent ${representing}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
!loading && (
|
||||||
|
<>
|
||||||
|
<TitleSection title="Analytics">
|
||||||
|
<Box className={classes.overviewLegend}>
|
||||||
|
<LegendEntry
|
||||||
|
IconComponent={UpIcon}
|
||||||
|
label={'Up since last period'}
|
||||||
|
/>
|
||||||
|
<LegendEntry
|
||||||
|
IconComponent={DownIcon}
|
||||||
|
label={'Down since last period'}
|
||||||
|
/>
|
||||||
|
<LegendEntry
|
||||||
|
IconComponent={EqualIcon}
|
||||||
|
label={'Same since last period'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</TitleSection>
|
||||||
|
<div className={classes.dropdownsOverviewWrapper}>
|
||||||
|
<div className={classes.dropdowns}>
|
||||||
|
<Select
|
||||||
|
label="Representing"
|
||||||
|
onSelectedItemChange={setRepresenting}
|
||||||
|
items={REPRESENTING_OPTIONS}
|
||||||
|
default={REPRESENTING_OPTIONS[0]}
|
||||||
|
selectedItem={representing}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Time period"
|
||||||
|
onSelectedItemChange={setPeriod}
|
||||||
|
items={PERIOD_OPTIONS}
|
||||||
|
default={PERIOD_OPTIONS[0]}
|
||||||
|
selectedItem={period}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classes.overview}>
|
||||||
|
<OverviewEntry
|
||||||
|
label="Transactions"
|
||||||
|
value={txs.current}
|
||||||
|
oldValue={txs.previous}
|
||||||
|
/>
|
||||||
|
<div className={classes.verticalLine} />
|
||||||
|
<OverviewEntry
|
||||||
|
label="Avg. txn amount"
|
||||||
|
value={avgAmount.current}
|
||||||
|
oldValue={avgAmount.previous}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
<div className={classes.verticalLine} />
|
||||||
|
<OverviewEntry
|
||||||
|
label="Volume"
|
||||||
|
value={txVolume.current}
|
||||||
|
oldValue={txVolume.previous}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
<div className={classes.verticalLine} />
|
||||||
|
<OverviewEntry
|
||||||
|
label="Commissions"
|
||||||
|
value={commissions.current}
|
||||||
|
oldValue={commissions.previous}
|
||||||
|
currency={fiatLocale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getGraphInfo(representing)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Analytics
|
||||||
141
new-lamassu-admin/src/pages/Analytics/Analytics.styles.js
Normal file
141
new-lamassu-admin/src/pages/Analytics/Analytics.styles.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { offDarkColor, tomato, neon, java } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
overviewLegend: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
'& span': {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
'& > :last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legendEntry: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& > :first-child': {
|
||||||
|
marginRight: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropdownsOverviewWrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16
|
||||||
|
},
|
||||||
|
verticalLine: {
|
||||||
|
height: 64,
|
||||||
|
width: 1,
|
||||||
|
border: 'solid',
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: offDarkColor
|
||||||
|
},
|
||||||
|
dropdowns: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
'& div': {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
'& > :last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& div': {
|
||||||
|
marginRight: 40
|
||||||
|
},
|
||||||
|
'& > :last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overviewFieldWrapper: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
'& span': {
|
||||||
|
fontSize: 24
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overviewGrowth: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& p': {
|
||||||
|
marginLeft: 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
growthPercentage: {
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
color: '#00CD5A'
|
||||||
|
},
|
||||||
|
decline: {
|
||||||
|
color: tomato
|
||||||
|
},
|
||||||
|
// Graph
|
||||||
|
graphHeaderWrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 40
|
||||||
|
},
|
||||||
|
graphHeaderLeft: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
},
|
||||||
|
graphHeaderRight: {
|
||||||
|
marginTop: 15,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
'& > *': {
|
||||||
|
marginRight: 30,
|
||||||
|
'&:last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
graphLegend: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
'& span': {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
'& > :last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
machineSelector: {
|
||||||
|
width: 248
|
||||||
|
},
|
||||||
|
cashInIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: java
|
||||||
|
},
|
||||||
|
cashOutIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: neon
|
||||||
|
},
|
||||||
|
txIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#000'
|
||||||
|
},
|
||||||
|
topMachinesRadio: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styles
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { P } from 'src/components/typography'
|
||||||
|
|
||||||
|
import styles from '../Analytics.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const LegendEntry = ({ IconElement, IconComponent, label }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classes.legendEntry}>
|
||||||
|
{!!IconComponent && <IconComponent height={12} />}
|
||||||
|
{!!IconElement && IconElement}
|
||||||
|
<P>{label}</P>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LegendEntry
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Paper } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
import { Info2, Label3, P } from 'src/components/typography'
|
||||||
|
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
||||||
|
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
|
||||||
|
import { singularOrPlural } from 'src/utils/string'
|
||||||
|
import { formatDate, formatDateNonUtc } from 'src/utils/timezones'
|
||||||
|
|
||||||
|
import styles from './GraphTooltip.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const formatCurrency = amount =>
|
||||||
|
amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
const GraphTooltip = ({
|
||||||
|
coords,
|
||||||
|
data,
|
||||||
|
dateInterval,
|
||||||
|
period,
|
||||||
|
currency,
|
||||||
|
representing
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles(coords)
|
||||||
|
|
||||||
|
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
|
||||||
|
? [
|
||||||
|
formatDate(
|
||||||
|
dateInterval[1],
|
||||||
|
null,
|
||||||
|
period.code === 'day' ? 'MMM D, HH:mm' : 'MMM D'
|
||||||
|
),
|
||||||
|
formatDate(
|
||||||
|
dateInterval[0],
|
||||||
|
null,
|
||||||
|
period.code === 'day' ? 'HH:mm' : 'MMM D'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
formatDateNonUtc(dateInterval[1], 'HH:mm'),
|
||||||
|
formatDateNonUtc(dateInterval[0], 'HH:mm')
|
||||||
|
]
|
||||||
|
|
||||||
|
const transactions = R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
acc.volume += parseInt(value.fiat)
|
||||||
|
if (value.txClass === 'cashIn') acc.cashIn++
|
||||||
|
if (value.txClass === 'cashOut') acc.cashOut++
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ volume: 0, cashIn: 0, cashOut: 0 },
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className={classes.dotOtWrapper}>
|
||||||
|
<Info2 noMargin>
|
||||||
|
{period.code === 'day' || R.includes('hourOfDay', representing.code)
|
||||||
|
? `${formattedDateInterval[0]} - ${formattedDateInterval[1]}`
|
||||||
|
: `${formattedDateInterval[0]}`}
|
||||||
|
</Info2>
|
||||||
|
<P noMargin className={classes.dotOtTransactionAmount}>
|
||||||
|
{R.length(data)}{' '}
|
||||||
|
{singularOrPlural(R.length(data), 'transaction', 'transactions')}
|
||||||
|
</P>
|
||||||
|
<P noMargin className={classes.dotOtTransactionVolume}>
|
||||||
|
{formatCurrency(transactions.volume)} {currency} in volume
|
||||||
|
</P>
|
||||||
|
<div className={classes.dotOtTransactionClasses}>
|
||||||
|
<Label3 noMargin>
|
||||||
|
<TxInIcon />
|
||||||
|
<span>{transactions.cashIn} cash-in</span>
|
||||||
|
</Label3>
|
||||||
|
<Label3 noMargin>
|
||||||
|
<TxOutIcon />
|
||||||
|
<span>{transactions.cashOut} cash-out</span>
|
||||||
|
</Label3>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { comet } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
dotOtWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: coords => coords?.y ?? 0,
|
||||||
|
left: coords => coords?.x ?? 0,
|
||||||
|
width: 150,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8
|
||||||
|
},
|
||||||
|
dotOtTransactionAmount: {
|
||||||
|
margin: [[8, 0, 8, 0]]
|
||||||
|
},
|
||||||
|
dotOtTransactionVolume: {
|
||||||
|
color: comet
|
||||||
|
},
|
||||||
|
dotOtTransactionClasses: {
|
||||||
|
marginTop: 15,
|
||||||
|
'& p > span': {
|
||||||
|
marginLeft: 5
|
||||||
|
},
|
||||||
|
'& p:last-child': {
|
||||||
|
marginTop: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styles
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { RadioGroup, Select } from 'src/components/inputs'
|
||||||
|
import { H2 } from 'src/components/typography'
|
||||||
|
import { MINUTE } from 'src/utils/time'
|
||||||
|
|
||||||
|
import styles from '../../Analytics.styles'
|
||||||
|
import Graph from '../../graphs/Graph'
|
||||||
|
import LegendEntry from '../LegendEntry'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ code: 'hourOfDayTransactions', display: 'Transactions' },
|
||||||
|
{ code: 'hourOfDayVolume', display: 'Volume' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const dayOptions = R.map(
|
||||||
|
it => ({
|
||||||
|
code: R.toLower(it),
|
||||||
|
display: it
|
||||||
|
}),
|
||||||
|
moment.weekdays()
|
||||||
|
)
|
||||||
|
|
||||||
|
const HourOfDayBarGraphHeader = ({
|
||||||
|
title,
|
||||||
|
period,
|
||||||
|
data,
|
||||||
|
machines,
|
||||||
|
selectedMachine,
|
||||||
|
handleMachineChange,
|
||||||
|
timezone,
|
||||||
|
currency
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [graphType, setGraphType] = useState(options[0].code)
|
||||||
|
const [selectedDay, setSelectedDay] = useState(dayOptions[0])
|
||||||
|
|
||||||
|
const legend = {
|
||||||
|
cashIn: <div className={classes.cashInIcon}></div>,
|
||||||
|
cashOut: <div className={classes.cashOutIcon}></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = parseInt(timezone.split(':')[1]) * MINUTE
|
||||||
|
|
||||||
|
const txsPerWeekday = R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
const created = new Date(value.created)
|
||||||
|
// console.log('before', R.clone(created))
|
||||||
|
created.setTime(
|
||||||
|
created.getTime() + created.getTimezoneOffset() * MINUTE + offset
|
||||||
|
)
|
||||||
|
// console.log('after', R.clone(created))
|
||||||
|
switch (created.getDay()) {
|
||||||
|
case 0:
|
||||||
|
acc.sunday.push(value)
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
acc.monday.push(value)
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
acc.tuesday.push(value)
|
||||||
|
break
|
||||||
|
case 3:
|
||||||
|
acc.wednesday.push(value)
|
||||||
|
break
|
||||||
|
case 4:
|
||||||
|
acc.thursday.push(value)
|
||||||
|
break
|
||||||
|
case 5:
|
||||||
|
acc.friday.push(value)
|
||||||
|
break
|
||||||
|
case 6:
|
||||||
|
acc.saturday.push(value)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Day of week not recognized')
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
R.fromPairs(R.map(it => [it.code, []], dayOptions)),
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.graphHeaderWrapper}>
|
||||||
|
<div className={classes.graphHeaderLeft}>
|
||||||
|
<H2 noMargin>{title}</H2>
|
||||||
|
<Box className={classes.graphLegend}>
|
||||||
|
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||||
|
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div className={classes.graphHeaderRight}>
|
||||||
|
<RadioGroup
|
||||||
|
options={options}
|
||||||
|
className={classes.topMachinesRadio}
|
||||||
|
value={graphType}
|
||||||
|
onChange={e => setGraphType(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Day of the week"
|
||||||
|
items={dayOptions}
|
||||||
|
default={dayOptions[0]}
|
||||||
|
selectedItem={selectedDay}
|
||||||
|
onSelectedItemChange={setSelectedDay}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Machines"
|
||||||
|
onSelectedItemChange={handleMachineChange}
|
||||||
|
items={machines}
|
||||||
|
default={machines[0]}
|
||||||
|
selectedItem={selectedMachine}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Graph
|
||||||
|
representing={R.find(it => it.code === graphType)(options)}
|
||||||
|
period={period}
|
||||||
|
data={txsPerWeekday[selectedDay.code]}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={currency}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={machines}
|
||||||
|
selectedDay={selectedDay}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HourOfDayBarGraphHeader
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Select } from 'src/components/inputs'
|
||||||
|
import { H2 } from 'src/components/typography'
|
||||||
|
import { primaryColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
import styles from '../../Analytics.styles'
|
||||||
|
import Graph from '../../graphs/Graph'
|
||||||
|
import LegendEntry from '../LegendEntry'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const OverTimeDotGraphHeader = ({
|
||||||
|
title,
|
||||||
|
representing,
|
||||||
|
period,
|
||||||
|
data,
|
||||||
|
machines,
|
||||||
|
selectedMachine,
|
||||||
|
handleMachineChange,
|
||||||
|
timezone,
|
||||||
|
currency
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const legend = {
|
||||||
|
cashIn: <div className={classes.cashInIcon}></div>,
|
||||||
|
cashOut: <div className={classes.cashOutIcon}></div>,
|
||||||
|
transaction: <div className={classes.txIcon}></div>,
|
||||||
|
average: (
|
||||||
|
<svg height="12" width="18">
|
||||||
|
<path
|
||||||
|
stroke={primaryColor}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray="5, 2"
|
||||||
|
d="M 5 6 l 20 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.graphHeaderWrapper}>
|
||||||
|
<div className={classes.graphHeaderLeft}>
|
||||||
|
<H2 noMargin>{title}</H2>
|
||||||
|
<Box className={classes.graphLegend}>
|
||||||
|
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||||
|
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||||
|
<LegendEntry
|
||||||
|
IconElement={legend.transaction}
|
||||||
|
label={'One transaction'}
|
||||||
|
/>
|
||||||
|
<LegendEntry IconElement={legend.average} label={'Average'} />
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div className={classes.graphHeaderRight}>
|
||||||
|
<Select
|
||||||
|
label="Machines"
|
||||||
|
onSelectedItemChange={handleMachineChange}
|
||||||
|
items={machines}
|
||||||
|
default={machines[0]}
|
||||||
|
selectedItem={selectedMachine}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Graph
|
||||||
|
representing={representing}
|
||||||
|
period={period}
|
||||||
|
data={data}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={currency}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={machines}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverTimeDotGraphHeader
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { RadioGroup } from 'src/components/inputs'
|
||||||
|
import { H2 } from 'src/components/typography'
|
||||||
|
|
||||||
|
import styles from '../../Analytics.styles'
|
||||||
|
import Graph from '../../graphs/Graph'
|
||||||
|
import LegendEntry from '../LegendEntry'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ code: 'topMachinesTransactions', display: 'Transactions' },
|
||||||
|
{ code: 'topMachinesVolume', display: 'Volume' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const TopMachinesBarGraphHeader = ({
|
||||||
|
title,
|
||||||
|
period,
|
||||||
|
data,
|
||||||
|
machines,
|
||||||
|
selectedMachine,
|
||||||
|
timezone,
|
||||||
|
currency
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [graphType, setGraphType] = useState(options[0].code)
|
||||||
|
|
||||||
|
const legend = {
|
||||||
|
cashIn: <div className={classes.cashInIcon}></div>,
|
||||||
|
cashOut: <div className={classes.cashOutIcon}></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.graphHeaderWrapper}>
|
||||||
|
<div className={classes.graphHeaderLeft}>
|
||||||
|
<H2 noMargin>{title}</H2>
|
||||||
|
<Box className={classes.graphLegend}>
|
||||||
|
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||||
|
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div className={classes.graphHeaderRight}>
|
||||||
|
<RadioGroup
|
||||||
|
options={options}
|
||||||
|
className={classes.topMachinesRadio}
|
||||||
|
value={graphType}
|
||||||
|
onChange={e => setGraphType(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Graph
|
||||||
|
representing={R.find(R.propEq('code', graphType), options)}
|
||||||
|
period={period}
|
||||||
|
data={data}
|
||||||
|
timezone={timezone}
|
||||||
|
currency={currency}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={machines}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopMachinesBarGraphHeader
|
||||||
119
new-lamassu-admin/src/pages/Analytics/graphs/Graph.js
Normal file
119
new-lamassu-admin/src/pages/Analytics/graphs/Graph.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useState } from 'react'
|
||||||
|
|
||||||
|
import GraphTooltip from '../components/tooltips/GraphTooltip'
|
||||||
|
|
||||||
|
import HourOfDayBarGraph from './HourOfDayBarGraph'
|
||||||
|
import OverTimeDotGraph from './OverTimeDotGraph'
|
||||||
|
import TopMachinesBarGraph from './TopMachinesBarGraph'
|
||||||
|
|
||||||
|
const GraphWrapper = ({
|
||||||
|
data,
|
||||||
|
representing,
|
||||||
|
period,
|
||||||
|
timezone,
|
||||||
|
currency,
|
||||||
|
selectedMachine,
|
||||||
|
machines,
|
||||||
|
selectedDay
|
||||||
|
}) => {
|
||||||
|
const [selectionCoords, setSelectionCoords] = useState(null)
|
||||||
|
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
|
||||||
|
const [selectionData, setSelectionData] = useState(null)
|
||||||
|
|
||||||
|
const getGraph = representing => {
|
||||||
|
switch (representing.code) {
|
||||||
|
case 'overTime':
|
||||||
|
return (
|
||||||
|
<OverTimeDotGraph
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
timezone={timezone}
|
||||||
|
setSelectionCoords={setSelectionCoords}
|
||||||
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
|
setSelectionData={setSelectionData}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'topMachinesVolume':
|
||||||
|
return (
|
||||||
|
<TopMachinesBarGraph
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
timezone={timezone}
|
||||||
|
setSelectionCoords={setSelectionCoords}
|
||||||
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
|
setSelectionData={setSelectionData}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={R.filter(it => it.code !== 'all', machines)}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'topMachinesTransactions':
|
||||||
|
return (
|
||||||
|
<TopMachinesBarGraph
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
timezone={timezone}
|
||||||
|
setSelectionCoords={setSelectionCoords}
|
||||||
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
|
setSelectionData={setSelectionData}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={R.filter(it => it.code !== 'all', machines)}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'hourOfDayVolume':
|
||||||
|
return (
|
||||||
|
<HourOfDayBarGraph
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
timezone={timezone}
|
||||||
|
setSelectionCoords={setSelectionCoords}
|
||||||
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
|
setSelectionData={setSelectionData}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={R.filter(it => it.code !== 'all', machines)}
|
||||||
|
currency={currency}
|
||||||
|
selectedDay={selectedDay}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'hourOfDayTransactions':
|
||||||
|
return (
|
||||||
|
<HourOfDayBarGraph
|
||||||
|
data={data}
|
||||||
|
period={period}
|
||||||
|
timezone={timezone}
|
||||||
|
setSelectionCoords={setSelectionCoords}
|
||||||
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
|
setSelectionData={setSelectionData}
|
||||||
|
selectedMachine={selectedMachine}
|
||||||
|
machines={R.filter(it => it.code !== 'all', machines)}
|
||||||
|
currency={currency}
|
||||||
|
selectedDay={selectedDay}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw new Error(`There's no graph to represent ${representing}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!R.isNil(selectionCoords) && (
|
||||||
|
<GraphTooltip
|
||||||
|
coords={selectionCoords}
|
||||||
|
dateInterval={selectionDateInterval}
|
||||||
|
data={selectionData}
|
||||||
|
period={period}
|
||||||
|
currency={currency}
|
||||||
|
timezone={timezone}
|
||||||
|
representing={representing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{getGraph(representing)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(GraphWrapper)
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
import BigNumber from 'bignumber.js'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
java,
|
||||||
|
neon,
|
||||||
|
subheaderDarkColor,
|
||||||
|
fontColor,
|
||||||
|
fontSecondary,
|
||||||
|
subheaderColor
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
import { MINUTE } from 'src/utils/time'
|
||||||
|
|
||||||
|
const Graph = ({
|
||||||
|
data,
|
||||||
|
timezone,
|
||||||
|
setSelectionCoords,
|
||||||
|
setSelectionData,
|
||||||
|
setSelectionDateInterval
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const GRAPH_POPOVER_WIDTH = 150
|
||||||
|
const GRAPH_POPOVER_MARGIN = 25
|
||||||
|
const BAR_MARGIN = 10
|
||||||
|
const GRAPH_HEIGHT = 401
|
||||||
|
const GRAPH_WIDTH = 1163
|
||||||
|
const GRAPH_MARGIN = useMemo(
|
||||||
|
() => ({
|
||||||
|
top: 25,
|
||||||
|
right: 0.5,
|
||||||
|
bottom: 27,
|
||||||
|
left: 36.5
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const offset = parseInt(timezone.split(':')[1]) * MINUTE
|
||||||
|
|
||||||
|
const getTickIntervals = (domain, interval) => {
|
||||||
|
const ticks = []
|
||||||
|
const start = new Date(domain[0])
|
||||||
|
const end = new Date(domain[1])
|
||||||
|
|
||||||
|
const step = R.clone(start)
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unmodified-loop-condition
|
||||||
|
while (step <= end) {
|
||||||
|
ticks.push(R.clone(step))
|
||||||
|
step.setUTCHours(step.getUTCHours() + interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterByHourInterval = useCallback(
|
||||||
|
(lowerBound, upperBound) =>
|
||||||
|
R.filter(it => {
|
||||||
|
const tzCreated = new Date(it.created).setTime(
|
||||||
|
new Date(it.created).getTime() +
|
||||||
|
new Date(it.created).getTimezoneOffset() * MINUTE +
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
const created = new Date(tzCreated)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(lowerBound.getUTCHours() < upperBound.getUTCHours() &&
|
||||||
|
created.getUTCHours() >= new Date(lowerBound).getUTCHours() &&
|
||||||
|
created.getUTCHours() < new Date(upperBound).getUTCHours()) ||
|
||||||
|
(lowerBound.getUTCHours() > upperBound.getUTCHours() &&
|
||||||
|
created.getUTCHours() <= new Date(lowerBound).getUTCHours() &&
|
||||||
|
created.getUTCHours() < new Date(upperBound).getUTCHours())
|
||||||
|
)
|
||||||
|
}, data),
|
||||||
|
[data, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const txClassByHourInterval = useCallback(
|
||||||
|
(lowerBound, upperBound) =>
|
||||||
|
R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
if (value.txClass === 'cashIn')
|
||||||
|
acc.cashIn += BigNumber(value.fiat).toNumber()
|
||||||
|
if (value.txClass === 'cashOut')
|
||||||
|
acc.cashOut += BigNumber(value.fiat).toNumber()
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ cashIn: 0, cashOut: 0 },
|
||||||
|
filterByHourInterval(lowerBound, upperBound)
|
||||||
|
),
|
||||||
|
[filterByHourInterval]
|
||||||
|
)
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scaleUtc()
|
||||||
|
.domain([
|
||||||
|
moment()
|
||||||
|
.startOf('day')
|
||||||
|
.utc(),
|
||||||
|
moment()
|
||||||
|
.startOf('day')
|
||||||
|
.add(1, 'day')
|
||||||
|
.utc()
|
||||||
|
])
|
||||||
|
.rangeRound([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||||
|
|
||||||
|
const groupedByDateInterval = R.map(it => {
|
||||||
|
const lowerBound = R.clone(it)
|
||||||
|
it.setUTCHours(it.getUTCHours() + 2)
|
||||||
|
const upperBound = R.clone(it)
|
||||||
|
return [lowerBound, filterByHourInterval(lowerBound, upperBound)]
|
||||||
|
}, R.init(getTickIntervals(x.domain(), 2)))
|
||||||
|
|
||||||
|
const groupedByTxClass = R.map(it => {
|
||||||
|
const lowerBound = R.clone(it)
|
||||||
|
it.setUTCHours(it.getUTCHours() + 2)
|
||||||
|
const upperBound = R.clone(it)
|
||||||
|
return [lowerBound, txClassByHourInterval(lowerBound, upperBound)]
|
||||||
|
}, R.init(getTickIntervals(x.domain(), 2)))
|
||||||
|
|
||||||
|
const y = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain([
|
||||||
|
0,
|
||||||
|
d3.max(
|
||||||
|
groupedByTxClass.map(it => it[1]),
|
||||||
|
d => d.cashIn + d.cashOut
|
||||||
|
) !== 0
|
||||||
|
? d3.max(
|
||||||
|
groupedByTxClass.map(it => it[1]),
|
||||||
|
d => d.cashIn + d.cashOut
|
||||||
|
)
|
||||||
|
: 50
|
||||||
|
])
|
||||||
|
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||||
|
|
||||||
|
const buildXAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||||
|
)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.ticks(d3.timeHour.every(2))
|
||||||
|
.tickFormat(d3.timeFormat('%H:%M'))
|
||||||
|
),
|
||||||
|
[GRAPH_MARGIN, x]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildYAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.ticks(GRAPH_HEIGHT / 100)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickFormat(``)
|
||||||
|
)
|
||||||
|
.call(g => g.select('.domain').remove()),
|
||||||
|
[GRAPH_MARGIN, y]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildVerticalLines = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr('stroke', subheaderDarkColor)
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(getTickIntervals(x.domain(), 2))
|
||||||
|
.join('line')
|
||||||
|
.attr('x1', d => {
|
||||||
|
const xValue = x(d)
|
||||||
|
const intervals = getTickIntervals(x.domain(), 2)
|
||||||
|
return xValue === x(intervals[R.length(intervals) - 1])
|
||||||
|
? xValue - 1
|
||||||
|
: 0.5 + xValue
|
||||||
|
})
|
||||||
|
.attr('x2', d => {
|
||||||
|
const xValue = x(d)
|
||||||
|
const intervals = getTickIntervals(x.domain(), 2)
|
||||||
|
return xValue === x(intervals[R.length(intervals) - 1])
|
||||||
|
? xValue - 1
|
||||||
|
: 0.5 + xValue
|
||||||
|
})
|
||||||
|
.attr('y1', GRAPH_MARGIN.top)
|
||||||
|
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom),
|
||||||
|
[GRAPH_MARGIN, x]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildHoverableEventRects = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(getTickIntervals(x.domain(), 2))
|
||||||
|
.join('rect')
|
||||||
|
.attr('x', d => x(d))
|
||||||
|
.attr('y', GRAPH_MARGIN.top)
|
||||||
|
.attr('width', d => {
|
||||||
|
const xValue = Math.round(x(d) * 100) / 100
|
||||||
|
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||||
|
|
||||||
|
const index = R.findIndex(it => it === xValue, ticks)
|
||||||
|
const width =
|
||||||
|
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||||
|
|
||||||
|
return Math.round(width * 100) / 100
|
||||||
|
})
|
||||||
|
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
|
||||||
|
.attr('stroke', 'transparent')
|
||||||
|
.attr('fill', 'transparent')
|
||||||
|
.on('mouseover', d => {
|
||||||
|
const date = R.clone(new Date(d.target.__data__))
|
||||||
|
const startDate = R.clone(date)
|
||||||
|
date.setUTCHours(date.getUTCHours() + 2)
|
||||||
|
const endDate = R.clone(date)
|
||||||
|
|
||||||
|
const filteredData = groupedByDateInterval.find(it =>
|
||||||
|
R.equals(startDate, it[0])
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
const rectXCoords = {
|
||||||
|
left: R.clone(d.target.getBoundingClientRect().x),
|
||||||
|
right: R.clone(
|
||||||
|
d.target.getBoundingClientRect().x +
|
||||||
|
d.target.getBoundingClientRect().width
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xCoord =
|
||||||
|
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
|
||||||
|
? rectXCoords.right + GRAPH_POPOVER_MARGIN
|
||||||
|
: rectXCoords.left - GRAPH_POPOVER_WIDTH - GRAPH_POPOVER_MARGIN
|
||||||
|
const yCoord = R.clone(d.target.getBoundingClientRect().y)
|
||||||
|
|
||||||
|
setSelectionDateInterval([endDate, startDate])
|
||||||
|
setSelectionData(filteredData)
|
||||||
|
setSelectionCoords({
|
||||||
|
x: Math.round(xCoord),
|
||||||
|
y: Math.round(yCoord)
|
||||||
|
})
|
||||||
|
|
||||||
|
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
|
||||||
|
'fill',
|
||||||
|
subheaderColor
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on('mouseleave', d => {
|
||||||
|
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
|
||||||
|
'fill',
|
||||||
|
'transparent'
|
||||||
|
)
|
||||||
|
setSelectionDateInterval(null)
|
||||||
|
setSelectionData(null)
|
||||||
|
setSelectionCoords(null)
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
GRAPH_MARGIN,
|
||||||
|
groupedByDateInterval,
|
||||||
|
setSelectionCoords,
|
||||||
|
setSelectionData,
|
||||||
|
setSelectionDateInterval,
|
||||||
|
x
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildEventRects = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(getTickIntervals(x.domain(), 2))
|
||||||
|
.join('rect')
|
||||||
|
.attr('id', d => `event-rect-${x(d)}`)
|
||||||
|
.attr('x', d => x(d))
|
||||||
|
.attr('y', GRAPH_MARGIN.top)
|
||||||
|
.attr('width', d => {
|
||||||
|
const xValue = Math.round(x(d) * 100) / 100
|
||||||
|
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||||
|
|
||||||
|
const index = R.findIndex(it => it === xValue, ticks)
|
||||||
|
const width =
|
||||||
|
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||||
|
|
||||||
|
return Math.round(width * 100) / 100
|
||||||
|
})
|
||||||
|
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
|
||||||
|
.attr('stroke', 'transparent')
|
||||||
|
.attr('fill', 'transparent'),
|
||||||
|
[GRAPH_MARGIN, x]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatTicksText = useCallback(
|
||||||
|
() =>
|
||||||
|
d3
|
||||||
|
.selectAll('.tick text')
|
||||||
|
.style('stroke', fontColor)
|
||||||
|
.style('fill', fontColor)
|
||||||
|
.style('stroke-width', 0.5)
|
||||||
|
.style('font-family', fontSecondary),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawCashIn = useCallback(
|
||||||
|
g => {
|
||||||
|
g.selectAll('rect')
|
||||||
|
.data(R.init(getTickIntervals(x.domain(), 2)))
|
||||||
|
.join('rect')
|
||||||
|
.attr('stroke', java)
|
||||||
|
.attr('fill', java)
|
||||||
|
.attr('x', d => {
|
||||||
|
return x(d) + BAR_MARGIN / 2
|
||||||
|
})
|
||||||
|
.attr('y', d => {
|
||||||
|
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||||
|
return y(interval[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom
|
||||||
|
})
|
||||||
|
.attr('height', d => {
|
||||||
|
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||||
|
return R.clamp(
|
||||||
|
0,
|
||||||
|
GRAPH_HEIGHT,
|
||||||
|
GRAPH_HEIGHT -
|
||||||
|
y(interval[1].cashIn) -
|
||||||
|
GRAPH_MARGIN.bottom -
|
||||||
|
BAR_MARGIN / 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.attr('width', d => {
|
||||||
|
const xValue = Math.round(x(d) * 100) / 100
|
||||||
|
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||||
|
|
||||||
|
const index = R.findIndex(it => it === xValue, ticks)
|
||||||
|
const width =
|
||||||
|
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||||
|
return Math.round((width - BAR_MARGIN) * 100) / 100
|
||||||
|
})
|
||||||
|
.attr('rx', 2.5)
|
||||||
|
},
|
||||||
|
[x, y, GRAPH_MARGIN, groupedByTxClass]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawCashOut = useCallback(
|
||||||
|
g => {
|
||||||
|
g.selectAll('rect')
|
||||||
|
.data(R.init(getTickIntervals(x.domain(), 2)))
|
||||||
|
.join('rect')
|
||||||
|
.attr('stroke', neon)
|
||||||
|
.attr('fill', neon)
|
||||||
|
.attr('x', d => {
|
||||||
|
return x(d) + BAR_MARGIN / 2
|
||||||
|
})
|
||||||
|
.attr('y', d => {
|
||||||
|
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||||
|
return (
|
||||||
|
y(interval[1].cashIn + interval[1].cashOut) -
|
||||||
|
GRAPH_MARGIN.top +
|
||||||
|
GRAPH_MARGIN.bottom
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.attr('height', d => {
|
||||||
|
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||||
|
return R.clamp(
|
||||||
|
0,
|
||||||
|
GRAPH_HEIGHT,
|
||||||
|
GRAPH_HEIGHT -
|
||||||
|
y(interval[1].cashOut) -
|
||||||
|
GRAPH_MARGIN.bottom -
|
||||||
|
BAR_MARGIN / 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.attr('width', d => {
|
||||||
|
const xValue = Math.round(x(d) * 100) / 100
|
||||||
|
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||||
|
|
||||||
|
const index = R.findIndex(it => it === xValue, ticks)
|
||||||
|
const width =
|
||||||
|
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||||
|
return Math.round((width - BAR_MARGIN) * 100) / 100
|
||||||
|
})
|
||||||
|
.attr('rx', 2.5)
|
||||||
|
},
|
||||||
|
[x, y, GRAPH_MARGIN, groupedByTxClass]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawChart = useCallback(() => {
|
||||||
|
const svg = d3
|
||||||
|
.select(ref.current)
|
||||||
|
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||||
|
|
||||||
|
svg.append('g').call(buildXAxis)
|
||||||
|
svg.append('g').call(buildYAxis)
|
||||||
|
svg.append('g').call(buildVerticalLines)
|
||||||
|
svg.append('g').call(buildEventRects)
|
||||||
|
svg.append('g').call(formatTicksText)
|
||||||
|
svg.append('g').call(drawCashIn)
|
||||||
|
svg.append('g').call(drawCashOut)
|
||||||
|
svg.append('g').call(buildHoverableEventRects)
|
||||||
|
|
||||||
|
return svg.node()
|
||||||
|
}, [
|
||||||
|
buildXAxis,
|
||||||
|
buildYAxis,
|
||||||
|
buildEventRects,
|
||||||
|
buildHoverableEventRects,
|
||||||
|
buildVerticalLines,
|
||||||
|
drawCashIn,
|
||||||
|
formatTicksText,
|
||||||
|
drawCashOut
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
d3.select(ref.current)
|
||||||
|
.selectAll('*')
|
||||||
|
.remove()
|
||||||
|
drawChart()
|
||||||
|
}, [drawChart])
|
||||||
|
|
||||||
|
return <svg ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(
|
||||||
|
Graph,
|
||||||
|
(prev, next) =>
|
||||||
|
R.equals(prev.period, next.period) &&
|
||||||
|
R.equals(prev.selectedDay, next.selectedDay)
|
||||||
|
)
|
||||||
542
new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js
Normal file
542
new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js
Normal file
|
|
@ -0,0 +1,542 @@
|
||||||
|
import BigNumber from 'bignumber.js'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
java,
|
||||||
|
neon,
|
||||||
|
subheaderDarkColor,
|
||||||
|
offColor,
|
||||||
|
fontColor,
|
||||||
|
primaryColor,
|
||||||
|
fontSecondary,
|
||||||
|
subheaderColor
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||||
|
|
||||||
|
const Graph = ({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
timezone,
|
||||||
|
setSelectionCoords,
|
||||||
|
setSelectionData,
|
||||||
|
setSelectionDateInterval
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const GRAPH_POPOVER_WIDTH = 150
|
||||||
|
const GRAPH_POPOVER_MARGIN = 25
|
||||||
|
const GRAPH_HEIGHT = 401
|
||||||
|
const GRAPH_WIDTH = 1163
|
||||||
|
const GRAPH_MARGIN = useMemo(
|
||||||
|
() => ({
|
||||||
|
top: 25,
|
||||||
|
right: 0.5,
|
||||||
|
bottom: 27,
|
||||||
|
left: 36.5
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const offset = parseInt(timezone.split(':')[1]) * MINUTE
|
||||||
|
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(1),
|
||||||
|
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(1),
|
||||||
|
labelFormat: '%d'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = moment.weekdaysShort()
|
||||||
|
const months = moment.monthsShort()
|
||||||
|
|
||||||
|
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[period.code].step
|
||||||
|
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||||
|
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||||
|
if (roundDate(stepDate) > domain[1]) continue
|
||||||
|
if (stepDate < domain[0]) continue
|
||||||
|
points.push(roundDate(stepDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
},
|
||||||
|
[NOW, dataPoints, period.code]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildAreas = useCallback(
|
||||||
|
domain => {
|
||||||
|
const points = []
|
||||||
|
|
||||||
|
points.push(domain[1])
|
||||||
|
|
||||||
|
const roundDate = d => {
|
||||||
|
const step = dataPoints[period.code].step
|
||||||
|
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||||
|
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||||
|
if (roundDate(stepDate) > new Date(domain[1])) continue
|
||||||
|
if (stepDate < new Date(domain[0])) continue
|
||||||
|
points.push(roundDate(stepDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push(domain[0])
|
||||||
|
|
||||||
|
return points
|
||||||
|
},
|
||||||
|
[NOW, dataPoints, period.code]
|
||||||
|
)
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scaleUtc()
|
||||||
|
.domain(periodDomains[period.code])
|
||||||
|
.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.03
|
||||||
|
])
|
||||||
|
.nice()
|
||||||
|
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||||
|
|
||||||
|
const getAreaInterval = (breakpoints, limits) => {
|
||||||
|
const fullBreakpoints = [
|
||||||
|
limits[1],
|
||||||
|
...R.filter(it => it > limits[0] && it < limits[1], breakpoints),
|
||||||
|
limits[0]
|
||||||
|
]
|
||||||
|
|
||||||
|
const intervals = []
|
||||||
|
for (let i = 0; i < fullBreakpoints.length - 1; i++) {
|
||||||
|
intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAreaIntervalByX = (intervals, xValue) => {
|
||||||
|
return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateIntervalByX = (areas, intervals, xValue) => {
|
||||||
|
const flattenIntervals = R.uniq(R.flatten(intervals))
|
||||||
|
|
||||||
|
// flattenIntervals and areas should have the same number of elements
|
||||||
|
for (let i = intervals.length - 1; i >= 0; i--) {
|
||||||
|
if (xValue < flattenIntervals[i]) {
|
||||||
|
return [areas[i], areas[i + 1]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildXAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||||
|
)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.ticks(dataPoints[period.code].tick)
|
||||||
|
.tickFormat(d => {
|
||||||
|
return d3.timeFormat(dataPoints[period.code].labelFormat)(
|
||||||
|
d.getTime() + d.getTimezoneOffset() * MINUTE
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.call(g => g.select('.domain').remove())
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('line')
|
||||||
|
.attr('x1', GRAPH_MARGIN.left)
|
||||||
|
.attr('y1', -GRAPH_HEIGHT + GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
|
||||||
|
.attr('x2', GRAPH_MARGIN.left)
|
||||||
|
.attr('stroke', primaryColor)
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
),
|
||||||
|
[GRAPH_MARGIN, dataPoints, period.code, x]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildYAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||||
|
.call(d3.axisLeft(y).ticks(GRAPH_HEIGHT / 100))
|
||||||
|
.call(g => g.select('.domain').remove())
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.selectAll('.tick line')
|
||||||
|
.filter(d => d === 0)
|
||||||
|
.clone()
|
||||||
|
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right - GRAPH_MARGIN.left)
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('stroke', primaryColor)
|
||||||
|
),
|
||||||
|
[GRAPH_MARGIN, y]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildGrid = useCallback(
|
||||||
|
g => {
|
||||||
|
g.attr('stroke', subheaderDarkColor)
|
||||||
|
.attr('fill', subheaderDarkColor)
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
// Horizontal lines
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.scale()
|
||||||
|
.ticks(GRAPH_HEIGHT / 100)
|
||||||
|
)
|
||||||
|
.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 - GRAPH_MARGIN.right)
|
||||||
|
)
|
||||||
|
// Vertical transparent rectangles for events
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(buildAreas(x.domain()))
|
||||||
|
.join('rect')
|
||||||
|
.attr('x', d => x(d))
|
||||||
|
.attr('y', GRAPH_MARGIN.top)
|
||||||
|
.attr('width', d => {
|
||||||
|
const xValue = Math.round(x(d) * 100) / 100
|
||||||
|
const intervals = getAreaInterval(
|
||||||
|
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||||
|
x.range()
|
||||||
|
)
|
||||||
|
const interval = getAreaIntervalByX(intervals, xValue)
|
||||||
|
return Math.round((interval[0] - interval[1]) * 100) / 100
|
||||||
|
})
|
||||||
|
.attr(
|
||||||
|
'height',
|
||||||
|
GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top
|
||||||
|
)
|
||||||
|
.attr('stroke', 'transparent')
|
||||||
|
.attr('fill', 'transparent')
|
||||||
|
.on('mouseover', d => {
|
||||||
|
const xValue = Math.round(d.target.x.baseVal.value * 100) / 100
|
||||||
|
const areas = buildAreas(x.domain())
|
||||||
|
const intervals = getAreaInterval(
|
||||||
|
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||||
|
x.range()
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
|
||||||
|
const filteredData = data.filter(it => {
|
||||||
|
const created = new Date(it.created)
|
||||||
|
const tzCreated = created.setTime(created.getTime() + offset)
|
||||||
|
return (
|
||||||
|
tzCreated > new Date(dateInterval[1]) &&
|
||||||
|
tzCreated <= new Date(dateInterval[0])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rectXCoords = {
|
||||||
|
left: R.clone(d.target.getBoundingClientRect().x),
|
||||||
|
right: R.clone(
|
||||||
|
d.target.getBoundingClientRect().x +
|
||||||
|
d.target.getBoundingClientRect().width
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xCoord =
|
||||||
|
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
|
||||||
|
? rectXCoords.right + GRAPH_POPOVER_MARGIN
|
||||||
|
: rectXCoords.left -
|
||||||
|
GRAPH_POPOVER_WIDTH -
|
||||||
|
GRAPH_POPOVER_MARGIN
|
||||||
|
const yCoord = R.clone(d.target.getBoundingClientRect().y)
|
||||||
|
|
||||||
|
setSelectionDateInterval(dateInterval)
|
||||||
|
setSelectionData(filteredData)
|
||||||
|
setSelectionCoords({
|
||||||
|
x: Math.round(xCoord),
|
||||||
|
y: Math.round(yCoord)
|
||||||
|
})
|
||||||
|
|
||||||
|
d3.select(d.target).attr('fill', subheaderColor)
|
||||||
|
})
|
||||||
|
.on('mouseleave', d => {
|
||||||
|
d3.select(d.target).attr('fill', 'transparent')
|
||||||
|
setSelectionDateInterval(null)
|
||||||
|
setSelectionData(null)
|
||||||
|
setSelectionCoords(null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// Thick vertical lines
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(
|
||||||
|
buildTicks(x.domain()).filter(x => {
|
||||||
|
if (period.code === 'day') return x.getUTCHours() === 0
|
||||||
|
return x.getUTCDate() === 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('line')
|
||||||
|
.attr('class', 'dateSeparator')
|
||||||
|
.attr('x1', d => 0.5 + x(d))
|
||||||
|
.attr('x2', d => 0.5 + x(d))
|
||||||
|
.attr('y1', GRAPH_MARGIN.top - 50)
|
||||||
|
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||||
|
.attr('stroke-width', 5)
|
||||||
|
.join('text')
|
||||||
|
)
|
||||||
|
// Left side breakpoint label
|
||||||
|
.call(g => {
|
||||||
|
const separator = d3
|
||||||
|
?.select('.dateSeparator')
|
||||||
|
?.node()
|
||||||
|
?.getBBox()
|
||||||
|
|
||||||
|
if (!separator) return
|
||||||
|
|
||||||
|
const breakpoint = buildTicks(x.domain()).filter(x => {
|
||||||
|
if (period.code === 'day') return x.getUTCHours() === 0
|
||||||
|
return x.getUTCDate() === 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||||
|
|
||||||
|
return g
|
||||||
|
.append('text')
|
||||||
|
.attr('x', separator.x - 10)
|
||||||
|
.attr('y', separator.y + 33)
|
||||||
|
.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(x => {
|
||||||
|
if (period.code === 'day') return x.getUTCHours() === 0
|
||||||
|
return x.getUTCDate() === 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||||
|
|
||||||
|
return g
|
||||||
|
.append('text')
|
||||||
|
.attr('x', separator.x + 10)
|
||||||
|
.attr('y', separator.y + 33)
|
||||||
|
.attr('text-anchor', 'start')
|
||||||
|
.attr('dy', '.25em')
|
||||||
|
.text(labels.current)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[
|
||||||
|
GRAPH_MARGIN,
|
||||||
|
buildTicks,
|
||||||
|
getPastAndCurrentDayLabels,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
period,
|
||||||
|
buildAreas,
|
||||||
|
data,
|
||||||
|
offset,
|
||||||
|
setSelectionCoords,
|
||||||
|
setSelectionData,
|
||||||
|
setSelectionDateInterval
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatTicksText = useCallback(
|
||||||
|
() =>
|
||||||
|
d3
|
||||||
|
.selectAll('.tick text')
|
||||||
|
.style('stroke', fontColor)
|
||||||
|
.style('fill', fontColor)
|
||||||
|
.style('stroke-width', 0.5)
|
||||||
|
.style('font-family', fontSecondary),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatText = useCallback(
|
||||||
|
() =>
|
||||||
|
d3
|
||||||
|
.selectAll('text')
|
||||||
|
.style('stroke', offColor)
|
||||||
|
.style('fill', offColor)
|
||||||
|
.style('stroke-width', 0.5)
|
||||||
|
.style('font-family', fontSecondary),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatTicks = useCallback(() => {
|
||||||
|
d3.selectAll('.tick line')
|
||||||
|
.style('stroke', primaryColor)
|
||||||
|
.style('fill', primaryColor)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const buildAvg = useCallback(
|
||||||
|
g => {
|
||||||
|
g.attr('stroke', primaryColor)
|
||||||
|
.attr('stroke-width', 3)
|
||||||
|
.attr('stroke-dasharray', '10, 5')
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('line')
|
||||||
|
.attr(
|
||||||
|
'y1',
|
||||||
|
0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
|
||||||
|
)
|
||||||
|
.attr(
|
||||||
|
'y2',
|
||||||
|
0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
|
||||||
|
)
|
||||||
|
.attr('x1', GRAPH_MARGIN.left)
|
||||||
|
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[GRAPH_MARGIN, y, data]
|
||||||
|
)
|
||||||
|
|
||||||
|
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' ? java : 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(buildGrid)
|
||||||
|
svg.append('g').call(buildAvg)
|
||||||
|
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()
|
||||||
|
}, [
|
||||||
|
buildAvg,
|
||||||
|
buildGrid,
|
||||||
|
buildXAxis,
|
||||||
|
buildYAxis,
|
||||||
|
drawData,
|
||||||
|
formatText,
|
||||||
|
formatTicks,
|
||||||
|
formatTicksText
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
d3.select(ref.current)
|
||||||
|
.selectAll('*')
|
||||||
|
.remove()
|
||||||
|
drawChart()
|
||||||
|
}, [drawChart])
|
||||||
|
|
||||||
|
return <svg ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(
|
||||||
|
Graph,
|
||||||
|
(prev, next) =>
|
||||||
|
R.equals(prev.period, next.period) &&
|
||||||
|
R.equals(prev.selectedMachine, next.selectedMachine)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
import BigNumber from 'bignumber.js'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
java,
|
||||||
|
neon,
|
||||||
|
subheaderDarkColor,
|
||||||
|
fontColor,
|
||||||
|
fontSecondary
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
const Graph = ({ data, machines, currency }) => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const AMOUNT_OF_MACHINES = 5
|
||||||
|
const BAR_PADDING = 0.15
|
||||||
|
const BAR_MARGIN = 10
|
||||||
|
const GRAPH_HEIGHT = 401
|
||||||
|
const GRAPH_WIDTH = 1163
|
||||||
|
const GRAPH_MARGIN = useMemo(
|
||||||
|
() => ({
|
||||||
|
top: 25,
|
||||||
|
right: 0.5,
|
||||||
|
bottom: 27,
|
||||||
|
left: 36.5
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const machinesClone = R.clone(machines)
|
||||||
|
|
||||||
|
// This ensures that the graph renders a minimum amount of machines
|
||||||
|
// and avoids having a single bar for cases with one machine
|
||||||
|
const filledMachines =
|
||||||
|
R.length(machines) >= AMOUNT_OF_MACHINES
|
||||||
|
? machinesClone
|
||||||
|
: R.map(it => {
|
||||||
|
if (!R.isNil(machinesClone[it])) return machinesClone[it]
|
||||||
|
return { code: `ghostMachine${it}`, display: `` }
|
||||||
|
}, R.times(R.identity, AMOUNT_OF_MACHINES))
|
||||||
|
|
||||||
|
const txByDevice = R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
acc[value.code] = R.filter(it => it.deviceId === value.code, data)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
filledMachines
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDeviceVolume = deviceId =>
|
||||||
|
R.reduce(
|
||||||
|
(acc, value) => acc + BigNumber(value.fiat).toNumber(),
|
||||||
|
0,
|
||||||
|
txByDevice[deviceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDeviceVolumeByTxClass = deviceId =>
|
||||||
|
R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
if (value.txClass === 'cashIn')
|
||||||
|
acc.cashIn += BigNumber(value.fiat).toNumber()
|
||||||
|
if (value.txClass === 'cashOut')
|
||||||
|
acc.cashOut += BigNumber(value.fiat).toNumber()
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ cashIn: 0, cashOut: 0 },
|
||||||
|
txByDevice[deviceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const devicesByVolume = R.sort(
|
||||||
|
(a, b) => b[1] - a[1],
|
||||||
|
R.map(m => [m.code, getDeviceVolume(m.code)], filledMachines)
|
||||||
|
)
|
||||||
|
|
||||||
|
const topMachines = R.take(AMOUNT_OF_MACHINES, devicesByVolume)
|
||||||
|
|
||||||
|
const txClassVolumeByDevice = R.fromPairs(
|
||||||
|
R.map(v => [v[0], getDeviceVolumeByTxClass(v[0])], topMachines)
|
||||||
|
)
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scaleBand()
|
||||||
|
.domain(topMachines)
|
||||||
|
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||||
|
.paddingInner(BAR_PADDING)
|
||||||
|
|
||||||
|
const y = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.domain([
|
||||||
|
0,
|
||||||
|
d3.max(topMachines, d => d[1]) !== 0 ? d3.max(topMachines, d => d[1]) : 50
|
||||||
|
])
|
||||||
|
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||||
|
|
||||||
|
const buildXAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr('class', 'x-axis-1')
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||||
|
)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.tickFormat(
|
||||||
|
d =>
|
||||||
|
`${R.find(it => it.code === d[0], filledMachines).display ??
|
||||||
|
''}`
|
||||||
|
)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickPadding(10)
|
||||||
|
),
|
||||||
|
[GRAPH_MARGIN, x, filledMachines]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildXAxis2 = useCallback(
|
||||||
|
g => {
|
||||||
|
g.attr('class', 'x-axis-2')
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||||
|
)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.tickFormat(d =>
|
||||||
|
R.includes(`ghostMachine`, d[0])
|
||||||
|
? ``
|
||||||
|
: `${d[1].toFixed(2)} ${currency}`
|
||||||
|
)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickPadding(10)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[GRAPH_MARGIN, x, currency]
|
||||||
|
)
|
||||||
|
|
||||||
|
const positionXAxisLabels = useCallback(() => {
|
||||||
|
d3.selectAll('.x-axis-1 .tick text').attr('transform', function(d) {
|
||||||
|
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
|
||||||
|
return `translate(${-widthPerEntry / 2.25 + this.getBBox().width / 2}, 0)`
|
||||||
|
})
|
||||||
|
}, [x])
|
||||||
|
|
||||||
|
const positionXAxis2Labels = useCallback(() => {
|
||||||
|
d3.selectAll('.x-axis-2 .tick text').attr('transform', function(d) {
|
||||||
|
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
|
||||||
|
return `translate(${widthPerEntry / 2.25 - this.getBBox().width / 2}, 0)`
|
||||||
|
})
|
||||||
|
}, [x])
|
||||||
|
|
||||||
|
const buildYAxis = useCallback(
|
||||||
|
g =>
|
||||||
|
g
|
||||||
|
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.ticks(GRAPH_HEIGHT / 100)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickFormat(``)
|
||||||
|
)
|
||||||
|
.call(g => g.select('.domain').remove()),
|
||||||
|
[GRAPH_MARGIN, y]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatTicksText = useCallback(
|
||||||
|
() =>
|
||||||
|
d3
|
||||||
|
.selectAll('.tick text')
|
||||||
|
.style('stroke', fontColor)
|
||||||
|
.style('fill', fontColor)
|
||||||
|
.style('stroke-width', 0.5)
|
||||||
|
.style('font-family', fontSecondary),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildGrid = useCallback(
|
||||||
|
g => {
|
||||||
|
g.attr('stroke', subheaderDarkColor)
|
||||||
|
.attr('fill', subheaderDarkColor)
|
||||||
|
// Vertical lines
|
||||||
|
.call(g =>
|
||||||
|
g
|
||||||
|
.append('g')
|
||||||
|
.selectAll('line')
|
||||||
|
.data(R.tail(x.domain()))
|
||||||
|
.join('line')
|
||||||
|
.attr('x1', d => {
|
||||||
|
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
|
||||||
|
|
||||||
|
const xValue =
|
||||||
|
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
|
||||||
|
|
||||||
|
const paddedXValue = xValue * (BAR_PADDING / 2)
|
||||||
|
return 0.5 + x(d) - paddedXValue
|
||||||
|
})
|
||||||
|
.attr('x2', d => {
|
||||||
|
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
|
||||||
|
|
||||||
|
const xValue =
|
||||||
|
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
|
||||||
|
|
||||||
|
const paddedXValue = xValue * (BAR_PADDING / 2)
|
||||||
|
return 0.5 + x(d) - paddedXValue
|
||||||
|
})
|
||||||
|
.attr('y1', GRAPH_MARGIN.top)
|
||||||
|
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[GRAPH_MARGIN, x]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawCashIn = useCallback(
|
||||||
|
g => {
|
||||||
|
g.selectAll('rect')
|
||||||
|
.data(R.toPairs(txClassVolumeByDevice))
|
||||||
|
.join('rect')
|
||||||
|
.attr('fill', java)
|
||||||
|
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
|
||||||
|
.attr('y', d => y(d[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
|
||||||
|
.attr('height', d =>
|
||||||
|
R.clamp(
|
||||||
|
0,
|
||||||
|
GRAPH_HEIGHT,
|
||||||
|
GRAPH_HEIGHT - y(d[1].cashIn) - GRAPH_MARGIN.bottom - BAR_MARGIN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.attr('width', x.bandwidth())
|
||||||
|
.attr('rx', 2.5)
|
||||||
|
},
|
||||||
|
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawCashOut = useCallback(
|
||||||
|
g => {
|
||||||
|
g.selectAll('rect')
|
||||||
|
.data(R.toPairs(txClassVolumeByDevice))
|
||||||
|
.join('rect')
|
||||||
|
.attr('fill', neon)
|
||||||
|
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
|
||||||
|
.attr(
|
||||||
|
'y',
|
||||||
|
d =>
|
||||||
|
y(d[1].cashIn + d[1].cashOut) -
|
||||||
|
GRAPH_MARGIN.top +
|
||||||
|
GRAPH_MARGIN.bottom
|
||||||
|
)
|
||||||
|
.attr('height', d => {
|
||||||
|
return R.clamp(
|
||||||
|
0,
|
||||||
|
GRAPH_HEIGHT,
|
||||||
|
GRAPH_HEIGHT -
|
||||||
|
y(d[1].cashOut) -
|
||||||
|
GRAPH_MARGIN.bottom -
|
||||||
|
BAR_MARGIN / 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.attr('width', x.bandwidth())
|
||||||
|
.attr('rx', 2.5)
|
||||||
|
},
|
||||||
|
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawChart = useCallback(() => {
|
||||||
|
const svg = d3
|
||||||
|
.select(ref.current)
|
||||||
|
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||||
|
|
||||||
|
svg.append('g').call(buildXAxis)
|
||||||
|
svg.append('g').call(buildXAxis2)
|
||||||
|
svg.append('g').call(buildYAxis)
|
||||||
|
svg.append('g').call(formatTicksText)
|
||||||
|
svg.append('g').call(buildGrid)
|
||||||
|
svg.append('g').call(drawCashIn)
|
||||||
|
svg.append('g').call(drawCashOut)
|
||||||
|
svg.append('g').call(positionXAxisLabels)
|
||||||
|
svg.append('g').call(positionXAxis2Labels)
|
||||||
|
|
||||||
|
return svg.node()
|
||||||
|
}, [
|
||||||
|
buildXAxis,
|
||||||
|
buildXAxis2,
|
||||||
|
positionXAxisLabels,
|
||||||
|
positionXAxis2Labels,
|
||||||
|
buildYAxis,
|
||||||
|
formatTicksText,
|
||||||
|
buildGrid,
|
||||||
|
drawCashIn,
|
||||||
|
drawCashOut
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
d3.select(ref.current)
|
||||||
|
.selectAll('*')
|
||||||
|
.remove()
|
||||||
|
drawChart()
|
||||||
|
}, [drawChart])
|
||||||
|
|
||||||
|
return <svg ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Graph, (prev, next) => R.equals(prev.period, next.period))
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Redirect } from 'react-router-dom'
|
import { Redirect } from 'react-router-dom'
|
||||||
|
|
||||||
|
import Analytics from 'src/pages/Analytics/Analytics'
|
||||||
import Blacklist from 'src/pages/Blacklist'
|
import Blacklist from 'src/pages/Blacklist'
|
||||||
import Cashout from 'src/pages/Cashout'
|
import Cashout from 'src/pages/Cashout'
|
||||||
import Commissions from 'src/pages/Commissions'
|
import Commissions from 'src/pages/Commissions'
|
||||||
|
|
@ -82,6 +83,13 @@ const getLamassuRoutes = () => [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
route: '/analytics',
|
||||||
|
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
|
||||||
|
component: Analytics
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Redirect } from 'react-router-dom'
|
||||||
|
|
||||||
import ATMWallet from 'src/pages/ATMWallet/ATMWallet'
|
import ATMWallet from 'src/pages/ATMWallet/ATMWallet'
|
||||||
import Accounting from 'src/pages/Accounting/Accounting'
|
import Accounting from 'src/pages/Accounting/Accounting'
|
||||||
|
import Analytics from 'src/pages/Analytics/Analytics'
|
||||||
import Assets from 'src/pages/Assets/Assets'
|
import Assets from 'src/pages/Assets/Assets'
|
||||||
import Blacklist from 'src/pages/Blacklist'
|
import Blacklist from 'src/pages/Blacklist'
|
||||||
import Cashout from 'src/pages/Cashout'
|
import Cashout from 'src/pages/Cashout'
|
||||||
|
|
@ -84,6 +85,13 @@ const getPazuzRoutes = () => [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'analytics',
|
||||||
|
label: 'Analytics',
|
||||||
|
route: '/analytics',
|
||||||
|
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
|
||||||
|
component: Analytics
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
|
|
|
||||||
7
new-lamassu-admin/src/utils/time.js
Normal file
7
new-lamassu-admin/src/utils/time.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const MINUTE = 60 * 1000
|
||||||
|
const HOUR = 60 * 60 * 1000
|
||||||
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
|
const WEEK = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const MONTH = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export { MINUTE, HOUR, DAY, WEEK, MONTH }
|
||||||
|
|
@ -75,11 +75,15 @@ const getTzLabels = timezones =>
|
||||||
)
|
)
|
||||||
|
|
||||||
const formatDate = (date, timezoneCode, format) => {
|
const formatDate = (date, timezoneCode, format) => {
|
||||||
const dstOffset = timezoneCode.split(':')[1]
|
const dstOffset = timezoneCode?.split(':')[1] ?? 0
|
||||||
return moment
|
return moment
|
||||||
.utc(date)
|
.utc(date)
|
||||||
.utcOffset(parseInt(dstOffset))
|
.utcOffset(parseInt(dstOffset))
|
||||||
.format(format)
|
.format(format)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getTzLabels, formatDate }
|
const formatDateNonUtc = (date, format) => {
|
||||||
|
return moment(date).format(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getTzLabels, formatDate, formatDateNonUtc }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue