Rewrite portfolio history graphs with new graph machinery
This commit is contained in:
parent
63cc319b3a
commit
a6e5852306
|
@ -1,155 +1,84 @@
|
|||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useMemo } from 'react'
|
||||
import { scaleTime, scaleLinear } from 'd3-scale'
|
||||
import { curveStepAfter } from 'd3-shape'
|
||||
import { min, max } from 'lodash'
|
||||
import dayjs from 'dayjs'
|
||||
import { last } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { Col } from '../layout/col'
|
||||
import { TooltipProps } from 'web/components/charts/helpers'
|
||||
import {
|
||||
HistoryPoint,
|
||||
SingleValueHistoryChart,
|
||||
} from 'web/components/charts/generic-charts'
|
||||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
mode: 'value' | 'profit'
|
||||
handleGraphDisplayChange: (arg0: string | number | null) => void
|
||||
height?: number
|
||||
}) {
|
||||
const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
|
||||
const { width } = useWindowSize()
|
||||
const MARGIN = { top: 20, right: 10, bottom: 20, left: 70 }
|
||||
const MARGIN_X = MARGIN.left + MARGIN.right
|
||||
const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
||||
|
||||
const valuePoints = getPoints('value', portfolioHistory)
|
||||
const posProfitPoints = getPoints('posProfit', portfolioHistory)
|
||||
const negProfitPoints = getPoints('negProfit', portfolioHistory)
|
||||
export type GraphMode = 'profit' | 'value'
|
||||
|
||||
const valuePointsY = valuePoints.map((p) => p.y)
|
||||
const posProfitPointsY = posProfitPoints.map((p) => p.y)
|
||||
const negProfitPointsY = negProfitPoints.map((p) => p.y)
|
||||
export const PortfolioTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
|
||||
const { mouseX, xScale } = props
|
||||
const d = dayjs(xScale.invert(mouseX))
|
||||
return (
|
||||
<Col className="text-xs font-semibold sm:text-sm">
|
||||
<div>{d.format('MMM/D/YY')}</div>
|
||||
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
|
||||
{d.format('h:mm A')}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
let data
|
||||
const getY = (mode: GraphMode, p: PortfolioMetrics) =>
|
||||
p.balance + p.investmentValue - (mode === 'profit' ? p.totalDeposits : 0)
|
||||
|
||||
if (mode === 'value') {
|
||||
data = [{ id: 'value', data: valuePoints, color: '#4f46e5' }]
|
||||
} else {
|
||||
data = [
|
||||
{
|
||||
id: 'negProfit',
|
||||
data: negProfitPoints,
|
||||
color: '#dc2626',
|
||||
},
|
||||
{
|
||||
id: 'posProfit',
|
||||
data: posProfitPoints,
|
||||
color: '#14b8a6',
|
||||
},
|
||||
]
|
||||
}
|
||||
const numYTickValues = 2
|
||||
const endDate = last(data[0].data)?.x
|
||||
export function getPoints(mode: GraphMode, history: PortfolioMetrics[]) {
|
||||
return history.map((p) => ({
|
||||
x: new Date(p.timestamp),
|
||||
y: getY(mode, p),
|
||||
obj: p,
|
||||
}))
|
||||
}
|
||||
|
||||
const yMin =
|
||||
mode === 'value'
|
||||
? Math.min(...filterDefined(valuePointsY))
|
||||
: Math.min(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
|
||||
const yMax =
|
||||
mode === 'value'
|
||||
? Math.max(...filterDefined(valuePointsY))
|
||||
: Math.max(
|
||||
...filterDefined(negProfitPointsY),
|
||||
...filterDefined(posProfitPointsY)
|
||||
)
|
||||
export const PortfolioGraph = (props: {
|
||||
mode: 'profit' | 'value'
|
||||
history: PortfolioMetrics[]
|
||||
width: number
|
||||
height: number
|
||||
onMouseOver?: (p: HistoryPoint<PortfolioMetrics> | undefined) => void
|
||||
}) => {
|
||||
const { mode, history, onMouseOver, width, height } = props
|
||||
const { data, minDate, maxDate, minValue, maxValue } = useMemo(() => {
|
||||
const data = getPoints(mode, history)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const minDate = min(data.map((d) => d.x))!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const maxDate = max(data.map((d) => d.x))!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const minValue = min(data.map((d) => d.y))!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const maxValue = max(data.map((d) => d.y))!
|
||||
return { data, minDate, maxDate, minValue, maxValue }
|
||||
}, [mode, history])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: height ?? (!width || width >= 800 ? 200 : 100) }}
|
||||
onMouseLeave={() => handleGraphDisplayChange(null)}
|
||||
>
|
||||
<ResponsiveLine
|
||||
margin={{ top: 10, right: 0, left: 40, bottom: 10 }}
|
||||
data={data}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: valuePoints[0]?.x,
|
||||
max: endDate,
|
||||
}}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
stacked: false,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
}}
|
||||
curve="stepAfter"
|
||||
enablePoints={false}
|
||||
colors={{ datum: 'color' }}
|
||||
axisBottom={{
|
||||
tickValues: 0,
|
||||
}}
|
||||
pointBorderColor="#fff"
|
||||
pointSize={valuePoints.length > 100 ? 0 : 6}
|
||||
axisLeft={{
|
||||
tickValues: numYTickValues,
|
||||
format: '.3s',
|
||||
}}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
gridYValues={numYTickValues}
|
||||
enableSlices="x"
|
||||
animate={false}
|
||||
yFormat={(value) => formatMoney(+value)}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
sliceTooltip={({ slice }) => {
|
||||
handleGraphDisplayChange(slice.points[0].data.yFormatted)
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-white px-4 py-2 opacity-80">
|
||||
<div
|
||||
key={slice.points[0].id}
|
||||
className="text-xs font-semibold sm:text-sm"
|
||||
>
|
||||
<Col>
|
||||
<div>
|
||||
{dayjs(slice.points[0].data.xFormatted).format('MMM/D/YY')}
|
||||
</div>
|
||||
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
|
||||
{dayjs(slice.points[0].data.xFormatted).format('h:mm A')}
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{/* ))} */}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
></ResponsiveLine>
|
||||
</div>
|
||||
<SingleValueHistoryChart
|
||||
w={width}
|
||||
h={height}
|
||||
margin={MARGIN}
|
||||
xScale={scaleTime([minDate, maxDate], [0, width - MARGIN_X])}
|
||||
yScale={scaleLinear([minValue, maxValue], [height - MARGIN_Y, 0])}
|
||||
yKind="m$"
|
||||
data={data}
|
||||
curve={curveStepAfter}
|
||||
Tooltip={PortfolioTooltip}
|
||||
onMouseOver={onMouseOver}
|
||||
color={
|
||||
mode === 'value'
|
||||
? '#4f46e5'
|
||||
: (p: HistoryPoint) => (p.y >= 0 ? '#14b8a6' : '#f00')
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export function getPoints(
|
||||
line: 'value' | 'posProfit' | 'negProfit',
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
) {
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
|
||||
const profit = value - totalDeposits
|
||||
let posProfit = null
|
||||
let negProfit = null
|
||||
if (profit < 0) {
|
||||
negProfit = profit
|
||||
} else {
|
||||
posProfit = profit
|
||||
}
|
||||
|
||||
return {
|
||||
x: new Date(timestamp),
|
||||
y:
|
||||
line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit,
|
||||
}
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
|
|
@ -1,34 +1,29 @@
|
|||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { last } from 'lodash'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { Period } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
import { GraphMode, PortfolioGraph } from './portfolio-value-graph'
|
||||
import { SizedContainer } from 'web/components/sized-container'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
function PortfolioValueSection(props: { userId: string }) {
|
||||
const { userId } = props
|
||||
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
|
||||
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('profit')
|
||||
const portfolioHistory = usePortfolioHistory(userId, 'allTime')
|
||||
const [graphMode, setGraphMode] = useState<GraphMode>('profit')
|
||||
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
|
||||
number | string | null
|
||||
>(null)
|
||||
const handleGraphDisplayChange = (num: string | number | null) => {
|
||||
setGraphDisplayNumber(num)
|
||||
const handleGraphDisplayChange = (p: { y: number } | undefined) => {
|
||||
console.log(p)
|
||||
setGraphDisplayNumber(p != null ? formatMoney(p.y) : null)
|
||||
}
|
||||
|
||||
// Remember the last defined portfolio history.
|
||||
const portfolioRef = useRef(portfolioHistory)
|
||||
if (portfolioHistory) portfolioRef.current = portfolioHistory
|
||||
const currPortfolioHistory = portfolioRef.current
|
||||
|
||||
const lastPortfolioMetrics = last(currPortfolioHistory)
|
||||
if (!currPortfolioHistory || !lastPortfolioMetrics) {
|
||||
const lastPortfolioMetrics = last(portfolioHistory)
|
||||
if (!portfolioHistory || !lastPortfolioMetrics) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
|
@ -46,7 +41,10 @@ export const PortfolioValueSection = memo(
|
|||
? 'cursor-pointer opacity-40 hover:opacity-80'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => setGraphMode('profit')}
|
||||
onClick={() => {
|
||||
setGraphMode('profit')
|
||||
setGraphDisplayNumber(null)
|
||||
}}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">Profit</div>
|
||||
<div
|
||||
|
@ -78,7 +76,10 @@ export const PortfolioValueSection = memo(
|
|||
'cursor-pointer',
|
||||
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
|
||||
)}
|
||||
onClick={() => setGraphMode('value')}
|
||||
onClick={() => {
|
||||
setGraphMode('value')
|
||||
setGraphDisplayNumber(null)
|
||||
}}
|
||||
>
|
||||
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||
Portfolio value
|
||||
|
@ -93,56 +94,18 @@ export const PortfolioValueSection = memo(
|
|||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
mode={graphMode}
|
||||
handleGraphDisplayChange={handleGraphDisplayChange}
|
||||
/>
|
||||
<PortfolioPeriodSelection
|
||||
portfolioPeriod={portfolioPeriod}
|
||||
setPortfolioPeriod={setPortfolioPeriod}
|
||||
className="border-greyscale-2 mt-2 gap-4 border-b"
|
||||
selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
|
||||
/>
|
||||
<SizedContainer fullHeight={200} mobileHeight={100}>
|
||||
{(width, height) => (
|
||||
<PortfolioGraph
|
||||
mode={graphMode}
|
||||
history={portfolioHistory}
|
||||
width={width}
|
||||
height={height}
|
||||
onMouseOver={handleGraphDisplayChange}
|
||||
/>
|
||||
)}
|
||||
</SizedContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export function PortfolioPeriodSelection(props: {
|
||||
setPortfolioPeriod: (string: any) => void
|
||||
portfolioPeriod: string
|
||||
className?: string
|
||||
selectClassName?: string
|
||||
}) {
|
||||
const { setPortfolioPeriod, portfolioPeriod, className, selectClassName } =
|
||||
props
|
||||
return (
|
||||
<Row className={clsx(className, 'text-greyscale-4')}>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'daily' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('daily' as Period)}
|
||||
>
|
||||
1D
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'weekly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('weekly' as Period)}
|
||||
>
|
||||
1W
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'monthly' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('monthly' as Period)}
|
||||
>
|
||||
1M
|
||||
</button>
|
||||
<button
|
||||
className={clsx(portfolioPeriod === 'allTime' ? selectClassName : '')}
|
||||
onClick={() => setPortfolioPeriod('allTime' as Period)}
|
||||
>
|
||||
ALL
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user