diff --git a/front_end/messages/en.json b/front_end/messages/en.json index f73cc6e286..e8f23220c5 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -482,6 +482,8 @@ "forJournalists": "For Journalists", "trackRecord": "Track Record", "medals": "Medals", + "metaculusTrackRecord": "Metaculus Track Record", + "trackRecordOutdatedMessage": "This track record does not yet use the new Baseline and Peer scores introduced in November 2023. Updating this page is one of our next priorities.", "theJournal": "The Journal", "account": "Account", "settings": "Settings", @@ -713,5 +715,14 @@ "metaculusOnTwitter": "Metaculus on Twitter", "metaculusOnDiscord": "Metaculus on Discord", "signUpAsBot": "Sign Up as Bot", - "failedToCopyText": "failed to copy text: " + "failedToCopyText": "failed to copy text: ", + "resolutionLabel": "Resolution:", + "totalQuestions": "Total questions:", + "averageScore": "Average score:", + "confidenceInterval": "confidence interval", + "perfectCalibration": "perfect calibration", + "userCalibration": "user's calibration", + "scatterPlotHoverMessage": "Hover over a circle to see how that question resolved.", + "calibrationCurve": "Calibration Curve", + "calibrationCurveInfo": "If the diamonds are close to the grey lines, the predictions are well-calibrated at that confidence level. If the diamonds are closer to the 50% than the diamonds, the predictions were underconfident, and vice-versa." } diff --git a/front_end/src/app/(main)/accounts/profile/components/track_record.tsx b/front_end/src/app/(main)/accounts/profile/components/track_record.tsx index f07a492fb2..738a069bcc 100644 --- a/front_end/src/app/(main)/accounts/profile/components/track_record.tsx +++ b/front_end/src/app/(main)/accounts/profile/components/track_record.tsx @@ -3,9 +3,7 @@ import { useTranslations } from "next-intl"; import { FC } from "react"; -import CalibrationChart from "@/app/(main)/charts/calibration_chart"; -import ScatterPlot from "@/app/(main)/charts/scatter_plot"; -import UserHistogram from "@/app/(main)/charts/user_histogram"; +import TrackRecordCharts from "@/app/(main)/questions/track-record/components/track_record_charts"; import { UserProfile } from "@/types/users"; const TrackRecord: FC<{ profile: UserProfile }> = ({ profile }) => { @@ -15,62 +13,12 @@ const TrackRecord: FC<{ profile: UserProfile }> = ({ profile }) => { return (
-
-

- {t("scoreScatterPlot")} -

- {profile.score_scatter_plot && ( - - )} -

- {t("scoreHistogram")} -

- {profile.score_histogram && ( - - )} - -

- {t("scoreHistogram")} -

- {profile.score_histogram && ( - - )} - -

- Calibration Curve -

- {profile.calibration_curve && ( - - )} -
-
-
- confidence - interval -
-
- perfect - calibration -
-
- - user's calibration -
-
- - If the diamonds are close to the grey lines, the predictions are - well-calibrated at that confidence level. If the diamonds are closer - to the 50% than the diamonds, the predictions were underconfident, - and vice-versa. - -
-
+
diff --git a/front_end/src/app/(main)/accounts/profile/components/user_info.tsx b/front_end/src/app/(main)/accounts/profile/components/user_info.tsx index b8407657f4..e6d947b6df 100644 --- a/front_end/src/app/(main)/accounts/profile/components/user_info.tsx +++ b/front_end/src/app/(main)/accounts/profile/components/user_info.tsx @@ -15,7 +15,7 @@ import { UpdateProfileSchema, updateProfileSchema, } from "@/app/(main)/accounts/schemas"; -import CalibrationChart from "@/app/(main)/charts/calibration_chart"; +import CalibrationChart from "@/app/(main)/questions/track-record/components/charts/calibration_chart"; import Button from "@/components/ui/button"; import { FormError, Input, Textarea } from "@/components/ui/form_field"; import { useAuth } from "@/contexts/auth_context"; diff --git a/front_end/src/app/(main)/charts/scatter_plot.tsx b/front_end/src/app/(main)/charts/scatter_plot.tsx deleted file mode 100644 index fbe36b5356..0000000000 --- a/front_end/src/app/(main)/charts/scatter_plot.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import React from "react"; -import { - VictoryAxis, - VictoryChart, - VictoryScatter, - VictoryLine, -} from "victory"; - -import ChartContainer from "@/components/charts/primitives/chart_container"; -import XTickLabel from "@/components/charts/primitives/x_tick_label"; -import { darkTheme, lightTheme } from "@/constants/chart_theme"; -import { METAC_COLORS } from "@/constants/colors"; -import useAppTheme from "@/hooks/use_app_theme"; -import useContainerSize from "@/hooks/use_container_size"; -import { TimelineChartZoomOption } from "@/types/charts"; -import { - generateNumericDomain, - generateTicksY, - generateTimestampXScale, -} from "@/utils/charts"; - -type HistogramProps = { - score_scatter_plot: { score: number; score_timestamp: number }[]; -}; - -const ScatterPlot: React.FC = ({ score_scatter_plot }) => { - const t = useTranslations(); - const { theme, getThemeColor } = useAppTheme(); - const chartTheme = theme === "dark" ? darkTheme : lightTheme; - - const { ref: chartContainerRef, width: chartWidth } = - useContainerSize(); - - const { - overallAverage, - movingAverage, - ticksY, - ticksYFormat, - xDomain, - xScale, - } = buildChartData({ score_scatter_plot, chartWidth }); - - return ( - - - { - return { - x: point.score_timestamp, - y: point.score, - size: 5, - }; - })} - style={{ - data: { - stroke: getThemeColor(METAC_COLORS.blue["600"]), - fill: "none", - strokeWidth: 1, - }, - }} - /> - - - - } - /> - - - ); -}; - -function buildChartData({ - score_scatter_plot, - chartWidth, -}: { - score_scatter_plot: { score: number; score_timestamp: number }[]; - chartWidth: number; -}) { - const overallAverage = - score_scatter_plot.length === 0 - ? 0 - : score_scatter_plot.reduce((reducer, data) => { - return reducer + data.score; - }, 0) / score_scatter_plot.length; - - let scoreLocalSum = 0; - const movingAverage = score_scatter_plot.map((data, index) => { - scoreLocalSum += data.score; - return { - x: data.score_timestamp, - y: scoreLocalSum / (index + 1), - }; - }); - - const { ticks: ticksY, tickFormat: ticksYFormat } = generateTicksY( - 270, - [-100, -50, 0, 50, 100] - ); - const xDomain = generateNumericDomain( - score_scatter_plot.map((data) => data.score_timestamp), - "all" as TimelineChartZoomOption - ); - const xScale = generateTimestampXScale(xDomain, chartWidth); - - return { - overallAverage, - movingAverage, - ticksY, - ticksYFormat, - xDomain, - xScale, - }; -} - -export default ScatterPlot; diff --git a/front_end/src/app/(main)/charts/user_histogram.tsx b/front_end/src/app/(main)/charts/user_histogram.tsx deleted file mode 100644 index dd2e53f9cc..0000000000 --- a/front_end/src/app/(main)/charts/user_histogram.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { range } from "lodash"; -import { useTranslations } from "next-intl"; -import React from "react"; -import { - VictoryAxis, - VictoryChart, - VictoryContainer, - VictoryArea, -} from "victory"; - -import { darkTheme, lightTheme } from "@/constants/chart_theme"; -import useAppTheme from "@/hooks/use_app_theme"; -import { generateTicksY } from "@/utils/charts"; - -type HistogramProps = { - rawHistogramData: { - bin_start: number; - bin_end: number; - pct_scores: number; - }[]; - color: string; -}; - -const UserHistogram: React.FC = ({ - rawHistogramData, - color, -}) => { - const histogramData = mapHistogramData(rawHistogramData); - const t = useTranslations(); - const { theme } = useAppTheme(); - const chartTheme = theme === "dark" ? darkTheme : lightTheme; - - const { ticks: ticksY, tickFormat: ticksYFormat } = generateTicksY( - 180, - [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] - ); - return ( - } - padding={{ top: 20, bottom: 65, left: 40, right: 20 }} - height={180} - > - - - - - ); -}; - -const mapHistogramData = ( - userHistogram: { - bin_start: number; - bin_end: number; - pct_scores: number; - }[] -) => { - const mappedArray = [] as { x: number; y: number }[]; - userHistogram.forEach((data, index) => { - mappedArray.push( - ...[ - { x: data.bin_start, y: Math.max(data.pct_scores, 0) }, - { x: data.bin_end - 1, y: Math.max(data.pct_scores, 0) }, - ] - ); - }); - return mappedArray; -}; - -export default UserHistogram; diff --git a/front_end/src/app/(main)/questions/[id]/components/histogram_drawer.tsx b/front_end/src/app/(main)/questions/[id]/components/histogram_drawer.tsx index c61195da66..a19dec6156 100644 --- a/front_end/src/app/(main)/questions/[id]/components/histogram_drawer.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/histogram_drawer.tsx @@ -3,7 +3,7 @@ import { useTranslations } from "next-intl"; import React from "react"; -import Histogram from "@/app/(main)/charts/histogram"; +import Histogram from "@/components/charts/histogram"; import ExpandableContent from "@/components/ui/expandable_content"; import SectionToggle from "@/components/ui/section_toggle"; import { PostWithForecasts } from "@/types/post"; diff --git a/front_end/src/app/(main)/questions/track-record/components/async_track_record.tsx b/front_end/src/app/(main)/questions/track-record/components/async_track_record.tsx new file mode 100644 index 0000000000..c117356002 --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/components/async_track_record.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; + +import TrackRecordApi from "@/services/track_record"; + +import TrackRecordCharts from "./track_record_charts"; + +const AsyncTrackRecord: FC = async () => { + const trackRecord = await TrackRecordApi.getGlobalTrackRecord(); + + return ( + + ); +}; + +export default AsyncTrackRecord; diff --git a/front_end/src/app/(main)/charts/calibration_chart.tsx b/front_end/src/app/(main)/questions/track-record/components/charts/calibration_chart.tsx similarity index 93% rename from front_end/src/app/(main)/charts/calibration_chart.tsx rename to front_end/src/app/(main)/questions/track-record/components/charts/calibration_chart.tsx index fff596fb85..1752df6e1b 100644 --- a/front_end/src/app/(main)/charts/calibration_chart.tsx +++ b/front_end/src/app/(main)/questions/track-record/components/charts/calibration_chart.tsx @@ -13,11 +13,12 @@ import { import { darkTheme, lightTheme } from "@/constants/chart_theme"; import useAppTheme from "@/hooks/use_app_theme"; +import { TrackRecordCalibrationCurveItem } from "@/types/track_record"; -const CalibrationChart: React.FC<{ data: any; showIntervals?: boolean }> = ({ - data, - showIntervals = true, -}) => { +const CalibrationChart: React.FC<{ + data: TrackRecordCalibrationCurveItem[]; + showIntervals?: boolean; +}> = ({ data, showIntervals = true }) => { const calibrationData = data; const { theme, getThemeColor } = useAppTheme(); diff --git a/front_end/src/app/(main)/questions/track-record/components/charts/scatter_plot.tsx b/front_end/src/app/(main)/questions/track-record/components/charts/scatter_plot.tsx new file mode 100644 index 0000000000..784661139d --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/components/charts/scatter_plot.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React, { useMemo, useState } from "react"; +import { + VictoryAxis, + VictoryChart, + VictoryScatter, + VictoryLine, +} from "victory"; + +import ChartContainer from "@/components/charts/primitives/chart_container"; +import XTickLabel from "@/components/charts/primitives/x_tick_label"; +import { darkTheme, lightTheme } from "@/constants/chart_theme"; +import { METAC_COLORS } from "@/constants/colors"; +import useAppTheme from "@/hooks/use_app_theme"; +import useContainerSize from "@/hooks/use_container_size"; +import { TimelineChartZoomOption } from "@/types/charts"; +import { TrackRecordScatterPlotItem } from "@/types/track_record"; +import { + generateNumericDomain, + generateTicksY, + generateTimestampXScale, +} from "@/utils/charts"; + +import TrackRecordChartHero from "../track_record_chart_hero"; + +type HistogramProps = { + score_scatter_plot: TrackRecordScatterPlotItem[]; +}; + +const ScatterPlot: React.FC = ({ score_scatter_plot }) => { + const t = useTranslations(); + const { theme, getThemeColor } = useAppTheme(); + const chartTheme = theme === "dark" ? darkTheme : lightTheme; + + const { ref: chartContainerRef, width: chartWidth } = + useContainerSize(); + + const { + overallAverage, + movingAverage, + ticksY, + ticksYFormat, + xDomain, + xScale, + } = buildChartData({ score_scatter_plot, chartWidth }); + + const [activeIndex, setActiveIndex] = useState(null); + const hoverData = useMemo(() => { + if (activeIndex === null || !score_scatter_plot[activeIndex]) { + return null; + } + + const point = score_scatter_plot[activeIndex]; + if (!point) { + return null; + } + + return point; + }, [activeIndex, score_scatter_plot]); + + const averageScore = useMemo(() => { + const sum = score_scatter_plot.reduce((acc, { score }) => acc + score, 0); + return (sum / score_scatter_plot.length).toFixed(3); + }, [score_scatter_plot]); + + return ( + <> + + + + + { + return { + x: point.score_timestamp, + y: point.score, + size: index === activeIndex ? 6 : 5, + }; + })} + style={{ + data: { + stroke: getThemeColor(METAC_COLORS.blue["600"]), + fill: "none", + strokeWidth: 1, + }, + }} + events={[ + { + target: "data", + eventHandlers: { + onMouseOver: (_event, datum) => { + setActiveIndex(datum.index); + return { + mutation: (props) => { + return { + style: Object.assign({}, props.style, { + strokeWidth: 3, + }), + }; + }, + }; + }, + onMouseOut: () => { + setActiveIndex(null); + return { + mutation: () => null, + }; + }, + }, + }, + ]} + /> + + + + } + /> + + +
+ {hoverData ? ( + <> +
+ {hoverData.question_title} +
+
+ {t("resolutionLabel")} {hoverData.question_resolution} +
+ + ) : ( +
{t("scatterPlotHoverMessage")}
+ )} +
+ + ); +}; + +function buildChartData({ + score_scatter_plot, + chartWidth, +}: { + score_scatter_plot: { score: number; score_timestamp: number }[]; + chartWidth: number; +}) { + const overallAverage = + score_scatter_plot.length === 0 + ? 0 + : score_scatter_plot.reduce((reducer, data) => { + return reducer + data.score; + }, 0) / score_scatter_plot.length; + + let scoreLocalSum = 0; + const movingAverage = score_scatter_plot.map((data, index) => { + scoreLocalSum += data.score; + return { + x: data.score_timestamp, + y: scoreLocalSum / (index + 1), + }; + }); + + const { ticks: ticksY, tickFormat: ticksYFormat } = generateTicksY( + 270, + [-100, -50, 0, 50, 100] + ); + const xDomain = generateNumericDomain( + score_scatter_plot.map((data) => data.score_timestamp), + "all" as TimelineChartZoomOption + ); + const xScale = generateTimestampXScale(xDomain, chartWidth); + + return { + overallAverage, + movingAverage, + ticksY, + ticksYFormat, + xDomain, + xScale, + }; +} + +export default ScatterPlot; diff --git a/front_end/src/app/(main)/questions/track-record/components/charts/user_histogram.tsx b/front_end/src/app/(main)/questions/track-record/components/charts/user_histogram.tsx new file mode 100644 index 0000000000..1b6f0453d8 --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/components/charts/user_histogram.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { range } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { useMemo } from "react"; +import { + VictoryAxis, + VictoryChart, + VictoryContainer, + VictoryArea, +} from "victory"; + +import { darkTheme, lightTheme } from "@/constants/chart_theme"; +import useAppTheme from "@/hooks/use_app_theme"; +import { TrackRecordHistogramItem } from "@/types/track_record"; +import { generateTicksY } from "@/utils/charts"; + +import TrackRecordChartHero from "../track_record_chart_hero"; + +type HistogramProps = { + rawHistogramData: TrackRecordHistogramItem[]; + color: string; +}; + +const UserHistogram: React.FC = ({ + rawHistogramData, + color, +}) => { + const histogramData = mapHistogramData(rawHistogramData); + const t = useTranslations(); + const { theme } = useAppTheme(); + const chartTheme = theme === "dark" ? darkTheme : lightTheme; + + const { ticks: ticksY, tickFormat: ticksYFormat } = generateTicksY( + 180, + [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] + ); + + const averageScore = useMemo(() => { + const sum = histogramData.reduce((acc, { y }) => acc + y, 0); + + return Math.round((sum / histogramData.length) * 1000); + }, [histogramData]); + + return ( + <> + + + } + padding={{ top: 20, bottom: 65, left: 40, right: 20 }} + height={180} + > + + + + + + ); +}; + +const mapHistogramData = ( + userHistogram: { + bin_start: number; + bin_end: number; + pct_scores: number; + }[] +) => { + const mappedArray = [] as { x: number; y: number }[]; + userHistogram.forEach((data, index) => { + mappedArray.push( + ...[ + { x: data.bin_start, y: Math.max(data.pct_scores, 0) }, + { x: data.bin_end - 1, y: Math.max(data.pct_scores, 0) }, + ] + ); + }); + return mappedArray; +}; + +export default UserHistogram; diff --git a/front_end/src/app/(main)/questions/track-record/components/track_record_chart_hero.tsx b/front_end/src/app/(main)/questions/track-record/components/track_record_chart_hero.tsx new file mode 100644 index 0000000000..99b6ef00ed --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/components/track_record_chart_hero.tsx @@ -0,0 +1,26 @@ +import { useTranslations } from "next-intl"; +import React, { FC } from "react"; + +type Props = { + totalQuestions: string; + averageScore: string; +}; + +const TrackRecordChartHero: FC = ({ totalQuestions, averageScore }) => { + const t = useTranslations(); + + return ( +
+ {t("totalQuestions")} + + {" " + totalQuestions} + + {t("averageScore")} + + {" " + averageScore} + +
+ ); +}; + +export default TrackRecordChartHero; diff --git a/front_end/src/app/(main)/questions/track-record/components/track_record_charts.tsx b/front_end/src/app/(main)/questions/track-record/components/track_record_charts.tsx new file mode 100644 index 0000000000..081d2eeca4 --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/components/track_record_charts.tsx @@ -0,0 +1,70 @@ +import classNames from "classnames"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import { + TrackRecordCalibrationCurveItem, + TrackRecordHistogramItem, + TrackRecordScatterPlotItem, +} from "@/types/track_record"; + +import CalibrationChart from "./charts/calibration_chart"; +import ScatterPlot from "./charts/scatter_plot"; +import UserHistogram from "./charts/user_histogram"; + +type Props = { + scatterPlot?: TrackRecordScatterPlotItem[]; + scoreHistogram?: TrackRecordHistogramItem[]; + calibrationCurve?: TrackRecordCalibrationCurveItem[]; + className?: string; +}; + +const TrackRecordCharts: FC = ({ + scatterPlot, + scoreHistogram, + calibrationCurve, + className, +}) => { + const t = useTranslations(); + + return ( +
+

+ {t("scoreScatterPlot")} +

+ {scatterPlot && } +

+ {t("scoreHistogram")} +

+ {scoreHistogram && ( + + )} + +

+ {t("calibrationCurve")} +

+ {calibrationCurve && } +
+
+
+ + {t("confidenceInterval")} +
+
+ + {t("perfectCalibration")} +
+
+ + {t("userCalibration")} +
+
+ + {t("calibrationCurveInfo")} + +
+
+ ); +}; + +export default TrackRecordCharts; diff --git a/front_end/src/app/(main)/questions/track-record/page.tsx b/front_end/src/app/(main)/questions/track-record/page.tsx new file mode 100644 index 0000000000..541dd715a9 --- /dev/null +++ b/front_end/src/app/(main)/questions/track-record/page.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; + +import LoadingIndicator from "@/components/ui/loading_indicator"; + +import AsyncTrackRecord from "./components/async_track_record"; + +export default async function TrackRecordPage() { + const t = await getTranslations(); + + return ( +
+
+

{t("metaculusTrackRecord")}

+
+

+ {t.rich("trackRecordOutdatedMessage", { + link: (chunks) => ( + {chunks} + ), + })} +

+
+
+ + }> + + +
+ ); +} diff --git a/front_end/src/app/(main)/charts/histogram.tsx b/front_end/src/components/charts/histogram.tsx similarity index 98% rename from front_end/src/app/(main)/charts/histogram.tsx rename to front_end/src/components/charts/histogram.tsx index f6f077d158..df79f16b92 100644 --- a/front_end/src/app/(main)/charts/histogram.tsx +++ b/front_end/src/components/charts/histogram.tsx @@ -8,8 +8,6 @@ import { VictoryBar, VictoryChart, VictoryContainer, - VictoryLine, - VictoryLabel, } from "victory"; import { darkTheme, lightTheme } from "@/constants/chart_theme"; diff --git a/front_end/src/services/track_record.ts b/front_end/src/services/track_record.ts new file mode 100644 index 0000000000..d331e4e47f --- /dev/null +++ b/front_end/src/services/track_record.ts @@ -0,0 +1,10 @@ +import { GlobalTrackRecord } from "@/types/track_record"; +import { get } from "@/utils/fetch"; + +class TrackRecordApi { + static async getGlobalTrackRecord() { + return await get("/metaculus_track_record"); + } +} + +export default TrackRecordApi; diff --git a/front_end/src/types/track_record.ts b/front_end/src/types/track_record.ts new file mode 100644 index 0000000000..68aa1721d0 --- /dev/null +++ b/front_end/src/types/track_record.ts @@ -0,0 +1,27 @@ +import { Resolution } from "@/types/post"; + +export type TrackRecordHistogramItem = { + bin_start: number; + bin_end: number; + pct_scores: number; +}; + +export type TrackRecordScatterPlotItem = { + score: number; + score_timestamp: number; + question_title: string; + question_resolution: Resolution; +}; + +export type TrackRecordCalibrationCurveItem = { + user_lower_quartile: number; + user_middle_quartile: number; + user_upper_quartile: number; + perfect_calibration: number; +}; + +export type GlobalTrackRecord = { + calibration_curve: TrackRecordCalibrationCurveItem[]; + score_histogram: TrackRecordHistogramItem[]; + score_scatter_plot: TrackRecordScatterPlotItem[]; +}; diff --git a/front_end/src/types/users.ts b/front_end/src/types/users.ts index a651332822..2d3ff20895 100644 --- a/front_end/src/types/users.ts +++ b/front_end/src/types/users.ts @@ -1,4 +1,9 @@ import { SubscriptionEmailType } from "@/types/notifications"; +import { + TrackRecordScatterPlotItem, + TrackRecordHistogramItem, + TrackRecordCalibrationCurveItem, +} from "@/types/track_record"; import { ProfilePreferencesType } from "./preferences"; @@ -28,13 +33,9 @@ export type User = { }; export type UserProfile = User & { - calibration_curve?: any; - score_histogram?: { - bin_start: number; - bin_end: number; - pct_scores: number; - }[]; - score_scatter_plot?: { score: number; score_timestamp: number }[]; + calibration_curve?: TrackRecordCalibrationCurveItem[]; + score_histogram?: TrackRecordHistogramItem[]; + score_scatter_plot?: TrackRecordScatterPlotItem[]; nr_forecasts?: number; nr_comments?: number; avg_score?: number;