Merge pull request #1744 from RafaelTaranto/backport/analytics-adjustments
LAM-450 backport: analytics adjustments
This commit is contained in:
commit
b86ff46a41
9 changed files with 891 additions and 60 deletions
|
|
@ -23,22 +23,26 @@ import LegendEntry from './components/LegendEntry'
|
||||||
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
|
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
|
||||||
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
|
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
|
||||||
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
|
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
|
||||||
|
import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper'
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
|
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
|
||||||
const REPRESENTING_OPTIONS = [
|
const REPRESENTING_OPTIONS = [
|
||||||
{ code: 'overTime', display: 'Over time' },
|
{ code: 'overTime', display: 'Over time' },
|
||||||
|
{ code: 'volumeOverTime', display: 'Volume' },
|
||||||
{ code: 'topMachines', display: 'Top machines' },
|
{ code: 'topMachines', display: 'Top machines' },
|
||||||
{ code: 'hourOfTheDay', display: 'Hour of the day' }
|
{ code: 'hourOfTheDay', display: 'Hour of the day' }
|
||||||
]
|
]
|
||||||
const PERIOD_OPTIONS = [
|
const PERIOD_OPTIONS = [
|
||||||
{ code: 'day', display: 'Last 24 hours' },
|
{ code: 'day', display: 'Last 24 hours' },
|
||||||
|
{ code: 'threeDays', display: 'Last 3 days' },
|
||||||
{ code: 'week', display: 'Last 7 days' },
|
{ code: 'week', display: 'Last 7 days' },
|
||||||
{ code: 'month', display: 'Last 30 days' }
|
{ code: 'month', display: 'Last 30 days' }
|
||||||
]
|
]
|
||||||
const TIME_OPTIONS = {
|
const TIME_OPTIONS = {
|
||||||
day: DAY,
|
day: DAY,
|
||||||
|
threeDays: 3 * DAY,
|
||||||
week: WEEK,
|
week: WEEK,
|
||||||
month: MONTH
|
month: MONTH
|
||||||
}
|
}
|
||||||
|
|
@ -223,13 +227,11 @@ const Analytics = () => {
|
||||||
previous: filteredData(period.code).previous.length
|
previous: filteredData(period.code).previous.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgAmount = {
|
const median = values => (values.length === 0 ? 0 : R.median(values))
|
||||||
current:
|
|
||||||
R.sum(R.map(d => d.fiat, filteredData(period.code).current)) /
|
const medianAmount = {
|
||||||
(txs.current === 0 ? 1 : txs.current),
|
current: median(R.map(d => d.fiat, filteredData(period.code).current)),
|
||||||
previous:
|
previous: median(R.map(d => d.fiat, filteredData(period.code).previous))
|
||||||
R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) /
|
|
||||||
(txs.previous === 0 ? 1 : txs.previous)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const txVolume = {
|
const txVolume = {
|
||||||
|
|
@ -265,6 +267,20 @@ const Analytics = () => {
|
||||||
currency={fiatLocale}
|
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':
|
case 'topMachines':
|
||||||
return (
|
return (
|
||||||
<TopMachinesWrapper
|
<TopMachinesWrapper
|
||||||
|
|
@ -347,9 +363,9 @@ const Analytics = () => {
|
||||||
/>
|
/>
|
||||||
<div className={classes.verticalLine} />
|
<div className={classes.verticalLine} />
|
||||||
<OverviewEntry
|
<OverviewEntry
|
||||||
label="Avg. txn amount"
|
label="Median amount"
|
||||||
value={avgAmount.current}
|
value={medianAmount.current}
|
||||||
oldValue={avgAmount.previous}
|
oldValue={medianAmount.previous}
|
||||||
currency={fiatLocale}
|
currency={fiatLocale}
|
||||||
/>
|
/>
|
||||||
<div className={classes.verticalLine} />
|
<div className={classes.verticalLine} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import { offDarkColor, tomato, neon, java } from 'src/styling/variables'
|
import {
|
||||||
|
offColor,
|
||||||
|
offDarkColor,
|
||||||
|
tomato,
|
||||||
|
neon,
|
||||||
|
java
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
import typographyStyles from '../../components/typography/styles'
|
||||||
|
|
||||||
|
const { label1 } = typographyStyles
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
overviewLegend: {
|
overviewLegend: {
|
||||||
|
|
@ -135,6 +145,18 @@ const styles = {
|
||||||
topMachinesRadio: {
|
topMachinesRadio: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row'
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
|
graphHeaderSwitchBox: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > *': {
|
||||||
|
margin: 0
|
||||||
|
},
|
||||||
|
'& > :first-child': {
|
||||||
|
marginBottom: 2,
|
||||||
|
extend: label1,
|
||||||
|
color: offColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,12 @@ const GraphTooltip = ({
|
||||||
|
|
||||||
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
|
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
|
||||||
? [
|
? [
|
||||||
formatDate(
|
formatDate(dateInterval[1], null, 'MMM d'),
|
||||||
dateInterval[1],
|
formatDate(dateInterval[1], null, 'HH:mm'),
|
||||||
null,
|
formatDate(dateInterval[0], null, 'HH:mm')
|
||||||
period.code === 'day' ? 'MMM d, HH:mm' : 'MMM d'
|
|
||||||
),
|
|
||||||
formatDate(
|
|
||||||
dateInterval[0],
|
|
||||||
null,
|
|
||||||
period.code === 'day' ? 'HH:mm' : 'MMM d'
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
formatDate(dateInterval[1], null, 'MMM d'),
|
||||||
formatDateNonUtc(dateInterval[1], 'HH:mm'),
|
formatDateNonUtc(dateInterval[1], 'HH:mm'),
|
||||||
formatDateNonUtc(dateInterval[0], 'HH:mm')
|
formatDateNonUtc(dateInterval[0], 'HH:mm')
|
||||||
]
|
]
|
||||||
|
|
@ -55,10 +49,11 @@ const GraphTooltip = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.dotOtWrapper}>
|
<Paper className={classes.dotOtWrapper}>
|
||||||
|
{!R.includes('hourOfDay', representing.code) && (
|
||||||
|
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
|
||||||
|
)}
|
||||||
<Info2 noMargin>
|
<Info2 noMargin>
|
||||||
{period.code === 'day' || R.includes('hourOfDay', representing.code)
|
{`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
|
||||||
? `${formattedDateInterval[0]} - ${formattedDateInterval[1]}`
|
|
||||||
: `${formattedDateInterval[0]}`}
|
|
||||||
</Info2>
|
</Info2>
|
||||||
<P noMargin className={classes.dotOtTransactionAmount}>
|
<P noMargin className={classes.dotOtTransactionAmount}>
|
||||||
{R.length(data)}{' '}
|
{R.length(data)}{' '}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Box } from '@material-ui/core'
|
import { Box } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import { Select } from 'src/components/inputs'
|
import { Select, Switch } from 'src/components/inputs'
|
||||||
import { H2 } from 'src/components/typography'
|
import { H2 } from 'src/components/typography'
|
||||||
import { primaryColor } from 'src/styling/variables'
|
import { primaryColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
|
@ -25,11 +25,13 @@ const OverTimeDotGraphHeader = ({
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [logarithmic, setLogarithmic] = useState()
|
||||||
|
|
||||||
const legend = {
|
const legend = {
|
||||||
cashIn: <div className={classes.cashInIcon}></div>,
|
cashIn: <div className={classes.cashInIcon}></div>,
|
||||||
cashOut: <div className={classes.cashOutIcon}></div>,
|
cashOut: <div className={classes.cashOutIcon}></div>,
|
||||||
transaction: <div className={classes.txIcon}></div>,
|
transaction: <div className={classes.txIcon}></div>,
|
||||||
average: (
|
median: (
|
||||||
<svg height="12" width="18">
|
<svg height="12" width="18">
|
||||||
<path
|
<path
|
||||||
stroke={primaryColor}
|
stroke={primaryColor}
|
||||||
|
|
@ -53,10 +55,14 @@ const OverTimeDotGraphHeader = ({
|
||||||
IconElement={legend.transaction}
|
IconElement={legend.transaction}
|
||||||
label={'One transaction'}
|
label={'One transaction'}
|
||||||
/>
|
/>
|
||||||
<LegendEntry IconElement={legend.average} label={'Average'} />
|
<LegendEntry IconElement={legend.median} label={'Median'} />
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.graphHeaderRight}>
|
<div className={classes.graphHeaderRight}>
|
||||||
|
<div className={classes.graphHeaderSwitchBox}>
|
||||||
|
<span>Log. scale</span>
|
||||||
|
<Switch onChange={event => setLogarithmic(event.target.checked)} />
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="Machines"
|
label="Machines"
|
||||||
onSelectedItemChange={handleMachineChange}
|
onSelectedItemChange={handleMachineChange}
|
||||||
|
|
@ -74,6 +80,7 @@ const OverTimeDotGraphHeader = ({
|
||||||
currency={currency}
|
currency={currency}
|
||||||
selectedMachine={selectedMachine}
|
selectedMachine={selectedMachine}
|
||||||
machines={machines}
|
machines={machines}
|
||||||
|
log={logarithmic}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { Select, Switch } from 'src/components/inputs'
|
||||||
|
import { H2 } from 'src/components/typography'
|
||||||
|
import { neon, java } from 'src/styling/variables'
|
||||||
|
|
||||||
|
import styles from '../../Analytics.styles'
|
||||||
|
import Graph from '../../graphs/Graph'
|
||||||
|
import LegendEntry from '../LegendEntry'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const VolumeOverTimeGraphHeader = ({
|
||||||
|
title,
|
||||||
|
representing,
|
||||||
|
period,
|
||||||
|
data,
|
||||||
|
machines,
|
||||||
|
selectedMachine,
|
||||||
|
handleMachineChange,
|
||||||
|
timezone,
|
||||||
|
currency
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [logarithmic, setLogarithmic] = useState()
|
||||||
|
|
||||||
|
const legend = {
|
||||||
|
cashIn: (
|
||||||
|
<svg height="12" width="18">
|
||||||
|
<path
|
||||||
|
stroke={java}
|
||||||
|
strokeWidth="3"
|
||||||
|
d="M 3 6 l 12 0"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
cashOut: (
|
||||||
|
<svg height="12" width="18">
|
||||||
|
<path
|
||||||
|
stroke={neon}
|
||||||
|
strokeWidth="3"
|
||||||
|
d="M 3 6 l 12 0"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</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'} />
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div className={classes.graphHeaderRight}>
|
||||||
|
<div className={classes.graphHeaderSwitchBox}>
|
||||||
|
<span>Log. scale</span>
|
||||||
|
<Switch 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
|
||||||
|
|
@ -5,6 +5,7 @@ import GraphTooltip from '../components/tooltips/GraphTooltip'
|
||||||
|
|
||||||
import HourOfDayBarGraph from './HourOfDayBarGraph'
|
import HourOfDayBarGraph from './HourOfDayBarGraph'
|
||||||
import OverTimeDotGraph from './OverTimeDotGraph'
|
import OverTimeDotGraph from './OverTimeDotGraph'
|
||||||
|
import OverTimeLineGraph from './OverTimeLineGraph'
|
||||||
import TopMachinesBarGraph from './TopMachinesBarGraph'
|
import TopMachinesBarGraph from './TopMachinesBarGraph'
|
||||||
|
|
||||||
const GraphWrapper = ({
|
const GraphWrapper = ({
|
||||||
|
|
@ -15,7 +16,8 @@ const GraphWrapper = ({
|
||||||
currency,
|
currency,
|
||||||
selectedMachine,
|
selectedMachine,
|
||||||
machines,
|
machines,
|
||||||
selectedDay
|
selectedDay,
|
||||||
|
log
|
||||||
}) => {
|
}) => {
|
||||||
const [selectionCoords, setSelectionCoords] = useState(null)
|
const [selectionCoords, setSelectionCoords] = useState(null)
|
||||||
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
|
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
|
||||||
|
|
@ -33,6 +35,20 @@ const GraphWrapper = ({
|
||||||
setSelectionDateInterval={setSelectionDateInterval}
|
setSelectionDateInterval={setSelectionDateInterval}
|
||||||
setSelectionData={setSelectionData}
|
setSelectionData={setSelectionData}
|
||||||
selectedMachine={selectedMachine}
|
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':
|
case 'topMachinesVolume':
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
fontSecondary,
|
fontSecondary,
|
||||||
subheaderColor
|
subheaderColor
|
||||||
} from 'src/styling/variables'
|
} from 'src/styling/variables'
|
||||||
|
import { numberToFiatAmount } from 'src/utils/number'
|
||||||
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||||
|
|
||||||
const Graph = ({
|
const Graph = ({
|
||||||
|
|
@ -23,7 +24,8 @@ const Graph = ({
|
||||||
timezone,
|
timezone,
|
||||||
setSelectionCoords,
|
setSelectionCoords,
|
||||||
setSelectionData,
|
setSelectionData,
|
||||||
setSelectionDateInterval
|
setSelectionDateInterval,
|
||||||
|
log = false
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
|
@ -36,7 +38,7 @@ const Graph = ({
|
||||||
top: 25,
|
top: 25,
|
||||||
right: 3.5,
|
right: 3.5,
|
||||||
bottom: 27,
|
bottom: 27,
|
||||||
left: 36.5
|
left: 38
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
@ -46,6 +48,7 @@ const Graph = ({
|
||||||
|
|
||||||
const periodDomains = {
|
const periodDomains = {
|
||||||
day: [NOW - DAY, NOW],
|
day: [NOW - DAY, NOW],
|
||||||
|
threeDays: [NOW - 3 * DAY, NOW],
|
||||||
week: [NOW - WEEK, NOW],
|
week: [NOW - WEEK, NOW],
|
||||||
month: [NOW - MONTH, NOW]
|
month: [NOW - MONTH, NOW]
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +61,12 @@ const Graph = ({
|
||||||
tick: d3.utcHour.every(1),
|
tick: d3.utcHour.every(1),
|
||||||
labelFormat: '%H:%M'
|
labelFormat: '%H:%M'
|
||||||
},
|
},
|
||||||
|
threeDays: {
|
||||||
|
freq: 12,
|
||||||
|
step: 6 * 60 * 60 * 1000,
|
||||||
|
tick: d3.utcDay.every(1),
|
||||||
|
labelFormat: '%a %d'
|
||||||
|
},
|
||||||
week: {
|
week: {
|
||||||
freq: 7,
|
freq: 7,
|
||||||
step: 24 * 60 * 60 * 1000,
|
step: 24 * 60 * 60 * 1000,
|
||||||
|
|
@ -164,7 +173,7 @@ const Graph = ({
|
||||||
.domain(periodDomains[period.code])
|
.domain(periodDomains[period.code])
|
||||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
|
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
|
||||||
|
|
||||||
const y = d3
|
const yLin = d3
|
||||||
.scaleLinear()
|
.scaleLinear()
|
||||||
.domain([
|
.domain([
|
||||||
0,
|
0,
|
||||||
|
|
@ -173,6 +182,16 @@ const Graph = ({
|
||||||
.nice()
|
.nice()
|
||||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
.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 getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
|
||||||
const fullBreakpoints = [
|
const fullBreakpoints = [
|
||||||
graphLimits[1],
|
graphLimits[1],
|
||||||
|
|
@ -219,14 +238,11 @@ const Graph = ({
|
||||||
d.getTime() + d.getTimezoneOffset() * MINUTE
|
d.getTime() + d.getTimezoneOffset() * MINUTE
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.tickSizeOuter(0)
|
||||||
)
|
)
|
||||||
.call(g => g.select('.domain').remove())
|
|
||||||
.call(g =>
|
.call(g =>
|
||||||
g
|
g
|
||||||
.append('line')
|
.select('.domain')
|
||||||
.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', primaryColor)
|
||||||
.attr('stroke-width', 1)
|
.attr('stroke-width', 1)
|
||||||
),
|
),
|
||||||
|
|
@ -237,18 +253,23 @@ const Graph = ({
|
||||||
g =>
|
g =>
|
||||||
g
|
g
|
||||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||||
.call(d3.axisLeft(y).ticks(GRAPH_HEIGHT / 100))
|
.call(
|
||||||
.call(g => g.select('.domain').remove())
|
d3
|
||||||
.call(g =>
|
.axisLeft(y)
|
||||||
g
|
.ticks(GRAPH_HEIGHT / 100)
|
||||||
.selectAll('.tick line')
|
.tickSizeOuter(0)
|
||||||
.filter(d => d === 0)
|
.tickFormat(d => {
|
||||||
.clone()
|
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
|
||||||
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.left)
|
|
||||||
.attr('stroke-width', 1)
|
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
|
||||||
|
|
||||||
|
return numberToFiatAmount(d)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.select('.domain')
|
||||||
.attr('stroke', primaryColor)
|
.attr('stroke', primaryColor)
|
||||||
),
|
.attr('stroke-width', 1),
|
||||||
[GRAPH_MARGIN, y]
|
[GRAPH_MARGIN, y, log]
|
||||||
)
|
)
|
||||||
|
|
||||||
const buildGrid = useCallback(
|
const buildGrid = useCallback(
|
||||||
|
|
@ -477,25 +498,23 @@ const Graph = ({
|
||||||
|
|
||||||
const buildAvg = useCallback(
|
const buildAvg = useCallback(
|
||||||
g => {
|
g => {
|
||||||
|
const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0
|
||||||
|
|
||||||
|
if (log && median === 0) return
|
||||||
|
|
||||||
g.attr('stroke', primaryColor)
|
g.attr('stroke', primaryColor)
|
||||||
.attr('stroke-width', 3)
|
.attr('stroke-width', 3)
|
||||||
.attr('stroke-dasharray', '10, 5')
|
.attr('stroke-dasharray', '10, 5')
|
||||||
.call(g =>
|
.call(g =>
|
||||||
g
|
g
|
||||||
.append('line')
|
.append('line')
|
||||||
.attr(
|
.attr('y1', 0.5 + y(median))
|
||||||
'y1',
|
.attr('y2', 0.5 + y(median))
|
||||||
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('x1', GRAPH_MARGIN.left)
|
||||||
.attr('x2', GRAPH_WIDTH)
|
.attr('x2', GRAPH_WIDTH)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[GRAPH_MARGIN, y, data]
|
[GRAPH_MARGIN, y, data, log]
|
||||||
)
|
)
|
||||||
|
|
||||||
const drawData = useCallback(
|
const drawData = useCallback(
|
||||||
|
|
@ -554,5 +573,6 @@ export default memo(
|
||||||
Graph,
|
Graph,
|
||||||
(prev, next) =>
|
(prev, next) =>
|
||||||
R.equals(prev.period, next.period) &&
|
R.equals(prev.period, next.period) &&
|
||||||
R.equals(prev.selectedMachine, next.selectedMachine)
|
R.equals(prev.selectedMachine, next.selectedMachine) &&
|
||||||
|
R.equals(prev.log, next.log)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
fontSecondary,
|
fontSecondary,
|
||||||
backgroundColor
|
backgroundColor
|
||||||
} from 'src/styling/variables'
|
} from 'src/styling/variables'
|
||||||
|
import { numberToFiatAmount } from 'src/utils/number'
|
||||||
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||||
|
|
||||||
const Graph = ({ data, timeFrame, timezone }) => {
|
const Graph = ({ data, timeFrame, timezone }) => {
|
||||||
|
|
@ -172,7 +173,16 @@ const Graph = ({ data, timeFrame, timezone }) => {
|
||||||
g =>
|
g =>
|
||||||
g
|
g
|
||||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||||
.call(d3.axisLeft(y).ticks(5))
|
.call(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.ticks(5)
|
||||||
|
.tickFormat(d => {
|
||||||
|
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
|
||||||
|
|
||||||
|
return numberToFiatAmount(d)
|
||||||
|
})
|
||||||
|
)
|
||||||
.call(g => g.select('.domain').remove())
|
.call(g => g.select('.domain').remove())
|
||||||
.selectAll('text')
|
.selectAll('text')
|
||||||
.attr('dy', '-0.25rem'),
|
.attr('dy', '-0.25rem'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue