Rewrite portfolio history graphs with new graph machinery

This commit is contained in:
Marshall Polaris 2022-10-03 00:18:57 -07:00
parent 63cc319b3a
commit a6e5852306
2 changed files with 101 additions and 209 deletions

View File

@ -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
}

View File

@ -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>
)
}