diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts
index d2b5f9b2..12f41453 100644
--- a/functions/src/update-metrics.ts
+++ b/functions/src/update-metrics.ts
@@ -17,7 +17,8 @@ import {
   computeVolume,
 } from '../../common/calculate-metrics'
 import { getProbability } from '../../common/calculate'
-import { Group } from 'common/group'
+import { Group } from '../../common/group'
+import { batchedWaitAll } from '../../common/util/promise'
 
 const firestore = admin.firestore()
 
@@ -27,28 +28,46 @@ export const updateMetrics = functions
   .onRun(updateMetricsCore)
 
 export async function updateMetricsCore() {
-  const [users, contracts, bets, allPortfolioHistories, groups] =
-    await Promise.all([
-      getValues<User>(firestore.collection('users')),
-      getValues<Contract>(firestore.collection('contracts')),
-      getValues<Bet>(firestore.collectionGroup('bets')),
-      getValues<PortfolioMetrics>(
-        firestore
-          .collectionGroup('portfolioHistory')
-          .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
-      ),
-      getValues<Group>(firestore.collection('groups')),
-    ])
+  console.log('Loading users')
+  const users = await getValues<User>(firestore.collection('users'))
 
+  console.log('Loading contracts')
+  const contracts = await getValues<Contract>(firestore.collection('contracts'))
+
+  console.log('Loading portfolio history')
+  const allPortfolioHistories = await getValues<PortfolioMetrics>(
+    firestore
+      .collectionGroup('portfolioHistory')
+      .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
+  )
+
+  console.log('Loading groups')
+  const groups = await getValues<Group>(firestore.collection('groups'))
+
+  console.log('Loading bets')
+  const contractBets = await batchedWaitAll(
+    contracts
+      .filter((c) => c.id)
+      .map(
+        (c) => () =>
+          getValues<Bet>(
+            firestore.collection('contracts').doc(c.id).collection('bets')
+          )
+      ),
+    100
+  )
+  const bets = contractBets.flat()
+
+  console.log('Loading group contracts')
   const contractsByGroup = await Promise.all(
-    groups.map((group) => {
-      return getValues(
+    groups.map((group) =>
+      getValues(
         firestore
           .collection('groups')
           .doc(group.id)
           .collection('groupContracts')
       )
-    })
+    )
   )
   log(
     `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx
deleted file mode 100644
index e4167d11..00000000
--- a/web/components/answers/answers-graph.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-import { DatumValue } from '@nivo/core'
-import { ResponsiveLine } from '@nivo/line'
-import dayjs from 'dayjs'
-import { groupBy, sortBy, sumBy } from 'lodash'
-import { memo } from 'react'
-
-import { Bet } from 'common/bet'
-import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
-import { getOutcomeProbability } from 'common/calculate'
-import { useWindowSize } from 'web/hooks/use-window-size'
-
-const NUM_LINES = 6
-
-export const AnswersGraph = memo(function AnswersGraph(props: {
-  contract: FreeResponseContract | MultipleChoiceContract
-  bets: Bet[]
-  height?: number
-}) {
-  const { contract, bets, height } = props
-  const { createdTime, resolutionTime, closeTime, answers } = contract
-  const now = Date.now()
-
-  const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
-    bets,
-    contract
-  )
-
-  const isClosed = !!closeTime && now > closeTime
-  const latestTime = dayjs(
-    resolutionTime && isClosed
-      ? Math.min(resolutionTime, closeTime)
-      : isClosed
-      ? closeTime
-      : resolutionTime ?? now
-  )
-
-  const { width } = useWindowSize()
-
-  const isLargeWidth = !width || width > 800
-  const labelLength = isLargeWidth ? 50 : 20
-
-  // Add a fake datapoint so the line continues to the right
-  const endTime = latestTime.valueOf()
-
-  const times = sortBy([
-    createdTime,
-    ...bets.map((bet) => bet.createdTime),
-    endTime,
-  ])
-  const dateTimes = times.map((time) => new Date(time))
-
-  const data = sortedOutcomes.map((outcome) => {
-    const betProbs = probsByOutcome[outcome]
-    // Add extra point for contract start and end.
-    const probs = [0, ...betProbs, betProbs[betProbs.length - 1]]
-
-    const points = probs.map((prob, i) => ({
-      x: dateTimes[i],
-      y: Math.round(prob * 100),
-    }))
-
-    const answer =
-      answers?.find((answer) => answer.id === outcome)?.text ?? 'None'
-    const answerText =
-      answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '')
-
-    return { id: answerText, data: points }
-  })
-
-  data.reverse()
-
-  const yTickValues = [0, 25, 50, 75, 100]
-
-  const numXTickValues = isLargeWidth ? 5 : 2
-  const startDate = dayjs(contract.createdTime)
-  const endDate = startDate.add(1, 'hour').isAfter(latestTime)
-    ? latestTime.add(1, 'hours')
-    : latestTime
-  const includeMinute = endDate.diff(startDate, 'hours') < 2
-
-  const multiYear = !startDate.isSame(latestTime, 'year')
-  const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
-
-  return (
-    <div
-      className="w-full"
-      style={{ height: height ?? (isLargeWidth ? 350 : 250) }}
-    >
-      <ResponsiveLine
-        data={data}
-        yScale={{ min: 0, max: 100, type: 'linear', stacked: true }}
-        yFormat={formatPercent}
-        gridYValues={yTickValues}
-        axisLeft={{
-          tickValues: yTickValues,
-          format: formatPercent,
-        }}
-        xScale={{
-          type: 'time',
-          min: startDate.toDate(),
-          max: endDate.toDate(),
-        }}
-        xFormat={(d) =>
-          formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
-        }
-        axisBottom={{
-          tickValues: numXTickValues,
-          format: (time) =>
-            formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
-        }}
-        colors={[
-          '#fca5a5', // red-300
-          '#a5b4fc', // indigo-300
-          '#86efac', // green-300
-          '#fef08a', // yellow-200
-          '#fdba74', // orange-300
-          '#c084fc', // purple-400
-        ]}
-        pointSize={0}
-        curve="stepAfter"
-        enableSlices="x"
-        enableGridX={!!width && width >= 800}
-        enableArea
-        areaOpacity={1}
-        margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
-        legends={[
-          {
-            anchor: 'top-left',
-            direction: 'column',
-            justify: false,
-            translateX: isLargeWidth ? 5 : 2,
-            translateY: 0,
-            itemsSpacing: 0,
-            itemTextColor: 'black',
-            itemDirection: 'left-to-right',
-            itemWidth: isLargeWidth ? 288 : 138,
-            itemHeight: 20,
-            itemBackground: 'white',
-            itemOpacity: 0.9,
-            symbolSize: 12,
-            effects: [
-              {
-                on: 'hover',
-                style: {
-                  itemBackground: 'rgba(255, 255, 255, 1)',
-                  itemOpacity: 1,
-                },
-              },
-            ],
-          },
-        ]}
-      />
-    </div>
-  )
-})
-
-function formatPercent(y: DatumValue) {
-  return `${Math.round(+y.toString())}%`
-}
-
-function formatTime(
-  now: number,
-  time: number,
-  includeYear: boolean,
-  includeHour: boolean,
-  includeMinute: boolean
-) {
-  const d = dayjs(time)
-  if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
-    return 'Now'
-
-  let format: string
-  if (d.isSame(now, 'day')) {
-    format = '[Today]'
-  } else if (d.add(1, 'day').isSame(now, 'day')) {
-    format = '[Yesterday]'
-  } else {
-    format = 'MMM D'
-  }
-
-  if (includeMinute) {
-    format += ', h:mma'
-  } else if (includeHour) {
-    format += ', ha'
-  } else if (includeYear) {
-    format += ', YYYY'
-  }
-
-  return d.format(format)
-}
-
-const computeProbsByOutcome = (
-  bets: Bet[],
-  contract: FreeResponseContract | MultipleChoiceContract
-) => {
-  const { totalBets, outcomeType } = contract
-
-  const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
-  const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
-    const maxProb = Math.max(
-      ...betsByOutcome[outcome].map((bet) => bet.probAfter)
-    )
-    return (
-      (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
-      maxProb > 0.02 &&
-      totalBets[outcome] > 0.000000001
-    )
-  })
-
-  const trackedOutcomes = sortBy(
-    outcomes,
-    (outcome) => -1 * getOutcomeProbability(contract, outcome)
-  ).slice(0, NUM_LINES)
-
-  const probsByOutcome = Object.fromEntries(
-    trackedOutcomes.map((outcome) => [outcome, [] as number[]])
-  )
-  const sharesByOutcome = Object.fromEntries(
-    Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
-  )
-
-  for (const bet of bets) {
-    const { outcome, shares } = bet
-    sharesByOutcome[outcome] += shares
-
-    const sharesSquared = sumBy(
-      Object.values(sharesByOutcome).map((shares) => shares ** 2)
-    )
-
-    for (const outcome of trackedOutcomes) {
-      probsByOutcome[outcome].push(
-        sharesByOutcome[outcome] ** 2 / sharesSquared
-      )
-    }
-  }
-
-  return { probsByOutcome, sortedOutcomes: trackedOutcomes }
-}
diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx
index 90918283..5d908937 100644
--- a/web/components/bet-panel.tsx
+++ b/web/components/bet-panel.tsx
@@ -419,7 +419,7 @@ export function BuyPanel(props: {
           open={seeLimit}
           setOpen={setSeeLimit}
           position="center"
-          className="rounded-lg bg-white px-4 pb-8"
+          className="rounded-lg bg-white px-4 pb-4"
         >
           <Title text="Limit Order" />
           <LimitOrderPanel
@@ -428,6 +428,11 @@ export function BuyPanel(props: {
             user={user}
             unfilledBets={unfilledBets}
           />
+          <LimitBets
+            contract={contract}
+            bets={unfilledBets as LimitBet[]}
+            className="mt-4"
+          />
         </Modal>
       </Col>
     </Col>
diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx
new file mode 100644
index 00000000..6d906998
--- /dev/null
+++ b/web/components/charts/contract/binary.tsx
@@ -0,0 +1,76 @@
+import { useMemo, useRef } from 'react'
+import { last, sortBy } from 'lodash'
+import { scaleTime, scaleLinear } from 'd3-scale'
+
+import { Bet } from 'common/bet'
+import { getInitialProbability, getProbability } from 'common/calculate'
+import { BinaryContract } from 'common/contract'
+import { useIsMobile } from 'web/hooks/use-is-mobile'
+import {
+  MARGIN_X,
+  MARGIN_Y,
+  MAX_DATE,
+  getDateRange,
+  getRightmostVisibleDate,
+} from '../helpers'
+import { SingleValueHistoryChart } from '../generic-charts'
+import { useElementWidth } from 'web/hooks/use-element-width'
+
+const getBetPoints = (bets: Bet[]) => {
+  return sortBy(bets, (b) => b.createdTime).map(
+    (b) => [new Date(b.createdTime), b.probAfter] as const
+  )
+}
+
+const getStartPoint = (contract: BinaryContract, start: Date) => {
+  return [start, getInitialProbability(contract)] as const
+}
+
+const getEndPoint = (contract: BinaryContract, end: Date) => {
+  return [end, getProbability(contract)] as const
+}
+
+export const BinaryContractChart = (props: {
+  contract: BinaryContract
+  bets: Bet[]
+  height?: number
+}) => {
+  const { contract, bets } = props
+  const [contractStart, contractEnd] = getDateRange(contract)
+  const betPoints = useMemo(() => getBetPoints(bets), [bets])
+  const data = useMemo(
+    () => [
+      getStartPoint(contract, contractStart),
+      ...betPoints,
+      getEndPoint(contract, contractEnd ?? MAX_DATE),
+    ],
+    [contract, betPoints, contractStart, contractEnd]
+  )
+  const rightmostDate = getRightmostVisibleDate(
+    contractEnd,
+    last(betPoints)?.[0],
+    new Date(Date.now())
+  )
+  const visibleRange = [contractStart, rightmostDate]
+  const isMobile = useIsMobile(800)
+  const containerRef = useRef<HTMLDivElement>(null)
+  const width = useElementWidth(containerRef) ?? 0
+  const height = props.height ?? (isMobile ? 250 : 350)
+  const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
+  const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
+  return (
+    <div ref={containerRef}>
+      {width && (
+        <SingleValueHistoryChart
+          w={width}
+          h={height}
+          xScale={xScale}
+          yScale={yScale}
+          data={data}
+          color="#11b981"
+          pct
+        />
+      )}
+    </div>
+  )
+}
diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx
new file mode 100644
index 00000000..7cf3e5ed
--- /dev/null
+++ b/web/components/charts/contract/choice.tsx
@@ -0,0 +1,180 @@
+import { useMemo, useRef } from 'react'
+import { last, sum, sortBy, groupBy } from 'lodash'
+import { scaleTime, scaleLinear } from 'd3-scale'
+
+import { Bet } from 'common/bet'
+import { Answer } from 'common/answer'
+import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
+import { getOutcomeProbability } from 'common/calculate'
+import { useIsMobile } from 'web/hooks/use-is-mobile'
+import {
+  MARGIN_X,
+  MARGIN_Y,
+  MAX_DATE,
+  getDateRange,
+  getRightmostVisibleDate,
+} from '../helpers'
+import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
+import { useElementWidth } from 'web/hooks/use-element-width'
+
+// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors
+const CATEGORY_COLORS = [
+  '#00b8dd',
+  '#eecafe',
+  '#874c62',
+  '#6457ca',
+  '#f773ba',
+  '#9c6bbc',
+  '#a87744',
+  '#af8a04',
+  '#bff9aa',
+  '#f3d89d',
+  '#c9a0f5',
+  '#ff00e5',
+  '#9dc6f7',
+  '#824475',
+  '#d973cc',
+  '#bc6808',
+  '#056e70',
+  '#677932',
+  '#00b287',
+  '#c8ab6c',
+  '#a2fb7a',
+  '#f8db68',
+  '#14675a',
+  '#8288f4',
+  '#fe1ca0',
+  '#ad6aff',
+  '#786306',
+  '#9bfbaf',
+  '#b00cf7',
+  '#2f7ec5',
+  '#4b998b',
+  '#42fa0e',
+  '#5b80a1',
+  '#962d9d',
+  '#3385ff',
+  '#48c5ab',
+  '#b2c873',
+  '#4cf9a4',
+  '#00ffff',
+  '#3cca73',
+  '#99ae17',
+  '#7af5cf',
+  '#52af45',
+  '#fbb80f',
+  '#29971b',
+  '#187c9a',
+  '#00d539',
+  '#bbfa1a',
+  '#61f55c',
+  '#cabc03',
+  '#ff9000',
+  '#779100',
+  '#bcfd6f',
+  '#70a560',
+]
+
+const getTrackedAnswers = (
+  contract: FreeResponseContract | MultipleChoiceContract,
+  topN: number
+) => {
+  const { answers, outcomeType, totalBets } = contract
+  const validAnswers = answers.filter((answer) => {
+    return (
+      (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
+      totalBets[answer.id] > 0.000000001
+    )
+  })
+  return sortBy(
+    validAnswers,
+    (answer) => -1 * getOutcomeProbability(contract, answer.id)
+  ).slice(0, topN)
+}
+
+const getStartPoint = (answers: Answer[], start: Date) => {
+  return [start, answers.map((_) => 0)] as const
+}
+
+const getEndPoint = (
+  answers: Answer[],
+  contract: FreeResponseContract | MultipleChoiceContract,
+  end: Date
+) => {
+  return [
+    end,
+    answers.map((a) => getOutcomeProbability(contract, a.id)),
+  ] as const
+}
+
+const getBetPoints = (answers: Answer[], bets: Bet[]) => {
+  const sortedBets = sortBy(bets, (b) => b.createdTime)
+  const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
+  const sharesByOutcome = Object.fromEntries(
+    Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
+  )
+  const points: MultiPoint[] = []
+  for (const bet of sortedBets) {
+    const { outcome, shares } = bet
+    sharesByOutcome[outcome] += shares
+
+    const sharesSquared = sum(
+      Object.values(sharesByOutcome).map((shares) => shares ** 2)
+    )
+    points.push([
+      new Date(bet.createdTime),
+      answers.map((answer) => sharesByOutcome[answer.id] ** 2 / sharesSquared),
+    ])
+  }
+  return points
+}
+
+export const ChoiceContractChart = (props: {
+  contract: FreeResponseContract | MultipleChoiceContract
+  bets: Bet[]
+  height?: number
+}) => {
+  const { contract, bets } = props
+  const [contractStart, contractEnd] = getDateRange(contract)
+  const answers = useMemo(
+    () => getTrackedAnswers(contract, CATEGORY_COLORS.length),
+    [contract]
+  )
+  const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
+  const data = useMemo(
+    () => [
+      getStartPoint(answers, contractStart),
+      ...betPoints,
+      getEndPoint(answers, contract, contractEnd ?? MAX_DATE),
+    ],
+    [answers, contract, betPoints, contractStart, contractEnd]
+  )
+  const rightmostDate = getRightmostVisibleDate(
+    contractEnd,
+    last(betPoints)?.[0],
+    new Date(Date.now())
+  )
+  const visibleRange = [contractStart, rightmostDate]
+  const isMobile = useIsMobile(800)
+  const containerRef = useRef<HTMLDivElement>(null)
+  const width = useElementWidth(containerRef) ?? 0
+  const height = props.height ?? (isMobile ? 150 : 250)
+  const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
+  const yScale = scaleLinear([0, 1], [height - MARGIN_Y, 0])
+  return (
+    <div ref={containerRef}>
+      {width && (
+        <MultiValueHistoryChart
+          w={width}
+          h={height}
+          xScale={xScale}
+          yScale={yScale}
+          data={data}
+          colors={CATEGORY_COLORS}
+          labels={answers.map((answer) => answer.text)}
+          pct
+        />
+      )}
+    </div>
+  )
+}
diff --git a/web/components/charts/contract/index.tsx b/web/components/charts/contract/index.tsx
new file mode 100644
index 00000000..1f580bae
--- /dev/null
+++ b/web/components/charts/contract/index.tsx
@@ -0,0 +1,34 @@
+import { Contract } from 'common/contract'
+import { Bet } from 'common/bet'
+import { BinaryContractChart } from './binary'
+import { PseudoNumericContractChart } from './pseudo-numeric'
+import { ChoiceContractChart } from './choice'
+import { NumericContractChart } from './numeric'
+
+export const ContractChart = (props: {
+  contract: Contract
+  bets: Bet[]
+  height?: number
+}) => {
+  const { contract } = props
+  switch (contract.outcomeType) {
+    case 'BINARY':
+      return <BinaryContractChart {...{ ...props, contract }} />
+    case 'PSEUDO_NUMERIC':
+      return <PseudoNumericContractChart {...{ ...props, contract }} />
+    case 'FREE_RESPONSE':
+    case 'MULTIPLE_CHOICE':
+      return <ChoiceContractChart {...{ ...props, contract }} />
+    case 'NUMERIC':
+      return <NumericContractChart {...{ ...props, contract }} />
+    default:
+      return null
+  }
+}
+
+export {
+  BinaryContractChart,
+  PseudoNumericContractChart,
+  ChoiceContractChart,
+  NumericContractChart,
+}
diff --git a/web/components/charts/contract/numeric.tsx b/web/components/charts/contract/numeric.tsx
new file mode 100644
index 00000000..6b574f15
--- /dev/null
+++ b/web/components/charts/contract/numeric.tsx
@@ -0,0 +1,52 @@
+import { useMemo, useRef } from 'react'
+import { max, range } from 'lodash'
+import { scaleLinear } from 'd3-scale'
+
+import { getDpmOutcomeProbabilities } from 'common/calculate-dpm'
+import { NumericContract } from 'common/contract'
+import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
+import { useIsMobile } from 'web/hooks/use-is-mobile'
+import { MARGIN_X, MARGIN_Y } from '../helpers'
+import { SingleValueDistributionChart } from '../generic-charts'
+import { useElementWidth } from 'web/hooks/use-element-width'
+
+const getNumericChartData = (contract: NumericContract) => {
+  const { totalShares, bucketCount, min, max } = contract
+  const step = (max - min) / bucketCount
+  const bucketProbs = getDpmOutcomeProbabilities(totalShares)
+  return range(bucketCount).map(
+    (i) => [min + step * (i + 0.5), bucketProbs[`${i}`]] as const
+  )
+}
+
+export const NumericContractChart = (props: {
+  contract: NumericContract
+  height?: number
+}) => {
+  const { contract } = props
+  const data = useMemo(() => getNumericChartData(contract), [contract])
+  const isMobile = useIsMobile(800)
+  const containerRef = useRef<HTMLDivElement>(null)
+  const width = useElementWidth(containerRef) ?? 0
+  const height = props.height ?? (isMobile ? 150 : 250)
+  const maxY = max(data.map((d) => d[1])) as number
+  const xScale = scaleLinear(
+    [contract.min, contract.max],
+    [0, width - MARGIN_X]
+  )
+  const yScale = scaleLinear([0, maxY], [height - MARGIN_Y, 0])
+  return (
+    <div ref={containerRef}>
+      {width && (
+        <SingleValueDistributionChart
+          w={width}
+          h={height}
+          xScale={xScale}
+          yScale={yScale}
+          data={data}
+          color={NUMERIC_GRAPH_COLOR}
+        />
+      )}
+    </div>
+  )
+}
diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx
new file mode 100644
index 00000000..0e2aaad0
--- /dev/null
+++ b/web/components/charts/contract/pseudo-numeric.tsx
@@ -0,0 +1,96 @@
+import { useMemo, useRef } from 'react'
+import { last, sortBy } from 'lodash'
+import { scaleTime, scaleLog, scaleLinear } from 'd3-scale'
+
+import { Bet } from 'common/bet'
+import { getInitialProbability, getProbability } from 'common/calculate'
+import { PseudoNumericContract } from 'common/contract'
+import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
+import { useIsMobile } from 'web/hooks/use-is-mobile'
+import {
+  MARGIN_X,
+  MARGIN_Y,
+  MAX_DATE,
+  getDateRange,
+  getRightmostVisibleDate,
+} from '../helpers'
+import { SingleValueHistoryChart } from '../generic-charts'
+import { useElementWidth } from 'web/hooks/use-element-width'
+
+// mqp: note that we have an idiosyncratic version of 'log scale'
+// contracts. the values are stored "linearly" and can include zero.
+// as a result, we have to do some weird-looking stuff in this code
+
+const getY = (p: number, contract: PseudoNumericContract) => {
+  const { min, max, isLogScale } = contract
+  return isLogScale
+    ? 10 ** (p * Math.log10(max - min + 1)) + min - 1
+    : p * (max - min) + min
+}
+
+const getBetPoints = (contract: PseudoNumericContract, bets: Bet[]) => {
+  return sortBy(bets, (b) => b.createdTime).map(
+    (b) => [new Date(b.createdTime), getY(b.probAfter, contract)] as const
+  )
+}
+
+const getStartPoint = (contract: PseudoNumericContract, start: Date) => {
+  return [start, getY(getInitialProbability(contract), contract)] as const
+}
+
+const getEndPoint = (contract: PseudoNumericContract, end: Date) => {
+  return [end, getY(getProbability(contract), contract)] as const
+}
+
+export const PseudoNumericContractChart = (props: {
+  contract: PseudoNumericContract
+  bets: Bet[]
+  height?: number
+}) => {
+  const { contract, bets } = props
+  const [contractStart, contractEnd] = getDateRange(contract)
+  const betPoints = useMemo(
+    () => getBetPoints(contract, bets),
+    [contract, bets]
+  )
+  const data = useMemo(
+    () => [
+      getStartPoint(contract, contractStart),
+      ...betPoints,
+      getEndPoint(contract, contractEnd ?? MAX_DATE),
+    ],
+    [contract, betPoints, contractStart, contractEnd]
+  )
+  const rightmostDate = getRightmostVisibleDate(
+    contractEnd,
+    last(betPoints)?.[0],
+    new Date(Date.now())
+  )
+  const visibleRange = [contractStart, rightmostDate]
+  const isMobile = useIsMobile(800)
+  const containerRef = useRef<HTMLDivElement>(null)
+  const width = useElementWidth(containerRef) ?? 0
+  const height = props.height ?? (isMobile ? 150 : 250)
+  const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]).clamp(true)
+  const yScale = contract.isLogScale
+    ? scaleLog(
+        [Math.max(contract.min, 1), contract.max],
+        [height - MARGIN_Y, 0]
+      ).clamp(true) // make sure zeroes go to the bottom
+    : scaleLinear([contract.min, contract.max], [height - MARGIN_Y, 0])
+
+  return (
+    <div ref={containerRef}>
+      {width && (
+        <SingleValueHistoryChart
+          w={width}
+          h={height}
+          xScale={xScale}
+          yScale={yScale}
+          data={data}
+          color={NUMERIC_GRAPH_COLOR}
+        />
+      )}
+    </div>
+  )
+}
diff --git a/web/components/charts/generic-charts.tsx b/web/components/charts/generic-charts.tsx
new file mode 100644
index 00000000..0d262e17
--- /dev/null
+++ b/web/components/charts/generic-charts.tsx
@@ -0,0 +1,409 @@
+import { useCallback, useMemo, useState } from 'react'
+import { bisector } from 'd3-array'
+import { axisBottom, axisLeft } from 'd3-axis'
+import { D3BrushEvent } from 'd3-brush'
+import { ScaleTime, ScaleContinuousNumeric } from 'd3-scale'
+import { pointer } from 'd3-selection'
+import {
+  curveLinear,
+  curveStepAfter,
+  stack,
+  stackOrderReverse,
+  SeriesPoint,
+} from 'd3-shape'
+import { range, sortBy } from 'lodash'
+import dayjs from 'dayjs'
+
+import {
+  SVGChart,
+  AreaPath,
+  AreaWithTopStroke,
+  ChartTooltip,
+  TooltipPosition,
+} from './helpers'
+import { formatLargeNumber } from 'common/util/format'
+import { useEvent } from 'web/hooks/use-event'
+import { Row } from 'web/components/layout/row'
+
+export type MultiPoint = readonly [Date, number[]] // [time, [ordered outcome probs]]
+export type HistoryPoint = readonly [Date, number] // [time, number or percentage]
+export type DistributionPoint = readonly [number, number] // [outcome amount, prob]
+export type PositionValue<P> = TooltipPosition & { p: P }
+
+const formatPct = (n: number, digits?: number) => {
+  return `${(n * 100).toFixed(digits ?? 0)}%`
+}
+
+const formatDate = (
+  date: Date,
+  opts: { includeYear: boolean; includeHour: boolean; includeMinute: boolean }
+) => {
+  const { includeYear, includeHour, includeMinute } = opts
+  const d = dayjs(date)
+  const now = Date.now()
+  if (
+    d.add(1, 'minute').isAfter(now) &&
+    d.subtract(1, 'minute').isBefore(now)
+  ) {
+    return 'Now'
+  } else {
+    const dayName = d.isSame(now, 'day')
+      ? 'Today'
+      : d.add(1, 'day').isSame(now, 'day')
+      ? 'Yesterday'
+      : null
+    let format = dayName ? `[${dayName}]` : 'MMM D'
+    if (includeMinute) {
+      format += ', h:mma'
+    } else if (includeHour) {
+      format += ', ha'
+    } else if (includeYear) {
+      format += ', YYYY'
+    }
+    return d.format(format)
+  }
+}
+
+const getFormatterForDateRange = (start: Date, end: Date) => {
+  const opts = {
+    includeYear: !dayjs(start).isSame(end, 'year'),
+    includeHour: dayjs(start).add(8, 'day').isAfter(end),
+    includeMinute: dayjs(end).diff(start, 'hours') < 2,
+  }
+  return (d: Date) => formatDate(d, opts)
+}
+
+const getTickValues = (min: number, max: number, n: number) => {
+  const step = (max - min) / (n - 1)
+  return [min, ...range(1, n - 1).map((i) => min + step * i), max]
+}
+
+type LegendItem = { color: string; label: string; value?: string }
+
+const Legend = (props: { className?: string; items: LegendItem[] }) => {
+  const { items, className } = props
+  return (
+    <ol className={className}>
+      {items.map((item) => (
+        <li key={item.label} className="flex flex-row justify-between">
+          <Row className="mr-2 items-center overflow-hidden">
+            <span
+              className="mr-2 h-4 w-4 shrink-0"
+              style={{ backgroundColor: item.color }}
+            ></span>
+            <span className="overflow-hidden text-ellipsis">{item.label}</span>
+          </Row>
+          {item.value}
+        </li>
+      ))}
+    </ol>
+  )
+}
+
+export const SingleValueDistributionChart = (props: {
+  data: DistributionPoint[]
+  w: number
+  h: number
+  color: string
+  xScale: ScaleContinuousNumeric<number, number>
+  yScale: ScaleContinuousNumeric<number, number>
+}) => {
+  const { color, data, yScale, w, h } = props
+
+  // note that we have to type this funkily in order to succesfully store
+  // a function inside of useState
+  const [viewXScale, setViewXScale] =
+    useState<ScaleContinuousNumeric<number, number>>()
+  const [mouseState, setMouseState] =
+    useState<PositionValue<DistributionPoint>>()
+  const xScale = viewXScale ?? props.xScale
+
+  const px = useCallback((p: DistributionPoint) => xScale(p[0]), [xScale])
+  const py0 = yScale(yScale.domain()[0])
+  const py1 = useCallback((p: DistributionPoint) => yScale(p[1]), [yScale])
+  const xBisector = bisector((p: DistributionPoint) => p[0])
+
+  const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
+    const fmtX = (n: number) => formatLargeNumber(n)
+    const fmtY = (n: number) => formatPct(n, 2)
+    const xAxis = axisBottom<number>(xScale).ticks(w / 100)
+    const yAxis = axisLeft<number>(yScale).tickFormat(fmtY)
+    return { fmtX, fmtY, xAxis, yAxis }
+  }, [w, xScale, yScale])
+
+  const onSelect = useEvent((ev: D3BrushEvent<DistributionPoint>) => {
+    if (ev.selection) {
+      const [mouseX0, mouseX1] = ev.selection as [number, number]
+      setViewXScale(() =>
+        xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+      )
+      setMouseState(undefined)
+    } else {
+      setViewXScale(undefined)
+      setMouseState(undefined)
+    }
+  })
+
+  const onMouseOver = useEvent((ev: React.PointerEvent) => {
+    if (ev.pointerType === 'mouse') {
+      const [mouseX, mouseY] = pointer(ev)
+      const queryX = xScale.invert(mouseX)
+      const item = data[xBisector.left(data, queryX) - 1]
+      if (item == null) {
+        // this can happen if you are on the very left or right edge of the chart,
+        // so your queryX is out of bounds
+        return
+      }
+      const [_x, y] = item
+      setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
+    }
+  })
+
+  const onMouseLeave = useEvent(() => {
+    setMouseState(undefined)
+  })
+
+  return (
+    <div className="relative">
+      {mouseState && (
+        <ChartTooltip className="text-sm" {...mouseState}>
+          <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
+        </ChartTooltip>
+      )}
+      <SVGChart
+        w={w}
+        h={h}
+        xAxis={xAxis}
+        yAxis={yAxis}
+        onSelect={onSelect}
+        onMouseOver={onMouseOver}
+        onMouseLeave={onMouseLeave}
+      >
+        <AreaWithTopStroke
+          color={color}
+          data={data}
+          px={px}
+          py0={py0}
+          py1={py1}
+          curve={curveLinear}
+        />
+      </SVGChart>
+    </div>
+  )
+}
+
+export const MultiValueHistoryChart = (props: {
+  data: MultiPoint[]
+  w: number
+  h: number
+  labels: readonly string[]
+  colors: readonly string[]
+  xScale: ScaleTime<number, number>
+  yScale: ScaleContinuousNumeric<number, number>
+  pct?: boolean
+}) => {
+  const { colors, data, yScale, labels, w, h, pct } = props
+
+  const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
+  const [mouseState, setMouseState] = useState<PositionValue<MultiPoint>>()
+  const xScale = viewXScale ?? props.xScale
+
+  type SP = SeriesPoint<MultiPoint>
+  const px = useCallback((p: SP) => xScale(p.data[0]), [xScale])
+  const py0 = useCallback((p: SP) => yScale(p[0]), [yScale])
+  const py1 = useCallback((p: SP) => yScale(p[1]), [yScale])
+  const xBisector = bisector((p: MultiPoint) => p[0])
+
+  const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
+    const [start, end] = xScale.domain()
+    const fmtX = getFormatterForDateRange(start, end)
+    const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n))
+
+    const [min, max] = yScale.domain()
+    const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
+    const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
+    const yAxis = pct
+      ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY)
+      : axisLeft<number>(yScale)
+
+    return { fmtX, fmtY, xAxis, yAxis }
+  }, [w, h, pct, xScale, yScale])
+
+  const series = useMemo(() => {
+    const d3Stack = stack<MultiPoint, number>()
+      .keys(range(0, labels.length))
+      .value(([_date, probs], o) => probs[o])
+      .order(stackOrderReverse)
+    return d3Stack(data)
+  }, [data, labels.length])
+
+  const onSelect = useEvent((ev: D3BrushEvent<MultiPoint>) => {
+    if (ev.selection) {
+      const [mouseX0, mouseX1] = ev.selection as [number, number]
+      setViewXScale(() =>
+        xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+      )
+      setMouseState(undefined)
+    } else {
+      setViewXScale(undefined)
+      setMouseState(undefined)
+    }
+  })
+
+  const onMouseOver = useEvent((ev: React.PointerEvent) => {
+    if (ev.pointerType === 'mouse') {
+      const [mouseX, mouseY] = pointer(ev)
+      const queryX = xScale.invert(mouseX)
+      const item = data[xBisector.left(data, queryX) - 1]
+      if (item == null) {
+        // this can happen if you are on the very left or right edge of the chart,
+        // so your queryX is out of bounds
+        return
+      }
+      const [_x, ys] = item
+      setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, ys] })
+    }
+  })
+
+  const onMouseLeave = useEvent(() => {
+    setMouseState(undefined)
+  })
+
+  const mouseProbs = mouseState?.p[1] ?? []
+  const legendItems = sortBy(
+    mouseProbs.map((p, i) => ({
+      color: colors[i],
+      label: labels[i],
+      value: fmtY(p),
+      p,
+    })),
+    (item) => -item.p
+  ).slice(0, 10)
+
+  return (
+    <div className="relative">
+      {mouseState && (
+        <ChartTooltip {...mouseState}>
+          {fmtX(mouseState.p[0])}
+          <Legend className="max-w-xs text-sm" items={legendItems} />
+        </ChartTooltip>
+      )}
+      <SVGChart
+        w={w}
+        h={h}
+        xAxis={xAxis}
+        yAxis={yAxis}
+        onSelect={onSelect}
+        onMouseOver={onMouseOver}
+        onMouseLeave={onMouseLeave}
+      >
+        {series.map((s, i) => (
+          <AreaPath
+            key={i}
+            data={s}
+            px={px}
+            py0={py0}
+            py1={py1}
+            curve={curveStepAfter}
+            fill={colors[i]}
+          />
+        ))}
+      </SVGChart>
+    </div>
+  )
+}
+
+export const SingleValueHistoryChart = (props: {
+  data: HistoryPoint[]
+  w: number
+  h: number
+  color: string
+  xScale: ScaleTime<number, number>
+  yScale: ScaleContinuousNumeric<number, number>
+  pct?: boolean
+}) => {
+  const { color, data, pct, yScale, w, h } = props
+
+  const [viewXScale, setViewXScale] = useState<ScaleTime<number, number>>()
+  const [mouseState, setMouseState] = useState<PositionValue<HistoryPoint>>()
+  const xScale = viewXScale ?? props.xScale
+
+  const px = useCallback((p: HistoryPoint) => xScale(p[0]), [xScale])
+  const py0 = yScale(yScale.domain()[0])
+  const py1 = useCallback((p: HistoryPoint) => yScale(p[1]), [yScale])
+  const xBisector = bisector((p: HistoryPoint) => p[0])
+
+  const { fmtX, fmtY, xAxis, yAxis } = useMemo(() => {
+    const [start, end] = xScale.domain()
+    const fmtX = getFormatterForDateRange(start, end)
+    const fmtY = (n: number) => (pct ? formatPct(n, 0) : formatLargeNumber(n))
+
+    const [min, max] = yScale.domain()
+    const pctTickValues = getTickValues(min, max, h < 200 ? 3 : 5)
+    const xAxis = axisBottom<Date>(xScale).ticks(w / 100)
+    const yAxis = pct
+      ? axisLeft<number>(yScale).tickValues(pctTickValues).tickFormat(fmtY)
+      : axisLeft<number>(yScale)
+    return { fmtX, fmtY, xAxis, yAxis }
+  }, [w, h, pct, xScale, yScale])
+
+  const onSelect = useEvent((ev: D3BrushEvent<HistoryPoint>) => {
+    if (ev.selection) {
+      const [mouseX0, mouseX1] = ev.selection as [number, number]
+      setViewXScale(() =>
+        xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
+      )
+      setMouseState(undefined)
+    } else {
+      setViewXScale(undefined)
+      setMouseState(undefined)
+    }
+  })
+
+  const onMouseOver = useEvent((ev: React.PointerEvent) => {
+    if (ev.pointerType === 'mouse') {
+      const [mouseX, mouseY] = pointer(ev)
+      const queryX = xScale.invert(mouseX)
+      const item = data[xBisector.left(data, queryX) - 1]
+      if (item == null) {
+        // this can happen if you are on the very left or right edge of the chart,
+        // so your queryX is out of bounds
+        return
+      }
+      const [_x, y] = item
+      setMouseState({ top: mouseY - 10, left: mouseX + 60, p: [queryX, y] })
+    }
+  })
+
+  const onMouseLeave = useEvent(() => {
+    setMouseState(undefined)
+  })
+
+  return (
+    <div className="relative">
+      {mouseState && (
+        <ChartTooltip className="text-sm" {...mouseState}>
+          <strong>{fmtY(mouseState.p[1])}</strong> {fmtX(mouseState.p[0])}
+        </ChartTooltip>
+      )}
+      <SVGChart
+        w={w}
+        h={h}
+        xAxis={xAxis}
+        yAxis={yAxis}
+        onSelect={onSelect}
+        onMouseOver={onMouseOver}
+        onMouseLeave={onMouseLeave}
+      >
+        <AreaWithTopStroke
+          color={color}
+          data={data}
+          px={px}
+          py0={py0}
+          py1={py1}
+          curve={curveStepAfter}
+        />
+      </SVGChart>
+    </div>
+  )
+}
diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx
new file mode 100644
index 00000000..644a421c
--- /dev/null
+++ b/web/components/charts/helpers.tsx
@@ -0,0 +1,222 @@
+import { ReactNode, SVGProps, memo, useRef, useEffect, useMemo } from 'react'
+import { select } from 'd3-selection'
+import { Axis } from 'd3-axis'
+import { brushX, D3BrushEvent } from 'd3-brush'
+import { area, line, curveStepAfter, CurveFactory } from 'd3-shape'
+import { nanoid } from 'nanoid'
+import clsx from 'clsx'
+
+import { Contract } from 'common/contract'
+
+export const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
+export const MARGIN_X = MARGIN.right + MARGIN.left
+export const MARGIN_Y = MARGIN.top + MARGIN.bottom
+
+export const MAX_TIMESTAMP = 8640000000000000
+export const MAX_DATE = new Date(MAX_TIMESTAMP)
+
+export const XAxis = <X,>(props: { w: number; h: number; axis: Axis<X> }) => {
+  const { h, axis } = props
+  const axisRef = useRef<SVGGElement>(null)
+  useEffect(() => {
+    if (axisRef.current != null) {
+      select(axisRef.current)
+        .transition()
+        .duration(250)
+        .call(axis)
+        .select('.domain')
+        .attr('stroke-width', 0)
+    }
+  }, [h, axis])
+  return <g ref={axisRef} transform={`translate(0, ${h})`} />
+}
+
+export const YAxis = <Y,>(props: { w: number; h: number; axis: Axis<Y> }) => {
+  const { w, h, axis } = props
+  const axisRef = useRef<SVGGElement>(null)
+  useEffect(() => {
+    if (axisRef.current != null) {
+      select(axisRef.current)
+        .transition()
+        .duration(250)
+        .call(axis)
+        .call((g) =>
+          g.selectAll('.tick line').attr('x2', w).attr('stroke-opacity', 0.1)
+        )
+        .select('.domain')
+        .attr('stroke-width', 0)
+    }
+  }, [w, h, axis])
+  return <g ref={axisRef} />
+}
+
+const LinePathInternal = <P,>(
+  props: {
+    data: P[]
+    px: number | ((p: P) => number)
+    py: number | ((p: P) => number)
+    curve?: CurveFactory
+  } & SVGProps<SVGPathElement>
+) => {
+  const { data, px, py, curve, ...rest } = props
+  const d3Line = line<P>(px, py).curve(curve ?? curveStepAfter)
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  return <path {...rest} fill="none" d={d3Line(data)!} />
+}
+export const LinePath = memo(LinePathInternal) as typeof LinePathInternal
+
+const AreaPathInternal = <P,>(
+  props: {
+    data: P[]
+    px: number | ((p: P) => number)
+    py0: number | ((p: P) => number)
+    py1: number | ((p: P) => number)
+    curve?: CurveFactory
+  } & SVGProps<SVGPathElement>
+) => {
+  const { data, px, py0, py1, curve, ...rest } = props
+  const d3Area = area<P>(px, py0, py1).curve(curve ?? curveStepAfter)
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  return <path {...rest} d={d3Area(data)!} />
+}
+export const AreaPath = memo(AreaPathInternal) as typeof AreaPathInternal
+
+export const AreaWithTopStroke = <P,>(props: {
+  color: string
+  data: P[]
+  px: number | ((p: P) => number)
+  py0: number | ((p: P) => number)
+  py1: number | ((p: P) => number)
+  curve?: CurveFactory
+}) => {
+  const { color, data, px, py0, py1, curve } = props
+  return (
+    <g>
+      <AreaPath
+        data={data}
+        px={px}
+        py0={py0}
+        py1={py1}
+        curve={curve}
+        fill={color}
+        opacity={0.3}
+      />
+      <LinePath data={data} px={px} py={py1} curve={curve} stroke={color} />
+    </g>
+  )
+}
+
+export const SVGChart = <X, Y>(props: {
+  children: ReactNode
+  w: number
+  h: number
+  xAxis: Axis<X>
+  yAxis: Axis<Y>
+  onSelect?: (ev: D3BrushEvent<any>) => void
+  onMouseOver?: (ev: React.PointerEvent) => void
+  onMouseLeave?: (ev: React.PointerEvent) => void
+  pct?: boolean
+}) => {
+  const { children, w, h, xAxis, yAxis, onMouseOver, onMouseLeave, onSelect } =
+    props
+  const overlayRef = useRef<SVGGElement>(null)
+  const innerW = w - MARGIN_X
+  const innerH = h - MARGIN_Y
+  const clipPathId = useMemo(() => nanoid(), [])
+
+  const justSelected = useRef(false)
+  useEffect(() => {
+    if (onSelect != null && overlayRef.current) {
+      const brush = brushX().extent([
+        [0, 0],
+        [innerW, innerH],
+      ])
+      brush.on('end', (ev) => {
+        // when we clear the brush after a selection, that would normally cause
+        // another 'end' event, so we have to suppress it with this flag
+        if (!justSelected.current) {
+          justSelected.current = true
+          onSelect(ev)
+          if (overlayRef.current) {
+            select(overlayRef.current).call(brush.clear)
+          }
+        } else {
+          justSelected.current = false
+        }
+      })
+      // mqp: shape-rendering null overrides the default d3-brush shape-rendering
+      // of `crisp-edges`, which seems to cause graphical glitches on Chrome
+      // (i.e. the bug where the area fill flickers white)
+      select(overlayRef.current)
+        .call(brush)
+        .select('.selection')
+        .attr('shape-rendering', 'null')
+    }
+  }, [innerW, innerH, onSelect])
+
+  return (
+    <svg className="w-full" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
+      <clipPath id={clipPathId}>
+        <rect x={0} y={0} width={innerW} height={innerH} />
+      </clipPath>
+      <g transform={`translate(${MARGIN.left}, ${MARGIN.top})`}>
+        <XAxis axis={xAxis} w={innerW} h={innerH} />
+        <YAxis axis={yAxis} w={innerW} h={innerH} />
+        <g clipPath={`url(#${clipPathId})`}>{children}</g>
+        <g
+          ref={overlayRef}
+          x="0"
+          y="0"
+          width={innerW}
+          height={innerH}
+          fill="none"
+          pointerEvents="all"
+          onPointerEnter={onMouseOver}
+          onPointerMove={onMouseOver}
+          onPointerLeave={onMouseLeave}
+        />
+      </g>
+    </svg>
+  )
+}
+
+export type TooltipPosition = { top: number; left: number }
+
+export const ChartTooltip = (
+  props: TooltipPosition & { className?: string; children: React.ReactNode }
+) => {
+  const { top, left, className, children } = props
+  return (
+    <div
+      className={clsx(
+        className,
+        'pointer-events-none absolute z-10 whitespace-pre rounded border-2 border-black bg-white/90 p-2'
+      )}
+      style={{ top, left }}
+    >
+      {children}
+    </div>
+  )
+}
+
+export const getDateRange = (contract: Contract) => {
+  const { createdTime, closeTime, resolutionTime } = contract
+  const isClosed = !!closeTime && Date.now() > closeTime
+  const endDate = resolutionTime ?? (isClosed ? closeTime : null)
+  return [new Date(createdTime), endDate ? new Date(endDate) : null] as const
+}
+
+export const getRightmostVisibleDate = (
+  contractEnd: Date | null | undefined,
+  lastActivity: Date | null | undefined,
+  now: Date
+) => {
+  if (contractEnd != null) {
+    return contractEnd
+  } else if (lastActivity != null) {
+    // client-DB clock divergence may cause last activity to be later than now
+    return new Date(Math.max(lastActivity.getTime(), now.getTime()))
+  } else {
+    return now
+  }
+}
diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx
index 139b30fe..add9ba48 100644
--- a/web/components/contract/contract-overview.tsx
+++ b/web/components/contract/contract-overview.tsx
@@ -2,7 +2,12 @@ import React from 'react'
 
 import { tradingAllowed } from 'web/lib/firebase/contracts'
 import { Col } from '../layout/col'
-import { ContractProbGraph } from './contract-prob-graph'
+import {
+  BinaryContractChart,
+  NumericContractChart,
+  PseudoNumericContractChart,
+  ChoiceContractChart,
+} from 'web/components/charts/contract'
 import { useUser } from 'web/hooks/use-user'
 import { Row } from '../layout/row'
 import { Linkify } from '../linkify'
@@ -14,7 +19,6 @@ import {
 } from './contract-card'
 import { Bet } from 'common/bet'
 import BetButton, { BinaryMobileBetting } from '../bet-button'
-import { AnswersGraph } from '../answers/answers-graph'
 import {
   Contract,
   CPMMContract,
@@ -25,7 +29,6 @@ import {
   BinaryContract,
 } from 'common/contract'
 import { ContractDetails } from './contract-details'
-import { NumericGraph } from './numeric-graph'
 
 const OverviewQuestion = (props: { text: string }) => (
   <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
@@ -63,7 +66,7 @@ const NumericOverview = (props: { contract: NumericContract }) => {
           contract={contract}
         />
       </Col>
-      <NumericGraph contract={contract} />
+      <NumericContractChart contract={contract} />
     </Col>
   )
 }
@@ -83,7 +86,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
           />
         </Row>
       </Col>
-      <ContractProbGraph contract={contract} bets={[...bets].reverse()} />
+      <BinaryContractChart contract={contract} bets={bets} />
       <Row className="items-center justify-between gap-4 xl:hidden">
         {tradingAllowed(contract) && (
           <BinaryMobileBetting contract={contract} />
@@ -109,7 +112,7 @@ const ChoiceOverview = (props: {
         )}
       </Col>
       <Col className={'mb-1 gap-y-2'}>
-        <AnswersGraph contract={contract} bets={[...bets].reverse()} />
+        <ChoiceContractChart contract={contract} bets={bets} />
       </Col>
     </Col>
   )
@@ -136,7 +139,7 @@ const PseudoNumericOverview = (props: {
           {tradingAllowed(contract) && <BetWidget contract={contract} />}
         </Row>
       </Col>
-      <ContractProbGraph contract={contract} bets={[...bets].reverse()} />
+      <PseudoNumericContractChart contract={contract} bets={bets} />
     </Col>
   )
 }
diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx
deleted file mode 100644
index 60ef85b5..00000000
--- a/web/components/contract/contract-prob-graph.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { DatumValue } from '@nivo/core'
-import { ResponsiveLine, SliceTooltipProps } from '@nivo/line'
-import { BasicTooltip } from '@nivo/tooltip'
-import dayjs from 'dayjs'
-import { memo } from 'react'
-import { Bet } from 'common/bet'
-import { getInitialProbability } from 'common/calculate'
-import { BinaryContract, PseudoNumericContract } from 'common/contract'
-import { useWindowSize } from 'web/hooks/use-window-size'
-import { formatLargeNumber } from 'common/util/format'
-
-export const ContractProbGraph = memo(function ContractProbGraph(props: {
-  contract: BinaryContract | PseudoNumericContract
-  bets: Bet[]
-  height?: number
-}) {
-  const { contract, height } = props
-  const { resolutionTime, closeTime, outcomeType } = contract
-  const now = Date.now()
-  const isBinary = outcomeType === 'BINARY'
-  const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
-
-  const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
-
-  const startProb = getInitialProbability(contract)
-
-  const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
-
-  const f: (p: number) => number = isBinary
-    ? (p) => p
-    : isLogScale
-    ? (p) => p * Math.log10(contract.max - contract.min + 1)
-    : (p) => p * (contract.max - contract.min) + contract.min
-
-  const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
-
-  const isClosed = !!closeTime && now > closeTime
-  const latestTime = dayjs(
-    resolutionTime && isClosed
-      ? Math.min(resolutionTime, closeTime)
-      : isClosed
-      ? closeTime
-      : resolutionTime ?? now
-  )
-
-  // Add a fake datapoint so the line continues to the right
-  times.push(latestTime.valueOf())
-  probs.push(probs[probs.length - 1])
-
-  const { width } = useWindowSize()
-
-  const quartiles = !width || width < 800 ? [0, 50, 100] : [0, 25, 50, 75, 100]
-
-  const yTickValues = isBinary
-    ? quartiles
-    : quartiles.map((x) => x / 100).map(f)
-
-  const numXTickValues = !width || width < 800 ? 2 : 5
-  const startDate = dayjs(times[0])
-  const endDate = startDate.add(1, 'hour').isAfter(latestTime)
-    ? latestTime.add(1, 'hours')
-    : latestTime
-  const includeMinute = endDate.diff(startDate, 'hours') < 2
-
-  // Minimum number of points for the graph to have. For smooth tooltip movement
-  // If we aren't actually loading any data yet, skip adding extra points to let page load faster
-  // This fn runs again once DOM is finished loading
-  const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
-
-  const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
-
-  const points: { x: Date; y: number }[] = []
-  const s = isBinary ? 100 : 1
-
-  for (let i = 0; i < times.length - 1; i++) {
-    const p = probs[i]
-    const d0 = times[i]
-    const d1 = times[i + 1]
-    const msDiff = d1 - d0
-    const numPoints = Math.floor(msDiff / timeStep)
-    points.push({ x: new Date(times[i]), y: s * p })
-    if (numPoints > 1) {
-      const thisTimeStep: number = msDiff / numPoints
-      for (let n = 1; n < numPoints; n++) {
-        points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
-      }
-    }
-  }
-
-  const data = [
-    { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
-  ]
-
-  const multiYear = !startDate.isSame(latestTime, 'year')
-  const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
-
-  const formatter = isBinary
-    ? formatPercent
-    : isLogScale
-    ? (x: DatumValue) =>
-        formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
-    : (x: DatumValue) => formatLargeNumber(+x.valueOf())
-
-  return (
-    <div
-      className="w-full overflow-visible"
-      style={{ height: height ?? (!width || width >= 800 ? 250 : 150) }}
-    >
-      <ResponsiveLine
-        data={data}
-        yScale={
-          isBinary
-            ? { min: 0, max: 100, type: 'linear' }
-            : isLogScale
-            ? {
-                min: 0,
-                max: Math.log10(contract.max - contract.min + 1),
-                type: 'linear',
-              }
-            : { min: contract.min, max: contract.max, type: 'linear' }
-        }
-        yFormat={formatter}
-        gridYValues={yTickValues}
-        axisLeft={{
-          tickValues: yTickValues,
-          format: formatter,
-        }}
-        xScale={{
-          type: 'time',
-          min: startDate.toDate(),
-          max: endDate.toDate(),
-        }}
-        xFormat={(d) =>
-          formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
-        }
-        axisBottom={{
-          tickValues: numXTickValues,
-          format: (time) =>
-            formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
-        }}
-        colors={{ datum: 'color' }}
-        curve="stepAfter"
-        enablePoints={false}
-        pointBorderWidth={1}
-        pointBorderColor="#fff"
-        enableSlices="x"
-        enableGridX={false}
-        enableArea
-        areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
-        margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
-        animate={false}
-        sliceTooltip={SliceTooltip}
-      />
-    </div>
-  )
-})
-
-const SliceTooltip = ({ slice }: SliceTooltipProps) => {
-  return (
-    <BasicTooltip
-      id={slice.points.map((point) => [
-        <span key="date">
-          <strong>{point.data[`yFormatted`]}</strong> {point.data['xFormatted']}
-        </span>,
-      ])}
-    />
-  )
-}
-
-function formatPercent(y: DatumValue) {
-  return `${Math.round(+y.toString())}%`
-}
-
-function formatTime(
-  now: number,
-  time: number,
-  includeYear: boolean,
-  includeHour: boolean,
-  includeMinute: boolean
-) {
-  const d = dayjs(time)
-  if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
-    return 'Now'
-
-  let format: string
-  if (d.isSame(now, 'day')) {
-    format = '[Today]'
-  } else if (d.add(1, 'day').isSame(now, 'day')) {
-    format = '[Yesterday]'
-  } else {
-    format = 'MMM D'
-  }
-
-  if (includeMinute) {
-    format += ', h:mma'
-  } else if (includeHour) {
-    format += ', ha'
-  } else if (includeYear) {
-    format += ', YYYY'
-  }
-
-  return d.format(format)
-}
diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx
index a743bd3c..19350a39 100644
--- a/web/components/contract/contract-tabs.tsx
+++ b/web/components/contract/contract-tabs.tsx
@@ -23,19 +23,22 @@ import {
   HOUSE_LIQUIDITY_PROVIDER_ID,
 } from 'common/antes'
 import { buildArray } from 'common/util/array'
+import { ContractComment } from 'common/comment'
 
 import { formatMoney } from 'common/util/format'
 import { Button } from 'web/components/button'
 import { MINUTE_MS } from 'common/util/time'
 import { useUser } from 'web/hooks/use-user'
 import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
+import { Tooltip } from 'web/components/tooltip'
 
 export function ContractTabs(props: {
   contract: Contract
   bets: Bet[]
   userBets: Bet[]
+  comments: ContractComment[]
 }) {
-  const { contract, bets, userBets } = props
+  const { contract, bets, userBets, comments } = props
   const { openCommentBounties } = contract
 
   const yourTrades = (
@@ -56,7 +59,7 @@ export function ContractTabs(props: {
             openCommentBounties
           )} currently available.`
         : undefined,
-      content: <CommentsTabContent contract={contract} />,
+      content: <CommentsTabContent contract={contract} comments={comments} />,
       inlineTabIcon: <span>({formatMoney(COMMENT_BOUNTY_AMOUNT)})</span>,
     },
     {
@@ -76,12 +79,13 @@ export function ContractTabs(props: {
 
 const CommentsTabContent = memo(function CommentsTabContent(props: {
   contract: Contract
+  comments: ContractComment[]
 }) {
   const { contract } = props
   const tips = useTipTxns({ contractId: contract.id })
+  const comments = useComments(contract.id) ?? props.comments
   const [sort, setSort] = useState<'Newest' | 'Best'>('Best')
   const me = useUser()
-  const comments = useComments(contract.id)
   if (comments == null) {
     return <LoadingIndicator />
   }
@@ -133,12 +137,16 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
       </>
     )
   } else {
+    const tipsOrBountiesAwarded =
+      Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded)
     const commentsByParent = groupBy(
       sortBy(comments, (c) =>
         sort === 'Newest'
           ? -c.createdTime
-          : // Is this too magic? 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
-          c.createdTime > Date.now() - 10 * MINUTE_MS && c.userId === me?.id
+          : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score
+          tipsOrBountiesAwarded &&
+            c.createdTime > Date.now() - 10 * MINUTE_MS &&
+            c.userId === me?.id
           ? -Infinity
           : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? [])))
       ),
@@ -154,7 +162,15 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
           className="mb-4"
           onClick={() => setSort(sort === 'Newest' ? 'Best' : 'Newest')}
         >
-          Sorted by: {sort}
+          <Tooltip
+            text={
+              sort === 'Best'
+                ? 'Comments with tips or bounties will be shown first. Your comments made within the last 10 minutes will temporarily appear (to you) first.'
+                : ''
+            }
+          >
+            Sorted by: {sort}
+          </Tooltip>
         </Button>
         <ContractCommentInput className="mb-5" contract={contract} />
         {topLevelComments.map((parent) => (
diff --git a/web/components/contract/numeric-graph.tsx b/web/components/contract/numeric-graph.tsx
deleted file mode 100644
index f6532b9b..00000000
--- a/web/components/contract/numeric-graph.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { DatumValue } from '@nivo/core'
-import { Point, ResponsiveLine } from '@nivo/line'
-import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
-import { memo } from 'react'
-import { range } from 'lodash'
-import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
-import { NumericContract } from '../../../common/contract'
-import { useWindowSize } from '../../hooks/use-window-size'
-import { Col } from '../layout/col'
-import { formatLargeNumber } from 'common/util/format'
-
-export const NumericGraph = memo(function NumericGraph(props: {
-  contract: NumericContract
-  height?: number
-}) {
-  const { contract, height } = props
-  const { totalShares, bucketCount, min, max } = contract
-
-  const bucketProbs = getDpmOutcomeProbabilities(totalShares)
-
-  const xs = range(bucketCount).map(
-    (i) => min + ((max - min) * i) / bucketCount
-  )
-  const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100)
-  const points = probs.map((prob, i) => ({ x: xs[i], y: prob }))
-  const maxProb = Math.max(...probs)
-  const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
-
-  const yTickValues = [
-    0,
-    0.25 * maxProb,
-    0.5 & maxProb,
-    0.75 * maxProb,
-    maxProb,
-  ]
-
-  const { width } = useWindowSize()
-
-  const numXTickValues = !width || width < 800 ? 2 : 5
-
-  return (
-    <div
-      className="w-full overflow-hidden"
-      style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
-    >
-      <ResponsiveLine
-        data={data}
-        yScale={{ min: 0, max: maxProb, type: 'linear' }}
-        yFormat={formatPercent}
-        axisLeft={{
-          tickValues: yTickValues,
-          format: formatPercent,
-        }}
-        xScale={{
-          type: 'linear',
-          min: min,
-          max: max,
-        }}
-        xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
-        axisBottom={{
-          tickValues: numXTickValues,
-          format: (d) => `${formatLargeNumber(+d, 3)}`,
-        }}
-        colors={{ datum: 'color' }}
-        pointSize={0}
-        enableSlices="x"
-        sliceTooltip={({ slice }) => {
-          const point = slice.points[0]
-          return <Tooltip point={point} />
-        }}
-        enableGridX={!!width && width >= 800}
-        enableArea
-        margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
-      />
-    </div>
-  )
-})
-
-function formatPercent(y: DatumValue) {
-  const p = Math.round(+y * 100) / 100
-  return `${p}%`
-}
-
-function Tooltip(props: { point: Point }) {
-  const { point } = props
-  return (
-    <Col className="border border-gray-300 bg-white py-2 px-3">
-      <div
-        className="pb-1"
-        style={{
-          color: point.serieColor,
-        }}
-      >
-        <strong>{point.serieId}</strong> {point.data.yFormatted}
-      </div>
-      <div>{formatLargeNumber(+point.data.x)}</div>
-    </Col>
-  )
-}
diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx
index 07b7c659..f54ad915 100644
--- a/web/components/contract/prob-change-table.tsx
+++ b/web/components/contract/prob-change-table.tsx
@@ -7,6 +7,7 @@ import { SiteLink } from '../site-link'
 import { Col } from '../layout/col'
 import { Row } from '../layout/row'
 import { LoadingIndicator } from '../loading-indicator'
+import { useContractWithPreload } from 'web/hooks/use-contract'
 
 export function ProbChangeTable(props: {
   changes: CPMMContract[] | undefined
@@ -59,7 +60,9 @@ export function ProbChangeRow(props: {
   contract: CPMMContract
   className?: string
 }) {
-  const { contract, className } = props
+  const { className } = props
+  const contract =
+    (useContractWithPreload(props.contract) as CPMMContract) ?? props.contract
   return (
     <Row
       className={clsx(
diff --git a/web/hooks/use-element-width.tsx b/web/hooks/use-element-width.tsx
new file mode 100644
index 00000000..1c373839
--- /dev/null
+++ b/web/hooks/use-element-width.tsx
@@ -0,0 +1,17 @@
+import { RefObject, useState, useEffect } from 'react'
+
+// todo: consider consolidation with use-measure-size
+export const useElementWidth = <T extends Element>(ref: RefObject<T>) => {
+  const [width, setWidth] = useState<number>()
+  useEffect(() => {
+    const handleResize = () => {
+      setWidth(ref.current?.clientWidth)
+    }
+    handleResize()
+    window.addEventListener('resize', handleResize)
+    return () => {
+      window.removeEventListener('resize', handleResize)
+    }
+  }, [ref])
+  return width
+}
diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts
index db4e8ede..733a1e06 100644
--- a/web/lib/firebase/comments.ts
+++ b/web/lib/firebase/comments.ts
@@ -131,7 +131,7 @@ function getCommentsOnPostCollection(postId: string) {
 }
 
 export async function listAllComments(contractId: string) {
-  return await getValues<Comment>(
+  return await getValues<ContractComment>(
     query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
   )
 }
diff --git a/web/package.json b/web/package.json
index 24650ba9..a5fa8ced 100644
--- a/web/package.json
+++ b/web/package.json
@@ -39,6 +39,12 @@
     "browser-image-compression": "2.0.0",
     "clsx": "1.1.1",
     "cors": "2.8.5",
+    "d3-array": "3.2.0",
+    "d3-axis": "3.0.0",
+    "d3-brush": "3.0.0",
+    "d3-scale": "4.0.2",
+    "d3-shape": "3.1.0",
+    "d3-selection": "3.0.0",
     "daisyui": "1.16.4",
     "dayjs": "1.10.7",
     "firebase": "9.9.3",
@@ -66,6 +72,7 @@
     "@tailwindcss/forms": "0.4.0",
     "@tailwindcss/line-clamp": "^0.3.1",
     "@tailwindcss/typography": "^0.5.1",
+    "@types/d3": "7.4.0",
     "@types/lodash": "4.14.178",
     "@types/node": "16.11.11",
     "@types/react": "17.0.43",
diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx
index 1dde2f95..93b53447 100644
--- a/web/pages/[username]/[contractSlug].tsx
+++ b/web/pages/[username]/[contractSlug].tsx
@@ -46,6 +46,8 @@ import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
 import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
 import BetButton from 'web/components/bet-button'
 import { BetsSummary } from 'web/components/bet-summary'
+import { listAllComments } from 'web/lib/firebase/comments'
+import { ContractComment } from 'common/comment'
 
 export const getStaticProps = fromPropz(getStaticPropz)
 export async function getStaticPropz(props: {
@@ -55,10 +57,15 @@ export async function getStaticPropz(props: {
   const contract = (await getContractFromSlug(contractSlug)) || null
   const contractId = contract?.id
   const bets = contractId ? await listAllBets(contractId) : []
+  const comments = contractId ? await listAllComments(contractId) : []
 
   return {
-    // Limit the data sent to the client. Client will still load all bets directly.
-    props: { contract, bets: bets.slice(0, 5000) },
+    props: {
+      contract,
+      // Limit the data sent to the client. Client will still load all bets/comments directly.
+      bets: bets.slice(0, 5000),
+      comments: comments.slice(0, 1000),
+    },
     revalidate: 5, // regenerate after five seconds
   }
 }
@@ -70,9 +77,14 @@ export async function getStaticPaths() {
 export default function ContractPage(props: {
   contract: Contract | null
   bets: Bet[]
+  comments: ContractComment[]
   backToHome?: () => void
 }) {
-  props = usePropz(props, getStaticPropz) ?? { contract: null, bets: [] }
+  props = usePropz(props, getStaticPropz) ?? {
+    contract: null,
+    bets: [],
+    comments: [],
+  }
 
   const inIframe = useIsIframe()
   if (inIframe) {
@@ -147,7 +159,7 @@ export function ContractPageContent(
     contract: Contract
   }
 ) {
-  const { backToHome } = props
+  const { backToHome, comments } = props
   const contract = useContractWithPreload(props.contract) ?? props.contract
   const user = useUser()
   usePrefetch(user?.id)
@@ -258,7 +270,12 @@ export function ContractPageContent(
           userBets={userBets}
         />
 
-        <ContractTabs contract={contract} bets={bets} userBets={userBets} />
+        <ContractTabs
+          contract={contract}
+          bets={bets}
+          userBets={userBets}
+          comments={comments}
+        />
 
         {!user ? (
           <Col className="mt-4 max-w-sm items-center xl:hidden">
diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx
new file mode 100644
index 00000000..21494c37
--- /dev/null
+++ b/web/pages/cowp.tsx
@@ -0,0 +1,20 @@
+import Link from 'next/link'
+import { Page } from 'web/components/page'
+import { SEO } from 'web/components/SEO'
+
+const App = () => {
+  return (
+    <Page className="">
+      <SEO
+        title="COWP"
+        description="A picture of a cowpy cowp copwer cowp saying 'salutations'"
+        url="/cowp"
+      />
+      <Link href="https://www.youtube.com/watch?v=FavUpD_IjVY">
+        <img src="https://i.imgur.com/Lt54IiU.png" />
+      </Link>
+    </Page>
+  )
+}
+
+export default App
diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx
index 75a9ad05..e925a1f6 100644
--- a/web/pages/embed/[username]/[contractSlug].tsx
+++ b/web/pages/embed/[username]/[contractSlug].tsx
@@ -2,7 +2,6 @@ import { Bet } from 'common/bet'
 import { Contract } from 'common/contract'
 import { DOMAIN } from 'common/envs/constants'
 import { useState } from 'react'
-import { AnswersGraph } from 'web/components/answers/answers-graph'
 import { BetInline } from 'web/components/bet-inline'
 import { Button } from 'web/components/button'
 import {
@@ -12,8 +11,7 @@ import {
   PseudoNumericResolutionOrExpectation,
 } from 'web/components/contract/contract-card'
 import { MarketSubheader } from 'web/components/contract/contract-details'
-import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
-import { NumericGraph } from 'web/components/contract/numeric-graph'
+import { ContractChart } from 'web/components/charts/contract'
 import { Col } from 'web/components/layout/col'
 import { Row } from 'web/components/layout/row'
 import { Spacer } from 'web/components/layout/spacer'
@@ -134,22 +132,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
       )}
 
       <div className="mx-1 mb-2 min-h-0 flex-1" ref={setElem}>
-        {(isBinary || isPseudoNumeric) && (
-          <ContractProbGraph
-            contract={contract}
-            bets={[...bets].reverse()}
-            height={graphHeight}
-          />
-        )}
-
-        {(outcomeType === 'FREE_RESPONSE' ||
-          outcomeType === 'MULTIPLE_CHOICE') && (
-          <AnswersGraph contract={contract} bets={bets} height={graphHeight} />
-        )}
-
-        {outcomeType === 'NUMERIC' && (
-          <NumericGraph contract={contract} height={graphHeight} />
-        )}
+        <ContractChart contract={contract} bets={bets} height={graphHeight} />
       </div>
     </Col>
   )
diff --git a/yarn.lock b/yarn.lock
index 6eaaf43f..c012b75c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3245,6 +3245,216 @@
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
 
+"@types/d3-array@*":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac"
+  integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==
+
+"@types/d3-axis@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.1.tgz#6afc20744fa5cc0cbc3e2bd367b140a79ed3e7a8"
+  integrity sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-brush@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.1.tgz#ae5f17ce391935ca88b29000e60ee20452c6357c"
+  integrity sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-chord@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.1.tgz#54c8856c19c8e4ab36a53f73ba737de4768ad248"
+  integrity sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==
+
+"@types/d3-color@*":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4"
+  integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==
+
+"@types/d3-contour@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.1.tgz#9ff4e2fd2a3910de9c5097270a7da8a6ef240017"
+  integrity sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==
+  dependencies:
+    "@types/d3-array" "*"
+    "@types/geojson" "*"
+
+"@types/d3-delaunay@*":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
+  integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
+
+"@types/d3-dispatch@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz#a1b18ae5fa055a6734cb3bd3cbc6260ef19676e3"
+  integrity sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==
+
+"@types/d3-drag@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.1.tgz#fb1e3d5cceeee4d913caa59dedf55c94cb66e80f"
+  integrity sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-dsv@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.0.tgz#f3c61fb117bd493ec0e814856feb804a14cfc311"
+  integrity sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==
+
+"@types/d3-ease@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0"
+  integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==
+
+"@types/d3-fetch@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.1.tgz#f9fa88b81aa2eea5814f11aec82ecfddbd0b8fe0"
+  integrity sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==
+  dependencies:
+    "@types/d3-dsv" "*"
+
+"@types/d3-force@*":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.3.tgz#76cb20d04ae798afede1ea6e41750763ff5a9c82"
+  integrity sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==
+
+"@types/d3-format@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
+  integrity sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==
+
+"@types/d3-geo@*":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.0.2.tgz#e7ec5f484c159b2c404c42d260e6d99d99f45d9a"
+  integrity sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==
+  dependencies:
+    "@types/geojson" "*"
+
+"@types/d3-hierarchy@*":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz#4561bb7ace038f247e108295ef77b6a82193ac25"
+  integrity sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ==
+
+"@types/d3-interpolate@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
+  integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==
+  dependencies:
+    "@types/d3-color" "*"
+
+"@types/d3-path@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b"
+  integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==
+
+"@types/d3-polygon@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.0.tgz#5200a3fa793d7736fa104285fa19b0dbc2424b93"
+  integrity sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==
+
+"@types/d3-quadtree@*":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz#433112a178eb7df123aab2ce11c67f51cafe8ff5"
+  integrity sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==
+
+"@types/d3-random@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.1.tgz#5c8d42b36cd4c80b92e5626a252f994ca6bfc953"
+  integrity sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==
+
+"@types/d3-scale-chromatic@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#103124777e8cdec85b20b51fd3397c682ee1e954"
+  integrity sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==
+
+"@types/d3-scale@*":
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.2.tgz#41be241126af4630524ead9cb1008ab2f0f26e69"
+  integrity sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==
+  dependencies:
+    "@types/d3-time" "*"
+
+"@types/d3-selection@*":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.3.tgz#57be7da68e7d9c9b29efefd8ea5a9ef1171e42ba"
+  integrity sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA==
+
+"@types/d3-shape@*":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505"
+  integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA==
+  dependencies:
+    "@types/d3-path" "*"
+
+"@types/d3-time-format@*":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946"
+  integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==
+
+"@types/d3-time@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
+  integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
+
+"@types/d3-timer@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce"
+  integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==
+
+"@types/d3-transition@*":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.2.tgz#393dc3e3d55009a43cc6f252e73fccab6d78a8a4"
+  integrity sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-zoom@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826"
+  integrity sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==
+  dependencies:
+    "@types/d3-interpolate" "*"
+    "@types/d3-selection" "*"
+
+"@types/d3@7.4.0":
+  version "7.4.0"
+  resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.0.tgz#fc5cac5b1756fc592a3cf1f3dc881bf08225f515"
+  integrity sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==
+  dependencies:
+    "@types/d3-array" "*"
+    "@types/d3-axis" "*"
+    "@types/d3-brush" "*"
+    "@types/d3-chord" "*"
+    "@types/d3-color" "*"
+    "@types/d3-contour" "*"
+    "@types/d3-delaunay" "*"
+    "@types/d3-dispatch" "*"
+    "@types/d3-drag" "*"
+    "@types/d3-dsv" "*"
+    "@types/d3-ease" "*"
+    "@types/d3-fetch" "*"
+    "@types/d3-force" "*"
+    "@types/d3-format" "*"
+    "@types/d3-geo" "*"
+    "@types/d3-hierarchy" "*"
+    "@types/d3-interpolate" "*"
+    "@types/d3-path" "*"
+    "@types/d3-polygon" "*"
+    "@types/d3-quadtree" "*"
+    "@types/d3-random" "*"
+    "@types/d3-scale" "*"
+    "@types/d3-scale-chromatic" "*"
+    "@types/d3-selection" "*"
+    "@types/d3-shape" "*"
+    "@types/d3-time" "*"
+    "@types/d3-time-format" "*"
+    "@types/d3-timer" "*"
+    "@types/d3-transition" "*"
+    "@types/d3-zoom" "*"
+
 "@types/eslint-scope@^3.7.3":
   version "3.7.3"
   resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
@@ -3294,6 +3504,11 @@
     "@types/express-serve-static-core" "*"
     "@types/serve-static" "*"
 
+"@types/geojson@*":
+  version "7946.0.10"
+  resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249"
+  integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==
+
 "@types/google.maps@^3.45.3":
   version "3.49.0"
   resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.49.0.tgz#26fcf3d86ecbc6545db0e6691a434ec8132df48b"
@@ -5237,11 +5452,39 @@ d3-array@2, d3-array@^2.3.0:
   dependencies:
     internmap "^1.0.0"
 
+"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14"
+  integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==
+  dependencies:
+    internmap "1 - 2"
+
+d3-axis@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322"
+  integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==
+
+d3-brush@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c"
+  integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-drag "2 - 3"
+    d3-interpolate "1 - 3"
+    d3-selection "3"
+    d3-transition "3"
+
 "d3-color@1 - 2", d3-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
   integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
 
+"d3-color@1 - 3":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+  integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
 d3-delaunay@^5.3.0:
   version "5.3.0"
   resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d"
@@ -5249,11 +5492,34 @@ d3-delaunay@^5.3.0:
   dependencies:
     delaunator "4"
 
+"d3-dispatch@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
+  integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
+
+"d3-drag@2 - 3":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
+  integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-selection "3"
+
+"d3-ease@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+  integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
 "d3-format@1 - 2":
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767"
   integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==
 
+"d3-format@1 - 3":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
+  integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
+
 d3-format@^1.4.4:
   version "1.4.5"
   resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
@@ -5266,11 +5532,23 @@ d3-format@^1.4.4:
   dependencies:
     d3-color "1 - 2"
 
+"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+  integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+  dependencies:
+    d3-color "1 - 3"
+
 d3-path@1:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
   integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
 
+"d3-path@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e"
+  integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w==
+
 d3-scale-chromatic@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab"
@@ -5279,6 +5557,17 @@ d3-scale-chromatic@^2.0.0:
     d3-color "1 - 2"
     d3-interpolate "1 - 2"
 
+d3-scale@4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
+  integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
+  dependencies:
+    d3-array "2.10.0 - 3"
+    d3-format "1 - 3"
+    d3-interpolate "1.2.0 - 3"
+    d3-time "2.1.1 - 3"
+    d3-time-format "2 - 4"
+
 d3-scale@^3.2.3:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3"
@@ -5290,6 +5579,18 @@ d3-scale@^3.2.3:
     d3-time "^2.1.1"
     d3-time-format "2 - 3"
 
+d3-selection@3, d3-selection@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
+  integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
+
+d3-shape@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556"
+  integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ==
+  dependencies:
+    d3-path "1 - 3"
+
 d3-shape@^1.3.5:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
@@ -5304,6 +5605,13 @@ d3-shape@^1.3.5:
   dependencies:
     d3-time "1 - 2"
 
+"d3-time-format@2 - 4":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+  integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+  dependencies:
+    d3-time "1 - 3"
+
 "d3-time@1 - 2", d3-time@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682"
@@ -5311,11 +5619,34 @@ d3-shape@^1.3.5:
   dependencies:
     d3-array "2"
 
+"d3-time@1 - 3", "d3-time@2.1.1 - 3":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975"
+  integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==
+  dependencies:
+    d3-array "2 - 3"
+
 d3-time@^1.0.11:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
   integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
 
+"d3-timer@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+  integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
+d3-transition@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
+  integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
+  dependencies:
+    d3-color "1 - 3"
+    d3-dispatch "1 - 3"
+    d3-ease "1 - 3"
+    d3-interpolate "1 - 3"
+    d3-timer "1 - 3"
+
 daisyui@1.16.4:
   version "1.16.4"
   resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-1.16.4.tgz#52773401c0962e37ef40507d29f0e513c7f2856f"
@@ -7514,6 +7845,11 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+"internmap@1 - 2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+  integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
 internmap@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"