chore: use monorepo organization

This commit is contained in:
Rafael Taranto 2025-05-12 10:52:54 +01:00
parent deaf7d6ecc
commit a687827f7e
1099 changed files with 8184 additions and 11535 deletions

View file

@ -0,0 +1,387 @@
import { useQuery, gql } from '@apollo/client'
import classnames from 'classnames'
import { endOfToday } from 'date-fns'
import { subDays, format, add, startOfWeek } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState } from 'react'
import TitleSection from 'src/components/layout/TitleSection'
import { Info2, P } from 'src/components/typography'
import DownIcon from 'src/styling/icons/dashboard/down.svg?react'
import EqualIcon from 'src/styling/icons/dashboard/equal.svg?react'
import UpIcon from 'src/styling/icons/dashboard/up.svg?react'
import { Select } from 'src/components/inputs'
import { fromNamespace } from 'src/utils/config'
import { numberToFiatAmount } from 'src/utils/number'
import { DAY, WEEK, MONTH } from 'src/utils/time'
import LegendEntry from './components/LegendEntry'
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper'
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
const REPRESENTING_OPTIONS = [
{ code: 'overTime', display: 'Over time' },
{ code: 'volumeOverTime', display: 'Volume' },
{ code: 'topMachines', display: 'Top machines' },
{ code: 'hourOfTheDay', display: 'Hour of the day' }
]
const PERIOD_OPTIONS = [
{ code: 'day', display: 'Last 24 hours' },
{ code: 'threeDays', display: 'Last 3 days' },
{ code: 'week', display: 'Last 7 days' },
{ code: 'month', display: 'Last 30 days' }
]
const TIME_OPTIONS = {
day: DAY,
threeDays: 3 * DAY,
week: WEEK,
month: MONTH
}
const DAY_OPTIONS = R.map(
it => ({
code: R.toLower(it),
display: it
}),
Array.from(Array(7)).map((_, i) =>
format('EEEE', add({ days: i }, startOfWeek(new Date())))
)
)
const GET_TRANSACTIONS = gql`
query transactions(
$from: DateTimeISO
$until: DateTimeISO
$excludeTestingCustomers: Boolean
) {
transactions(
from: $from
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id
txClass
txHash
toAddress
commissionPercentage
expired
machineName
operatorCompleted
sendConfirmed
dispense
hasError: error
deviceId
fiat
fixedFee
fiatCode
cryptoAtoms
cryptoCode
toAddress
created
profit
}
}
`
const GET_DATA = gql`
query getData {
config
machines {
name
deviceId
}
fiatRates {
code
name
rate
}
}
`
const VerticalLine = () => (
<div className="h-16 border-solid border border-comet2" />
)
const OverviewEntry = ({ label, value, oldValue, currency }) => {
const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue
const growthRate = ((value - oldValue) * 100) / _oldValue
const growthClasses = {
'font-bold': true,
'text-malachite': R.gt(value, oldValue),
'text-tomato': R.gt(oldValue, value)
}
return (
<div>
<P noMargin>{label}</P>
<Info2 noMargin className="mt-[6px] mb-[6px]">
<span className="text-[24px]">{numberToFiatAmount(value)}</span>
{!!currency && ` ${currency}`}
</Info2>
<span className="flex items-center gap-1">
{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)}>
{numberToFiatAmount(growthRate)}%
</P>
</span>
</div>
)
}
const Analytics = () => {
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, {
variables: {
from: subDays(65, endOfToday()),
until: endOfToday(),
excludeTestingCustomers: true
}
})
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 [selectedDay, setSelectedDay] = useState(
R.equals(representing.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
)
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) &&
!tx.hasError
)
) ?? []
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 => {
const txDay = new Date(d.created)
const isSameWeekday = !R.isNil(selectedDay)
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
: true
return isSameWeekday && txDay >= Date.now() - TIME_OPTIONS[timeInterval]
}) ?? [],
previous:
machineTxs.filter(d => {
const txDay = new Date(d.created)
const isSameWeekday = !R.isNil(selectedDay)
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
: true
return (
isSameWeekday &&
txDay < Date.now() - TIME_OPTIONS[timeInterval] &&
txDay >= Date.now() - 2 * TIME_OPTIONS[timeInterval]
)
}) ?? []
})
const txs = {
current: filteredData(period.code).current.length,
previous: filteredData(period.code).previous.length
}
const median = values => (values.length === 0 ? 0 : R.median(values))
const medianAmount = {
current: median(R.map(d => d.fiat, filteredData(period.code).current)),
previous: median(R.map(d => d.fiat, filteredData(period.code).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.profit, filteredData(period.code).current)),
previous: R.sum(R.map(d => d.profit, filteredData(period.code).previous))
}
const handleRepresentationChange = newRepresentation => {
setRepresenting(newRepresentation)
setSelectedDay(
R.equals(newRepresentation.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
)
}
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 'volumeOverTime':
return (
<VolumeOverTimeWrapper
title="Transactions volume 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="Top 5 Machines"
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}
selectedDay={selectedDay}
dayOptions={DAY_OPTIONS}
handleDayChange={setSelectedDay}
timezone={timezone}
currency={fiatLocale}
/>
)
default:
throw new Error(`There's no graph info to represent ${representing}`)
}
}
return (
!loading && (
<>
<TitleSection title="Analytics">
<div className="flex gap-6 justify-end">
<LegendEntry
IconComponent={UpIcon}
label={'Up since last period'}
/>
<LegendEntry
IconComponent={DownIcon}
label={'Down since last period'}
/>
<LegendEntry
IconComponent={EqualIcon}
label={'Same since last period'}
/>
</div>
</TitleSection>
<div className="flex justify-between items-center mb-4">
<div className="flex gap-6">
<Select
label="Representing"
onSelectedItemChange={handleRepresentationChange}
items={REPRESENTING_OPTIONS}
default={REPRESENTING_OPTIONS[0]}
selectedItem={representing}
defaultAsFilter
/>
<Select
label="Time period"
onSelectedItemChange={setPeriod}
items={PERIOD_OPTIONS}
default={PERIOD_OPTIONS[0]}
selectedItem={period}
defaultAsFilter
/>
</div>
<div className="flex items-center gap-10">
<OverviewEntry
label="Transactions"
value={txs.current}
oldValue={txs.previous}
/>
<VerticalLine />
<OverviewEntry
label="Median amount"
value={medianAmount.current}
oldValue={medianAmount.previous}
currency={fiatLocale}
/>
<VerticalLine />
<OverviewEntry
label="Volume"
value={txVolume.current}
oldValue={txVolume.previous}
currency={fiatLocale}
/>
<VerticalLine />
<OverviewEntry
label="Commissions"
value={commissions.current}
oldValue={commissions.previous}
currency={fiatLocale}
/>
</div>
</div>
{getGraphInfo(representing)}
</>
)
)
}
export default Analytics

