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 { useMemo } from 'react'
|
||||||
import { PortfolioMetrics } from 'common/user'
|
import { scaleTime, scaleLinear } from 'd3-scale'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { curveStepAfter } from 'd3-shape'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { min, max } from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { last } from 'lodash'
|
import { PortfolioMetrics } from 'common/user'
|
||||||
import { memo } from 'react'
|
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { Col } from '../layout/col'
|
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: {
|
const MARGIN = { top: 20, right: 10, bottom: 20, left: 70 }
|
||||||
portfolioHistory: PortfolioMetrics[]
|
const MARGIN_X = MARGIN.left + MARGIN.right
|
||||||
mode: 'value' | 'profit'
|
const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
||||||
handleGraphDisplayChange: (arg0: string | number | null) => void
|
|
||||||
height?: number
|
|
||||||
}) {
|
|
||||||
const { portfolioHistory, height, mode, handleGraphDisplayChange } = props
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
|
|
||||||
const valuePoints = getPoints('value', portfolioHistory)
|
export type GraphMode = 'profit' | 'value'
|
||||||
const posProfitPoints = getPoints('posProfit', portfolioHistory)
|
|
||||||
const negProfitPoints = getPoints('negProfit', portfolioHistory)
|
|
||||||
|
|
||||||
const valuePointsY = valuePoints.map((p) => p.y)
|
|
||||||
const posProfitPointsY = posProfitPoints.map((p) => p.y)
|
|
||||||
const negProfitPointsY = negProfitPoints.map((p) => p.y)
|
|
||||||
|
|
||||||
let data
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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 PortfolioTooltip = (props: TooltipProps<Date, HistoryPoint>) => {
|
||||||
|
const { mouseX, xScale } = props
|
||||||
|
const d = dayjs(xScale.invert(mouseX))
|
||||||
return (
|
return (
|
||||||
<div
|
<Col className="text-xs font-semibold sm:text-sm">
|
||||||
className="w-full overflow-hidden"
|
<div>{d.format('MMM/D/YY')}</div>
|
||||||
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">
|
<div className="text-greyscale-6 text-2xs font-normal sm:text-xs">
|
||||||
{dayjs(slice.points[0].data.xFormatted).format('h:mm A')}
|
{d.format('h:mm A')}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</div>
|
|
||||||
{/* ))} */}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
></ResponsiveLine>
|
|
||||||
</div>
|
const getY = (mode: GraphMode, p: PortfolioMetrics) =>
|
||||||
)
|
p.balance + p.investmentValue - (mode === 'profit' ? p.totalDeposits : 0)
|
||||||
})
|
|
||||||
|
export function getPoints(mode: GraphMode, history: PortfolioMetrics[]) {
|
||||||
export function getPoints(
|
return history.map((p) => ({
|
||||||
line: 'value' | 'posProfit' | 'negProfit',
|
x: new Date(p.timestamp),
|
||||||
portfolioHistory: PortfolioMetrics[]
|
y: getY(mode, p),
|
||||||
) {
|
obj: p,
|
||||||
const points = portfolioHistory.map((p) => {
|
}))
|
||||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
}
|
||||||
const value = balance + investmentValue
|
|
||||||
|
export const PortfolioGraph = (props: {
|
||||||
const profit = value - totalDeposits
|
mode: 'profit' | 'value'
|
||||||
let posProfit = null
|
history: PortfolioMetrics[]
|
||||||
let negProfit = null
|
width: number
|
||||||
if (profit < 0) {
|
height: number
|
||||||
negProfit = profit
|
onMouseOver?: (p: HistoryPoint<PortfolioMetrics> | undefined) => void
|
||||||
} else {
|
}) => {
|
||||||
posProfit = profit
|
const { mode, history, onMouseOver, width, height } = props
|
||||||
}
|
const { data, minDate, maxDate, minValue, maxValue } = useMemo(() => {
|
||||||
|
const data = getPoints(mode, history)
|
||||||
return {
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
x: new Date(timestamp),
|
const minDate = min(data.map((d) => d.x))!
|
||||||
y:
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
line === 'value' ? value : line === 'posProfit' ? posProfit : negProfit,
|
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))!
|
||||||
return points
|
// 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 (
|
||||||
|
<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')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,29 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { last } from 'lodash'
|
import { last } from 'lodash'
|
||||||
import { memo, useRef, useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||||
import { Period } from 'web/lib/firebase/users'
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
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(
|
export const PortfolioValueSection = memo(
|
||||||
function PortfolioValueSection(props: { userId: string }) {
|
function PortfolioValueSection(props: { userId: string }) {
|
||||||
const { userId } = props
|
const { userId } = props
|
||||||
|
|
||||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
const portfolioHistory = usePortfolioHistory(userId, 'allTime')
|
||||||
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
|
const [graphMode, setGraphMode] = useState<GraphMode>('profit')
|
||||||
const [graphMode, setGraphMode] = useState<'profit' | 'value'>('profit')
|
|
||||||
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
|
const [graphDisplayNumber, setGraphDisplayNumber] = useState<
|
||||||
number | string | null
|
number | string | null
|
||||||
>(null)
|
>(null)
|
||||||
const handleGraphDisplayChange = (num: string | number | null) => {
|
const handleGraphDisplayChange = (p: { y: number } | undefined) => {
|
||||||
setGraphDisplayNumber(num)
|
console.log(p)
|
||||||
|
setGraphDisplayNumber(p != null ? formatMoney(p.y) : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember the last defined portfolio history.
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
const portfolioRef = useRef(portfolioHistory)
|
if (!portfolioHistory || !lastPortfolioMetrics) {
|
||||||
if (portfolioHistory) portfolioRef.current = portfolioHistory
|
|
||||||
const currPortfolioHistory = portfolioRef.current
|
|
||||||
|
|
||||||
const lastPortfolioMetrics = last(currPortfolioHistory)
|
|
||||||
if (!currPortfolioHistory || !lastPortfolioMetrics) {
|
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,7 +41,10 @@ export const PortfolioValueSection = memo(
|
||||||
? 'cursor-pointer opacity-40 hover:opacity-80'
|
? '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 className="text-greyscale-6 text-xs sm:text-sm">Profit</div>
|
||||||
<div
|
<div
|
||||||
|
@ -78,7 +76,10 @@ export const PortfolioValueSection = memo(
|
||||||
'cursor-pointer',
|
'cursor-pointer',
|
||||||
graphMode != 'value' ? 'opacity-40 hover:opacity-80' : ''
|
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">
|
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||||
Portfolio value
|
Portfolio value
|
||||||
|
@ -93,56 +94,18 @@ export const PortfolioValueSection = memo(
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<PortfolioValueGraph
|
<SizedContainer fullHeight={200} mobileHeight={100}>
|
||||||
portfolioHistory={currPortfolioHistory}
|
{(width, height) => (
|
||||||
|
<PortfolioGraph
|
||||||
mode={graphMode}
|
mode={graphMode}
|
||||||
handleGraphDisplayChange={handleGraphDisplayChange}
|
history={portfolioHistory}
|
||||||
/>
|
width={width}
|
||||||
<PortfolioPeriodSelection
|
height={height}
|
||||||
portfolioPeriod={portfolioPeriod}
|
onMouseOver={handleGraphDisplayChange}
|
||||||
setPortfolioPeriod={setPortfolioPeriod}
|
|
||||||
className="border-greyscale-2 mt-2 gap-4 border-b"
|
|
||||||
selectClassName="text-indigo-600 text-bold border-b border-indigo-600"
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</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