View file

@ -0,0 +1,14 @@
import React from 'react'
import { P } from 'src/components/typography'
const LegendEntry = ({ IconElement, IconComponent, label }) => {
return (
<span className="flex items-center gap-2">
{!!IconComponent && <IconComponent height={12} />}
{!!IconElement && IconElement}
<P>{label}</P>
</span>
)
}
export default LegendEntry

View file

@ -0,0 +1,74 @@
import Paper from '@mui/material/Paper'
import * as R from 'ramda'
import React, { memo } from 'react'
import { Info2, Label3, P } from 'src/components/typography'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { numberToFiatAmount } from 'src/utils/number'
import { singularOrPlural } from 'src/utils/string'
import { formatDate, formatDateNonUtc } from 'src/utils/timezones'
const GraphTooltip = ({
coords,
data,
dateInterval,
period,
currency,
representing
}) => {
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
? [
formatDate(dateInterval[1], null, 'MMM d'),
formatDate(dateInterval[1], null, 'HH:mm'),
formatDate(dateInterval[0], null, 'HH:mm')
]
: [
formatDate(dateInterval[1], null, '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="absolute top-[351px] w-[150px] p-3 rounded-lg"
style={{ left: coords?.x ?? 0 }}>
{!R.includes('hourOfDay', representing.code) && (
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
)}
<Info2 noMargin>
{`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
</Info2>
<P noMargin className="my-2">
{R.length(data)}{' '}
{singularOrPlural(R.length(data), 'transaction', 'transactions')}
</P>
<P noMargin className="text-comet">
{numberToFiatAmount(transactions.volume)} {currency} in volume
</P>
<div className="mt-4">
<Label3 noMargin>
<TxInIcon />
<span className="ml-1">{transactions.cashIn} cash-in</span>
</Label3>
<Label3 noMargin className="mt-1">
<TxOutIcon />
<span className="ml-1">{transactions.cashOut} cash-out</span>
</Label3>
</div>
</Paper>
)
}
export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords)

View file

@ -0,0 +1,124 @@
import { getTimezoneOffset } from 'date-fns-tz'
import * as R from 'ramda'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { MINUTE } from 'src/utils/time'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const options = [
{ code: 'hourOfDayTransactions', display: 'Transactions' },
{ code: 'hourOfDayVolume', display: 'Volume' }
]
const HourOfDayBarGraphHeader = ({
title,
period,
data,
machines,
selectedMachine,
handleMachineChange,
selectedDay,
dayOptions,
handleDayChange,
timezone,
currency
}) => {
const [graphType /*, setGraphType */] = useState(options[0].code)
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>
}
const offset = getTimezoneOffset(timezone)
const txsPerWeekday = R.reduce(
(acc, value) => {
const created = new Date(value.created)
created.setTime(
created.getTime() + created.getTimezoneOffset() * MINUTE + offset
)
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>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</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={handleDayChange}
/>
<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

View file

@ -0,0 +1,89 @@
import Switch from '@mui/material/Switch'
import React, { useState } from 'react'
import { H2, Label1 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { primaryColor } from 'src/styling/variables'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const OverTimeDotGraphHeader = ({
title,
representing,
period,
data,
machines,
selectedMachine,
handleMachineChange,
timezone,
currency
}) => {
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>,
transaction: <div className={classes.txIcon}></div>,
median: (
<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>
<div 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.median} label={'Median'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<Label1 noMargin className="mb-1 text-comet">
Log. scale
</Label1>
<Switch
className="m-0"
onChange={event => setLogarithmic(event.target.checked)}
/>
</div>
<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}
log={logarithmic}
/>
</>
)
}
export default OverTimeDotGraphHeader

View file

@ -0,0 +1,62 @@
import * as R from 'ramda'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const options = [
{ code: 'topMachinesTransactions', display: 'Transactions' },
{ code: 'topMachinesVolume', display: 'Volume' }
]
const TopMachinesBarGraphHeader = ({
title,
period,
data,
machines,
selectedMachine,
timezone,
currency
}) => {
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>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</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

View file

@ -0,0 +1,91 @@
import Switch from '@mui/material/Switch'
import React, { useState } from 'react'
import { H2, Label1 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { neon, java } from 'src/styling/variables'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const VolumeOverTimeGraphHeader = ({
title,
representing,
period,
data,
machines,
selectedMachine,
handleMachineChange,
timezone,
currency
}) => {
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: (
<svg height="12" width="18">
<path
stroke={java}
strokeWidth="3"
d="M 3 6 l 12 0"
strokeLinecap="round"
/>
</svg>
),
cashOut: (
<svg height="12" width="18">
<path
stroke={neon}
strokeWidth="3"
d="M 3 6 l 12 0"
strokeLinecap="round"
/>
</svg>
)
}
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<Label1 noMargin className="mb-1 text-comet">
Log. scale
</Label1>
<Switch
className="m-0"
onChange={event => setLogarithmic(event.target.checked)}
/>
</div>
<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}
log={logarithmic}
/>
</>
)
}
export default VolumeOverTimeGraphHeader

View file

@ -0,0 +1,56 @@
.graphHeaderWrapper {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
}
.graphHeaderLeft {
display: flex;
flex-direction: column;
}
.graphHeaderRight {
margin-top: 15px;
display: flex;
gap: 30px
}
.cashInIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: var(--java);
}
.cashOutIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: var(--neon);
}
.graphLegend {
display: flex;
align-items: center;
gap: 24px;
}
.txIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: #000;
}
.graphHeaderSwitchBox {
display: flex;
flex-direction: column;
/*'& > *': {*/
/* margin: 0*/
/*},*/
/*'& > :first-child': {*/
/* marginBottom: 2,*/
/* extend: label1,*/
/* color: offColor*/
/*}*/
}

View file

@ -0,0 +1,135 @@
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 OverTimeLineGraph from './OverTimeLineGraph'
import TopMachinesBarGraph from './TopMachinesBarGraph'
const GraphWrapper = ({
data,
representing,
period,
timezone,
currency,
selectedMachine,
machines,
selectedDay,
log
}) => {
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}
log={log}
/>
)
case 'volumeOverTime':
return (
<OverTimeLineGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
log={log}
/>
)
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)

View file

@ -0,0 +1,437 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, startOfDay } from 'date-fns/fp'
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'
import { toUtc } from 'src/utils/timezones'
const Graph = ({
data,
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
selectedMachine
}) => {
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 = getTimezoneOffset(timezone)
const getTickIntervals = (domain, interval) => {
const ticks = []
const start = new Date(domain[0])
const end = new Date(domain[1])
const step = R.clone(start)
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([
toUtc(startOfDay(new Date())),
toUtc(add({ days: 1 }, startOfDay(new Date())))
])
.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) &&
R.equals(prev.selectedMachine, next.selectedMachine)
)

View file

@ -0,0 +1,570 @@
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 * 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 { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({
data,
period,
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
log = false
}) => {
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: 3.5,
bottom: 27,
left: 38
}),
[]
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
day: [NOW - DAY, NOW],
threeDays: [NOW - 3 * 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'
},
threeDays: {
freq: 12,
step: 6 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
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 = 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[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])
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
const x2 = d3
.scaleUtc()
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const yLin = 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 yLog = d3
.scaleLog()
.domain([
(d3.min(data, d => new BigNumber(d.fiat).toNumber()) ?? 1) * 0.9,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.1
])
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const y = log ? yLog : yLin
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
const fullBreakpoints = [
graphLimits[1],
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
dataLimits[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
)
})
.tickSizeOuter(0)
)
.call(g =>
g
.select('.domain')
.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)
.tickSizeOuter(0)
.tickFormat(d => {
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
return numberToFiatAmount(d)
})
)
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1),
[GRAPH_MARGIN, y, log]
)
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)
)
// 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(),
x2.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(),
x2.range()
)
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
if (!dateInterval) return
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,
x2,
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 => {
const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0
if (log && median === 0) return
g.attr('stroke', primaryColor)
.attr('stroke-width', 3)
.attr('stroke-dasharray', '10, 5')
.call(g =>
g
.append('line')
.attr('y1', 0.5 + y(median))
.attr('y2', 0.5 + y(median))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH)
)
},
[GRAPH_MARGIN, y, data, log]
)
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) &&
R.equals(prev.log, next.log)
)

View file

@ -0,0 +1,646 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import {
add,
addMilliseconds,
compareDesc,
differenceInMilliseconds,
format,
startOfWeek,
startOfYear
} from 'date-fns/fp'
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 { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({
data,
period,
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
log = false
}) => {
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: 3.5,
bottom: 27,
left: 38
}),
[]
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
day: [NOW - DAY, NOW],
threeDays: [NOW - 3 * 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'
},
threeDays: {
freq: 12,
step: 6 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
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 = 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[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])
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
const x2 = d3
.scaleUtc()
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const bins = buildAreas(x.domain())
.sort((a, b) => compareDesc(a.date, b.date))
.map(addMilliseconds(-dataPoints[period.code].step))
.map((date, i, dates) => {
// move first and last bin in such way
// that all bin have uniform width
if (i === 0)
return addMilliseconds(dataPoints[period.code].step, dates[1])
else if (i === dates.length - 1)
return addMilliseconds(
-dataPoints[period.code].step,
dates[dates.length - 2]
)
else return date
})
.map(date => {
const middleOfBin = addMilliseconds(
dataPoints[period.code].step / 2,
date
)
const txs = data.filter(tx => {
const txCreated = new Date(tx.created)
const shift = new Date(txCreated.getTime() + offset)
return (
Math.abs(differenceInMilliseconds(shift, middleOfBin)) <
dataPoints[period.code].step / 2
)
})
const cashIn = txs
.filter(tx => tx.txClass === 'cashIn')
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
const cashOut = txs
.filter(tx => tx.txClass === 'cashOut')
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
return { date: middleOfBin, cashIn, cashOut }
})
const min = d3.min(bins, d => Math.min(d.cashIn, d.cashOut)) ?? 0
const max = d3.max(bins, d => Math.max(d.cashIn, d.cashOut)) ?? 1000
const yLin = d3
.scaleLinear()
.domain([0, (max === min ? min + 1000 : max) * 1.03])
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const yLog = d3
.scaleLog()
.domain([
min === 0 ? 0.9 : min * 0.9,
(max === min ? min + Math.pow(10, 2 * min + 1) : max) * 2
])
.clamp(true)
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const y = log ? yLog : yLin
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
const fullBreakpoints = [
graphLimits[1],
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
dataLimits[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
)
})
.tickSizeOuter(0)
)
.call(g =>
g
.select('.domain')
.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)
.tickSizeOuter(0)
.tickFormat(d => {
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
return numberToFiatAmount(d)
})
)
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1),
[GRAPH_MARGIN, y, log]
)
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)
)
// 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(),
x2.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(),
x2.range()
)
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
if (!dateInterval) return
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,
x2,
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 drawData = useCallback(
g => {
g.append('clipPath')
.attr('id', 'clip-path')
.append('rect')
.attr('x', GRAPH_MARGIN.left)
.attr('y', GRAPH_MARGIN.top)
.attr('width', GRAPH_WIDTH)
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
.attr('fill', java)
g.append('g')
.attr('clip-path', 'url(#clip-path)')
.selectAll('circle .cashIn')
.data(bins)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.cashIn))
.attr('fill', java)
.attr('r', d => (d.cashIn === 0 ? 0 : 3.5))
g.append('path')
.datum(bins)
.attr('fill', 'none')
.attr('stroke', java)
.attr('stroke-width', 3)
.attr('clip-path', 'url(#clip-path)')
.attr(
'd',
d3
.line()
.curve(d3.curveMonotoneX)
.x(d => x(d.date))
.y(d => y(d.cashIn))
)
g.append('g')
.attr('clip-path', 'url(#clip-path)')
.selectAll('circle .cashIn')
.data(bins)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.cashOut))
.attr('fill', neon)
.attr('r', d => (d.cashOut === 0 ? 0 : 3.5))
g.append('path')
.datum(bins)
.attr('fill', 'none')
.attr('stroke', neon)
.attr('stroke-width', 3)
.attr('clip-path', 'url(#clip-path)')
.attr(
'd',
d3
.line()
.curve(d3.curveMonotoneX)
.x(d => x(d.date))
.y(d => y(d.cashOut))
)
},
[x, y, bins, GRAPH_MARGIN]
)
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(drawData)
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)
return svg.node()
}, [
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) &&
R.equals(prev.log, next.log)
)

View file

@ -0,0 +1,314 @@
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, selectedMachine }) => {
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) &&
R.equals(prev.selectedMachine, next.selectedMachine)
)

View file

@ -0,0 +1,3 @@
import Analytics from './Analytics'
export default Analytics