diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 4209b695d1..0ca59d68ee 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -591,6 +591,12 @@ "parentBackgroundInfo": "Základní informace o nadřazené otázce", "childBackgroundInfo": "Základní informace o podřazené otázce", "histogram": "Histogram", + "distributions": "Rozdělení", + "quartileLabelP25": "P25", + "quartileLabelMedian": "Medián", + "quartileLabelP75": "P75", + "pdfCdfExplanation": "PDF (funkce hustoty pravděpodobnosti) ukazuje, jak pravděpodobné jsou různé výsledky kolem konkrétních hodnot, zatímco CDF (distribuční funkce) ukazuje kumulativní pravděpodobnost výsledků až po určitou hodnotu.", + "pmfCdfExplanation": "PMF (pravděpodobnostní funkce) ukazuje, jak pravděpodobné jsou různé konkrétní výsledky, zatímco CDF (distribuční funkce) ukazuje kumulativní pravděpodobnost výsledků až po určitou hodnotu.", "frequency": "Frekvence", "brierScoreForPlayer": "Brierova skóre pro hráčské předpovědi", "scoreHistogram": "Histogram skóre", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index d413ad47ca..8e555e0b28 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -912,6 +912,12 @@ "parentBackgroundInfo": "Parent Question Background Info", "childBackgroundInfo": "Child Question Background Info", "histogram": "Histogram", + "distributions": "Distributions", + "quartileLabelP25": "25th %", + "quartileLabelMedian": "Median", + "quartileLabelP75": "75th %", + "pdfCdfExplanation": "PDF (Probability Density Function) shows how likely different outcomes are around specific values, while CDF (Cumulative Distribution Function) shows the cumulative probability of outcomes up to a certain value.", + "pmfCdfExplanation": "PMF (Probability Mass Function) shows how likely different specific outcomes are, while CDF (Cumulative Distribution Function) shows the cumulative probability of outcomes up to a certain value.", "frequency": "Frequency", "brierScoreForPlayer": "Brier scores for player predictions", "scoreHistogram": "Score Histogram", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 975177e173..47711e0401 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -600,6 +600,12 @@ "parentBackgroundInfo": "Información de Fondo de la Pregunta Principal", "childBackgroundInfo": "Información de Fondo de la Pregunta Secundaria", "histogram": "Histograma", + "distributions": "Distribuciones", + "quartileLabelP25": "P25", + "quartileLabelMedian": "Mediana", + "quartileLabelP75": "P75", + "pdfCdfExplanation": "La PDF (función de densidad de probabilidad) muestra qué tan probables son los distintos resultados alrededor de valores específicos, mientras que la CDF (función de distribución acumulada) muestra la probabilidad acumulada de resultados hasta un valor determinado.", + "pmfCdfExplanation": "La PMF (función de masa de probabilidad) muestra qué tan probables son distintos resultados específicos, mientras que la CDF (función de distribución acumulada) muestra la probabilidad acumulada de resultados hasta un valor determinado.", "frequency": "Frecuencia", "brierScoreForPlayer": "Puntuaciones Brier para las predicciones de los jugadores", "scoreHistogram": "Histograma de Puntuaciones", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 0487627a14..4a58935f03 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -666,6 +666,12 @@ "parentBackgroundInfo": "Informações de Fundo da Pergunta Pai", "childBackgroundInfo": "Informações de Fundo da Pergunta Filha", "histogram": "Histograma", + "distributions": "Distribuições", + "quartileLabelP25": "P25", + "quartileLabelMedian": "Mediana", + "quartileLabelP75": "P75", + "pdfCdfExplanation": "A PDF (Função de Densidade de Probabilidade) mostra a probabilidade de diferentes resultados em torno de valores específicos, enquanto a CDF (Função de Distribuição Acumulada) mostra a probabilidade acumulada de resultados até um determinado valor.", + "pmfCdfExplanation": "A PMF (Função de Massa de Probabilidade) mostra a probabilidade de diferentes resultados específicos, enquanto a CDF (Função de Distribuição Acumulada) mostra a probabilidade acumulada de resultados até um determinado valor.", "frequency": "Frequência", "brierScoreForPlayer": "Pontuações de Brier para previsões dos jogadores", "scoreHistogram": "Histograma de Pontuações", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index eb384c74e8..4ffe990442 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -714,6 +714,12 @@ "parentBackgroundInfo": "父問題背景資訊", "childBackgroundInfo": "子問題背景資訊", "histogram": "直方圖", + "distributions": "分佈", + "quartileLabelP25": "P25", + "quartileLabelMedian": "中位數", + "quartileLabelP75": "P75", + "pdfCdfExplanation": "PDF(機率密度函數)顯示不同結果在特定值附近的可能性,而 CDF(累積分佈函數)顯示結果直到某一數值的累積機率。", + "pmfCdfExplanation": "PMF(機率質量函數)顯示不同具體結果的可能性,而 CDF(累積分佈函數)顯示結果直到某一數值的累積機率。", "frequency": "頻率", "brierScoreForPlayer": "玩家預測的Brier分數", "scoreHistogram": "分數直方圖", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 93804f09b6..186ee90cc7 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -589,6 +589,12 @@ "parentBackgroundInfo": "家長問題背景資訊", "childBackgroundInfo": "兒童問題背景訊息", "histogram": "直方圖", + "distributions": "分布", + "quartileLabelP25": "P25", + "quartileLabelMedian": "中位数", + "quartileLabelP75": "P75", + "pdfCdfExplanation": "PDF(概率密度函数)显示不同结果在特定值附近的可能性,而 CDF(累积分布函数)显示结果直到某一数值的累积概率。", + "pmfCdfExplanation": "PMF(概率质量函数)显示不同具体结果的可能性,而 CDF(累积分布函数)显示结果直到某一数值的累积概率。", "frequency": "頻率", "brierScoreForPlayer": "Brier 球員預測得分", "scoreHistogram": "分數直方圖", diff --git a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx index 0ca4ffe315..996f312c9d 100644 --- a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx @@ -62,6 +62,8 @@ type Props = QuestionsDataProps & { withHighlightEndpoint?: boolean; onCursorChange?: (ts: number) => void; hideTooltip?: boolean; + headerLeft?: ReactNode; + hideChartTitle?: boolean; }; /** @@ -96,6 +98,8 @@ const GroupTimeline: FC = ({ withHighlightEndpoint, onCursorChange, hideTooltip, + headerLeft, + hideChartTitle, }) => { const t = useTranslations(); const { user } = useAuth(); @@ -367,6 +371,8 @@ const GroupTimeline: FC = ({ withHighlightArea={withHighlightArea} withHighlightEndpoint={withHighlightEndpoint} hideTooltip={hideTooltip} + headerLeft={headerLeft} + hideChartTitle={hideChartTitle} /> ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx index 6f4589322d..4388122fd9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx @@ -61,6 +61,8 @@ type Props = { withHighlightArea?: boolean; withHighlightEndpoint?: boolean; hideTooltip?: boolean; + headerLeft?: ReactNode; + hideChartTitle?: boolean; }; const MultiChoicesChartView: FC = ({ @@ -97,6 +99,8 @@ const MultiChoicesChartView: FC = ({ withHighlightArea = true, withHighlightEndpoint = false, hideTooltip = false, + headerLeft, + hideChartTitle = false, }) => { const { user } = useAuth(); const isInteracted = useRef(false); @@ -300,7 +304,7 @@ const MultiChoicesChartView: FC = ({ !!forecastAvailability?.cpRevealsOn } choiceItems={binaryChoiceItems} - chartTitle={!embedMode ? title : undefined} + chartTitle={!embedMode && !hideChartTitle ? title : undefined} timelineMarkers={timelineMarkers} activeTimelineMarkerId={activeTimelineMarkerId} onTimelineMarkerEnter={onTimelineMarkerEnter} @@ -316,7 +320,9 @@ const MultiChoicesChartView: FC = ({ hideCP={hideCP} /> - ) : undefined + ) : ( + headerLeft + ) } forceShowLinePoints={!embedMode} /> @@ -324,7 +330,7 @@ const MultiChoicesChartView: FC = ({ = ({ hideCP={hideCP} /> - ) : undefined + ) : ( + headerLeft + ) } /> ) : ( @@ -350,7 +358,7 @@ const MultiChoicesChartView: FC = ({ } cursorTimestamp={cursorTimestamp} choiceItems={choiceItems} - chartTitle={!embedMode ? title : undefined} + chartTitle={!embedMode && !hideChartTitle ? title : undefined} timelineMarkers={timelineMarkers} activeTimelineMarkerId={activeTimelineMarkerId} onTimelineMarkerEnter={onTimelineMarkerEnter} @@ -366,7 +374,9 @@ const MultiChoicesChartView: FC = ({ hideCP={hideCP} /> - ) : undefined + ) : ( + headerLeft + ) } forceShowLinePoints={!embedMode} /> diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 6e3887098f..2a0627b4a9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -57,6 +57,7 @@ import PostScoreData from "../post_score_data"; import { QuestionLayoutProvider } from "../question_layout/question_layout_context"; import { QuestionVariantComposer } from "../question_variant_composer"; import ActionRow from "../question_view/action_row"; +import ConsumerDateGroupChart from "../question_view/consumer_question_view/consumer_date_group_chart"; import ConsumerGroupChart from "../question_view/consumer_question_view/consumer_group_chart"; import ConsumerListChartShell from "../question_view/consumer_question_view/consumer_list_chart_shell"; import ConsumerTimeSeriesPane from "../question_view/consumer_question_view/consumer_time_series_pane"; @@ -360,9 +361,9 @@ export const ConsumerShell: FC<{ @@ -404,13 +405,10 @@ export const ConsumerShell: FC<{ preselectedQuestionId={preselectedGroupQuestionId} /> ) : isDateGroup && postData.group_of_questions ? ( - + } /> ) : ( = ({ post, variant, className }) => { const { activeTab, setActiveTab } = useQuestionLayout(); const isSm = useBreakpoint("sm"); + const { hideCP } = useHideCP(); const commentsFeed = useCommentsFeedSafe(); const commentCount = typeof commentsFeed?.totalCount === "number" @@ -86,6 +90,9 @@ const QuestionPageShellTabBar: FC = ({ post, variant, className }) => { ...(!isSm && hasTimeline(post) ? [{ key: "timeline" as TabKey, label: t("timeline") }] : []), + ...(!isSm && !hideCP && hasGroupDistributions(post) + ? [{ key: "distributions" as TabKey, label: t("distributions") }] + : []), { key: "key-factors", label: t("keyFactors"), count: keyFactorsCount }, { key: "info", label: t("info") }, ...(hasScores diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx index dc41368ca6..a1a3276600 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs.tsx @@ -6,6 +6,7 @@ import { PostWithForecasts } from "@/types/post"; import QuestionPageShellTabBar from "./tab_bar"; import CommentsTab from "./tabs/comments"; +import DistributionsTab from "./tabs/distributions"; import KeyFactorsTab from "./tabs/key_factors"; import MyScoresTab from "./tabs/my_scores"; import PrivateNotesTab from "./tabs/private_notes"; @@ -34,6 +35,8 @@ const renderActivePanel = ( return ; case "timeline": return variant === "consumer" ? : null; + case "distributions": + return variant === "consumer" ? : null; case "key-factors": return ; case "info": diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/distributions.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/distributions.tsx new file mode 100644 index 0000000000..b7baba0bf7 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/distributions.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { faCircleQuestion } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useLocale, useTranslations } from "next-intl"; +import { FC, useMemo, useState } from "react"; + +import ContinuousAreaChart from "@/components/charts/continuous_area_chart"; +import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; +import Tooltip from "@/components/ui/tooltip"; +import useAppTheme from "@/hooks/use_app_theme"; +import { ContinuousAreaGraphType } from "@/types/charts"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices"; +import { isGroupOfQuestionsPost } from "@/utils/questions/helpers"; + +import { + getDistributionColor, + getSubquestionDistributionData, + hasSubquestionDistribution, +} from "../../question_view/consumer_question_view/group_distribution_utils"; + +type Props = { + post: PostWithForecasts; +}; + +const subTabClassName = (isActive: boolean) => + cn( + "border-[1.25px] border-solid rounded-full font-medium transition-colors", + "text-sm leading-4 px-2 py-1 sm:text-base sm:leading-6 sm:px-[15px] sm:py-[5px]", + isActive + ? "bg-blue-500/60 border-transparent text-blue-900 dark:bg-blue-500-dark/60 dark:text-blue-900-dark" + : "bg-gray-0 border-blue-500/20 text-blue-900 dark:bg-gray-0-dark dark:border-blue-500-dark/20 dark:text-blue-900-dark" + ); + +const DistributionsTab: FC = ({ post }) => { + const t = useTranslations(); + const locale = useLocale(); + const { getThemeColor } = useAppTheme(); + const [graphType, setGraphType] = useState("pmf"); + + const items = useMemo(() => { + if (!isGroupOfQuestionsPost(post)) { + return []; + } + const questionsById = new Map( + (post.group_of_questions?.questions ?? []).map((q) => [q.id, q]) + ); + return generateChoiceItemsFromGroupQuestions(post.group_of_questions, { + locale, + }).flatMap((choice) => { + if (choice.id == null) { + return []; + } + const question = questionsById.get(choice.id); + if (!question || !hasSubquestionDistribution(question)) { + return []; + } + return [{ id: choice.id, choice, question }]; + }); + }, [post, locale]); + + if (!isGroupOfQuestionsPost(post) || items.length === 0) { + return null; + } + + const groupType = + post.group_of_questions?.questions?.[0]?.type ?? QuestionType.Numeric; + const pdfLabel = groupType === QuestionType.Discrete ? t("pmf") : t("pdf"); + + return ( +
+ setGraphType(value as ContinuousAreaGraphType)} + className="bg-transparent dark:bg-transparent" + > + + + {pdfLabel} + + + {t("cdf")} + +
+ + + +
+
+
+
+ {items.map(({ id, choice, question }) => ( +
+
+ + + {choice.choice} + +
+ +
+ ))} +
+
+ ); +}; + +export default DistributionsTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx index 4f64ea1e1f..7b0d4585ed 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/tabs/timeline.tsx @@ -8,12 +8,16 @@ type Props = { post: PostWithForecasts; }; +// The chart's timeframe (zoom) buttons are hidden below md; force them visible +// so the mobile Timeline tab exposes the timeframe selector. const TimelineTab: FC = ({ post }) => ( - +
+ +
); export default TimelineTab; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_date_group_chart.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_date_group_chart.tsx new file mode 100644 index 0000000000..8fb5aa1beb --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_date_group_chart.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { FC } from "react"; + +import DateForecastCard from "@/components/consumer_post_card/group_forecast_card/date_forecast_card"; +import { useHideCP } from "@/contexts/cp_context"; +import { GroupOfQuestionsPost } from "@/types/post"; +import { QuestionWithNumericForecasts } from "@/types/question"; + +import { useListChartExpanded } from "./consumer_list_chart_shell"; +import GroupChartViewTabs from "./group_chart_view_tabs"; +import { hasSubquestionDistribution } from "./group_distribution_utils"; +import GroupDistributionsView from "./group_distributions_view"; + +type Props = { + post: GroupOfQuestionsPost; +}; + +const CHART_HEADER_HEIGHT = 44; + +const ConsumerDateGroupChart: FC = ({ post }) => { + const { hideCP } = useHideCP(); + const { viewMode, setViewMode, chartAreaHeight } = useListChartExpanded(); + const effectiveChartHeight = + chartAreaHeight > 0 + ? Math.max(80, chartAreaHeight - CHART_HEADER_HEIGHT) + : undefined; + + const canShowDistributions = (post.group_of_questions?.questions ?? []).some( + hasSubquestionDistribution + ); + + if (!hideCP && canShowDistributions && viewMode === "distributions") { + return ; + } + + return ( +
+ {!hideCP && canShowDistributions && ( +
+ +
+ )} + +
+ ); +}; + +export default ConsumerDateGroupChart; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx index a09fa7b03f..8e0ffac422 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx @@ -3,17 +3,24 @@ import { FC } from "react"; import GroupTimeline from "@/app/(main)/questions/[id]/components/group_timeline"; +import { useHideCP } from "@/contexts/cp_context"; import { GroupOfQuestionsPost, PostStatus } from "@/types/post"; import { QuestionWithNumericForecasts } from "@/types/question"; import { getPostDrivenTime } from "@/utils/questions/helpers"; import { useListChartExpanded } from "./consumer_list_chart_shell"; +import GroupChartViewTabs from "./group_chart_view_tabs"; +import { hasSubquestionDistribution } from "./group_distribution_utils"; +import GroupDistributionsView from "./group_distributions_view"; type Props = { post: GroupOfQuestionsPost; preselectedQuestionId?: number; chartHeight?: number; visibleQuestions?: QuestionWithNumericForecasts[]; + // Suppress the internal Timeline/Distributions switcher when a parent (the + // fan-graph panel) owns the tab bar. + hideViewTabs?: boolean; reservedHeight?: number; }; @@ -22,13 +29,17 @@ const ConsumerGroupChart: FC = ({ preselectedQuestionId, chartHeight, visibleQuestions, + hideViewTabs = false, reservedHeight = 0, }) => { + const { hideCP } = useHideCP(); const { hoveredChoiceName, setHoveredChoiceName, chartAreaHeight, setCursorTimestamp, + viewMode, + setViewMode, } = useListChartExpanded(); const { open_time, actual_close_time, scheduled_close_time, status } = post; const refCloseTime = actual_close_time ?? scheduled_close_time; @@ -37,6 +48,12 @@ const ConsumerGroupChart: FC = ({ ? { questions: visibleQuestions } : { group: post.group_of_questions }; + const canShowDistributions = ( + visibleQuestions ?? + post.group_of_questions?.questions ?? + [] + ).some(hasSubquestionDistribution); + // GroupChart renders a ~44px header (title + zoom picker) above the SVG div. // Subtract it so header + SVG together fit within chartAreaHeight. const CHART_HEADER_HEIGHT = 44; @@ -45,6 +62,19 @@ const ConsumerGroupChart: FC = ({ ? Math.max(80, chartAreaHeight - reservedHeight - CHART_HEADER_HEIGHT) : chartHeight; + // When CP is hidden, or nothing has a distribution, we keep the plain timeline + // and hide the view switcher. + if (!hideCP && canShowDistributions && viewMode === "distributions") { + return ( + + ); + } + return (
setHoveredChoiceName(null)}> = ({ externalHighlightedChoice={hoveredChoiceName} chartHeight={effectiveChartHeight} onCursorChange={setCursorTimestamp} - hideTooltip + hideChartTitle={hideViewTabs || (!hideCP && canShowDistributions)} + headerLeft={ + hideCP || hideViewTabs || !canShowDistributions ? undefined : ( + + ) + } />
); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx index cd629bac8a..8bda16d03d 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -12,8 +12,11 @@ import { } from "react"; import useContainerSize from "@/hooks/use_container_size"; +import { ContinuousAreaGraphType } from "@/types/charts"; import cn from "@/utils/core/cn"; +export type GroupChartViewMode = "timeline" | "distributions"; + type ListChartExpandedContextType = { setIsExpanded: (value: boolean) => void; hoveredChoiceName: string | null; @@ -21,6 +24,12 @@ type ListChartExpandedContextType = { chartAreaHeight: number; cursorTimestamp: number | null; setCursorTimestamp: (ts: number | null) => void; + viewMode: GroupChartViewMode; + setViewMode: (mode: GroupChartViewMode) => void; + selectedQuestionId: number | null; + setSelectedQuestionId: (id: number | null) => void; + graphType: ContinuousAreaGraphType; + setGraphType: (graphType: ContinuousAreaGraphType) => void; }; const ListChartExpandedContext = createContext({ @@ -30,6 +39,12 @@ const ListChartExpandedContext = createContext({ chartAreaHeight: 0, cursorTimestamp: null, setCursorTimestamp: () => {}, + viewMode: "timeline", + setViewMode: () => {}, + selectedQuestionId: null, + setSelectedQuestionId: () => {}, + graphType: "pmf", + setGraphType: () => {}, }); export const useListChartExpanded = () => useContext(ListChartExpandedContext); @@ -61,6 +76,11 @@ const ConsumerListChartShell: React.FC = ({ null ); const [cursorTimestamp, setCursorTimestamp] = useState(null); + const [viewMode, setViewMode] = useState("timeline"); + const [selectedQuestionId, setSelectedQuestionId] = useState( + null + ); + const [graphType, setGraphType] = useState("pmf"); const { ref: listColumnRef, height: listColumnHeight } = useContainerSize(); @@ -94,6 +114,12 @@ const ConsumerListChartShell: React.FC = ({ chartAreaHeight, cursorTimestamp, setCursorTimestamp, + viewMode, + setViewMode, + selectedQuestionId, + setSelectedQuestionId, + graphType, + setGraphType, }), [ hoveredChoiceName, @@ -102,6 +128,9 @@ const ConsumerListChartShell: React.FC = ({ setIsExpanded, cursorTimestamp, setCursorTimestamp, + viewMode, + selectedQuestionId, + graphType, ] ); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_time_series_pane.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_time_series_pane.tsx index 3763590636..ec25839c9a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_time_series_pane.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_time_series_pane.tsx @@ -1,12 +1,13 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC } from "react"; +import { FC, useCallback, useMemo } from "react"; import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import { QuestionWithNumericForecasts } from "@/types/question"; import { useListChartExpanded } from "./consumer_list_chart_shell"; +import { hasSubquestionDistribution } from "./group_distribution_utils"; type Props = { questions: QuestionWithNumericForecasts[]; @@ -15,7 +16,36 @@ type Props = { const ConsumerTimeSeriesPane: FC = ({ questions, height = 180 }) => { const t = useTranslations(); - const { hoveredChoiceName, setHoveredChoiceName } = useListChartExpanded(); + const { + hoveredChoiceName, + setHoveredChoiceName, + viewMode, + setViewMode, + selectedQuestionId, + setSelectedQuestionId, + } = useListChartExpanded(); + const isDistributions = viewMode === "distributions"; + const canSelectDistribution = useMemo( + () => questions.some(hasSubquestionDistribution), + [questions] + ); + + const selectedBarLabel = useMemo(() => { + if (!isDistributions || selectedQuestionId == null) return null; + return questions.find((q) => q.id === selectedQuestionId)?.label ?? null; + }, [isDistributions, selectedQuestionId, questions]); + + // Clicking a bin activates its distribution and switches into that mode. + const handleBarClick = useCallback( + (label: string) => { + const question = questions.find((q) => q.label === label); + if (question?.id != null && hasSubquestionDistribution(question)) { + setSelectedQuestionId(question.id); + setViewMode("distributions"); + } + }, + [questions, setSelectedQuestionId, setViewMode] + ); return (
@@ -28,6 +58,8 @@ const ConsumerTimeSeriesPane: FC = ({ questions, height = 180 }) => { height={height} hoveredBarLabel={hoveredChoiceName} onBarHover={setHoveredChoiceName} + selectedBarLabel={selectedBarLabel} + onBarClick={canSelectDistribution ? handleBarClick : undefined} />
); diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/fan_graph_chart_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/fan_graph_chart_panel.tsx index 9857e20701..a40bc5d37f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/fan_graph_chart_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/fan_graph_chart_panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import FanChart from "@/components/charts/fan_chart"; import Button from "@/components/ui/button"; @@ -12,8 +12,9 @@ import cn from "@/utils/core/cn"; import ConsumerGroupChart from "./consumer_group_chart"; import { useListChartExpanded } from "./consumer_list_chart_shell"; +import { hasGroupDistributions } from "./group_distribution_utils"; -type ChartView = "fan" | "timeline"; +type ChartView = "fan" | "timeline" | "distributions"; const TOGGLE_ROW_HEIGHT = 32; @@ -32,16 +33,50 @@ const FanGraphChartPanel: FC = ({ }) => { const t = useTranslations(); const { hideCP } = useHideCP(); - const { chartAreaHeight } = useListChartExpanded(); + const { chartAreaHeight, viewMode, setViewMode } = useListChartExpanded(); const isConsumer = variant === "consumer"; - const [activeView, setActiveView] = useState( - isConsumer ? "timeline" : "fan" - ); + const canShowDistributions = hasGroupDistributions(post); + + // "fan" is orthogonal to the shared timeline/distributions view mode. The + // distributions/timeline choice lives in context so the left pane (bins) can + // drive it, while fan is a local overlay on top of that. + const [showFan, setShowFan] = useState(!isConsumer); + + // Selecting a distribution (e.g. clicking a bin) forces us out of the fan view. + useEffect(() => { + if (isConsumer && viewMode === "distributions") { + setShowFan(false); + } + }, [isConsumer, viewMode]); + + const activeView: ChartView = showFan + ? "fan" + : viewMode === "distributions" + ? "distributions" + : "timeline"; + + const selectView = (view: ChartView) => { + if (view === "fan") { + setShowFan(true); + setViewMode("timeline"); + return; + } + setShowFan(false); + setViewMode(view === "distributions" ? "distributions" : "timeline"); + }; const views: { value: ChartView; label: string }[] = isConsumer ? [ { value: "timeline", label: t("timeline") }, { value: "fan", label: t("fanChart") }, + ...(hideCP || !canShowDistributions + ? [] + : [ + { + value: "distributions" as ChartView, + label: t("distributions"), + }, + ]), ] : [ { value: "fan", label: t("fanChart") }, @@ -55,7 +90,7 @@ const FanGraphChartPanel: FC = ({ {views.map(({ value, label }) => ( + ))} + + ); +}; + +export default GroupChartViewTabs; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distribution_utils.ts b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distribution_utils.ts new file mode 100644 index 0000000000..34e3a1a5c4 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distribution_utils.ts @@ -0,0 +1,92 @@ +import { ContinuousAreaGraphInput } from "@/components/charts/continuous_area_chart"; +import { METAC_COLORS } from "@/constants/colors"; +import { ContinuousAreaType } from "@/types/charts"; +import { PostWithForecasts, QuestionStatus } from "@/types/post"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; +import { ThemeColor } from "@/types/theme"; +import { isForecastActive } from "@/utils/forecasts/helpers"; +import { cdfToPmf } from "@/utils/math"; +import { + checkGroupOfQuestionsPostType, + isGroupOfQuestionsPost, +} from "@/utils/questions/helpers"; +import { isUnsuccessfullyResolved } from "@/utils/questions/resolution"; + +// Community-only, display-only distribution for a single group subquestion. +// Deliberately excludes the user's own forecast (unlike getContinuousAreaChartData). +export function getSubquestionDistributionData( + question: QuestionWithNumericForecasts +): ContinuousAreaGraphInput { + // Binary subquestions (e.g. binary fan-graph groups) have no continuous + // distribution — their aggregation has no forecast_values. + if ( + question.type !== QuestionType.Numeric && + question.type !== QuestionType.Discrete && + question.type !== QuestionType.Date + ) { + return []; + } + if (isUnsuccessfullyResolved(question.resolution)) { + return []; + } + + const latest = + question.aggregations[question.default_aggregation_method]?.latest; + if (!latest || !isForecastActive(latest)) { + return []; + } + + const isResolved = question.status === QuestionStatus.RESOLVED; + const isClosed = question.status === QuestionStatus.CLOSED; + const type: ContinuousAreaType = isResolved + ? "community_resolved" + : isClosed + ? "community_closed" + : "community"; + + return [ + { + pmf: cdfToPmf(latest.forecast_values), + cdf: latest.forecast_values, + type, + }, + ]; +} + +export function hasSubquestionDistribution( + question: QuestionWithNumericForecasts +): boolean { + return getSubquestionDistributionData(question).length > 0; +} + +// Closed subquestions are shown in gray in the list, so their distribution +// should be gray too; open/resolved keep their per-row color. +export function getDistributionColor( + question: QuestionWithNumericForecasts, + choiceColor: ThemeColor +): ThemeColor { + if (question.status === QuestionStatus.CLOSED) { + return METAC_COLORS.gray["500"]; + } + return choiceColor; +} + +// A group can show Distributions only when its subquestions are continuous +// (Numeric/Discrete/Date — excludes binary time-series groups) AND at least one +// subquestion actually has distribution data. Fan graphs are allowed here (a +// numeric fan graph qualifies; a binary one does not). +export function hasGroupDistributions(post: PostWithForecasts): boolean { + if (!isGroupOfQuestionsPost(post)) { + return false; + } + const isContinuousType = + checkGroupOfQuestionsPostType(post, QuestionType.Numeric) || + checkGroupOfQuestionsPostType(post, QuestionType.Discrete) || + checkGroupOfQuestionsPostType(post, QuestionType.Date); + if (!isContinuousType) { + return false; + } + return (post.group_of_questions?.questions ?? []).some( + hasSubquestionDistribution + ); +} diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distributions_view.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distributions_view.tsx new file mode 100644 index 0000000000..a227872e7e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distributions_view.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useLocale } from "next-intl"; +import { FC, useEffect, useMemo } from "react"; + +import ContinuousAreaChart from "@/components/charts/continuous_area_chart"; +import useAppTheme from "@/hooks/use_app_theme"; +import { ChoiceItem } from "@/types/choices"; +import { GroupOfQuestionsPost, QuestionStatus } from "@/types/post"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; +import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices"; + +import { useListChartExpanded } from "./consumer_list_chart_shell"; +import GroupChartViewTabs from "./group_chart_view_tabs"; +import { + getDistributionColor, + getSubquestionDistributionData, + hasSubquestionDistribution, +} from "./group_distribution_utils"; +import PdfCdfTabs from "./pdf_cdf_tabs"; + +type Props = { + post: GroupOfQuestionsPost; + visibleQuestions?: QuestionWithNumericForecasts[]; + height?: number; + // When the parent owns the view switcher (e.g. the fan-graph panel), hide the + // internal Timeline/Distributions tabs and keep only the PDF/CDF toggle. + hideViewTabs?: boolean; +}; + +// Distributions-mode chart body for a continuous group: header (view tabs + +// PDF/CDF toggle) and the selected subquestion's community distribution, colored +// to match its row. Display-only — never shows the user's own prediction. +const GroupDistributionsView: FC = ({ + post, + visibleQuestions, + height, + hideViewTabs = false, +}) => { + const locale = useLocale(); + const { getThemeColor } = useAppTheme(); + const { + viewMode, + setViewMode, + selectedQuestionId, + setSelectedQuestionId, + graphType, + setGraphType, + setHoveredChoiceName, + } = useListChartExpanded(); + + const allQuestions = useMemo( + () => visibleQuestions ?? post.group_of_questions?.questions ?? [], + [visibleQuestions, post.group_of_questions?.questions] + ); + + // Same generation the neighbouring selector uses (the list rows, or the + // fan-graph bins when visibleQuestions is a slice), so colors line up. + const choices = useMemo( + () => + generateChoiceItemsFromGroupQuestions( + visibleQuestions ?? post.group_of_questions, + { locale } + ).filter((c): c is ChoiceItem & { id: number } => c.id != null), + [visibleQuestions, post.group_of_questions, locale] + ); + const colorById = useMemo( + () => new Map(choices.map((c) => [c.id, c.color])), + [choices] + ); + const questionsById = useMemo( + () => new Map(allQuestions.map((q) => [q.id, q])), + [allQuestions] + ); + const groupType = allQuestions.at(0)?.type ?? QuestionType.Numeric; + + const selectableIds = useMemo(() => { + const ids = new Set(); + for (const choice of choices) { + const q = questionsById.get(choice.id); + if (q && hasSubquestionDistribution(q)) ids.add(choice.id); + } + return ids; + }, [choices, questionsById]); + + const defaultSelectedId = useMemo(() => { + const firstOpen = choices.find((c) => { + const q = questionsById.get(c.id); + return q && q.status === QuestionStatus.OPEN && selectableIds.has(c.id); + }); + if (firstOpen) return firstOpen.id; + return choices.find((c) => selectableIds.has(c.id))?.id ?? null; + }, [choices, questionsById, selectableIds]); + + const effectiveSelectedId = + selectedQuestionId != null && selectableIds.has(selectedQuestionId) + ? selectedQuestionId + : defaultSelectedId; + + // Keep exactly one subquestion selected while in distributions mode. + useEffect(() => { + if ( + (selectedQuestionId == null || !selectableIds.has(selectedQuestionId)) && + defaultSelectedId != null + ) { + setSelectedQuestionId(defaultSelectedId); + } + }, [ + selectedQuestionId, + selectableIds, + defaultSelectedId, + setSelectedQuestionId, + ]); + + // Nothing eligible to show — avoid rendering an empty header + blank chart. + if (effectiveSelectedId == null) { + return null; + } + + const selectedQuestion = questionsById.get(effectiveSelectedId); + const selectedColor = colorById.get(effectiveSelectedId); + + return ( +
setHoveredChoiceName(null)} + > +
+ {!hideViewTabs && ( + + )} +
+ +
+
+ {selectedQuestion && selectedColor && ( + {}} + cursorQuartileTooltip + alignChartTabs + /> + )} +
+ ); +}; + +export default GroupDistributionsView; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/pdf_cdf_tabs.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/pdf_cdf_tabs.tsx new file mode 100644 index 0000000000..430d5f5777 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/pdf_cdf_tabs.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { faCircleQuestion } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import Tooltip from "@/components/ui/tooltip"; +import { ContinuousAreaGraphType } from "@/types/charts"; +import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; + +type Props = { + value: ContinuousAreaGraphType; + onChange: (graphType: ContinuousAreaGraphType) => void; + questionType: QuestionType; + className?: string; +}; + +// Mirrors the timeframe/zoom button styling in chart_container.tsx. +const buttonClassName = (isActive: boolean) => + cn( + "rounded px-1 py-0.5 text-xs font-normal uppercase leading-4 text-gray-600 hover:text-blue-800 focus:outline-none dark:text-gray-600-dark hover:dark:text-blue-800-dark md:text-sm", + isActive && + "bg-gray-300 text-gray-900 dark:bg-gray-300-dark dark:text-gray-900-dark" + ); + +const PdfCdfTabs: FC = ({ + value, + onChange, + questionType, + className, +}) => { + const t = useTranslations(); + + const buttons: { value: ContinuousAreaGraphType; label: string }[] = [ + { + value: "pmf", + label: questionType === QuestionType.Discrete ? t("pmf") : t("pdf"), + }, + { value: "cdf", label: t("cdf") }, + ]; + + return ( +
+
+ {buttons.map(({ value: buttonValue, label }) => ( + + ))} +
+ + + +
+ ); +}; + +export default PdfCdfTabs; diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index 6354ef5d1f..8bf7d40d42 100644 --- a/front_end/src/components/charts/continuous_area_chart.tsx +++ b/front_end/src/components/charts/continuous_area_chart.tsx @@ -1,6 +1,6 @@ "use client"; import { isNil, merge } from "lodash"; -import { useLocale } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import React, { FC, useCallback, @@ -55,6 +55,7 @@ import { import { FEED_CHART_TARGET_POINTS, lttb } from "@/utils/charts/lttb"; import { getResolutionPoint } from "@/utils/charts/resolution"; import { isForecastActive } from "@/utils/forecasts/helpers"; +import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { formatResolution } from "@/utils/formatters/resolution"; import { cdfToPmf, @@ -108,6 +109,10 @@ type Props = { withResolutionChip?: boolean; withTodayLine?: boolean; globalScaling?: Scaling; + colorOverride?: string; + // Show a value chip at the top of the chart (instead of the cursor circle) + // when the cursor is near a quartile line. Used by the group distributions view. + cursorQuartileTooltip?: boolean; outlineUser?: boolean; centerOOBResolution?: boolean; animate?: object; @@ -132,12 +137,15 @@ const ContinuousAreaChart: FC = ({ withResolutionChip = true, withTodayLine = true, globalScaling, + colorOverride, + cursorQuartileTooltip = false, outlineUser = false, centerOOBResolution = false, animate, onChartReady, }) => { const locale = useLocale(); + const t = useTranslations(); const { ref: chartContainerRef, width: containerWidth } = useContainerSize(); const chartWidth = width || containerWidth; @@ -149,6 +157,7 @@ const ContinuousAreaChart: FC = ({ prevWidth.current = chartWidth; }, [onChartReady, chartWidth]); const [cursorEdge, setCursorEdge] = useState(null); + const [cursorX, setCursorX] = useState(null); const { theme, getThemeColor } = useAppTheme(); const chartTheme = theme === "dark" ? darkTheme : lightTheme; const actualTheme = extraTheme @@ -396,6 +405,7 @@ const ContinuousAreaChart: FC = ({ const handleMouseLeave = useCallback(() => { onCursorChange?.(null); setCursorEdge(null); + setCursorX(null); }, [onCursorChange]); const handleMouseMove = useCallback( @@ -503,6 +513,59 @@ const ContinuousAreaChart: FC = ({ (1.09 * ((question.inbound_outcome_count || 200) + openBoundCount)) ); }, [chartWidth, data, question, horizontalPadding]); + + // When enabled, snap a value chip to the nearest quartile line while the + // cursor is close to it (used in the group distributions view). Replaces the + // cursor circle at that moment. + const quartileTooltip = useMemo(() => { + if (!cursorQuartileTooltip || cursorX == null || discrete) return null; + const displayChart = charts.find((c) => c.type !== "user_components"); + if (!displayChart) return null; + const innerWidth = chartWidth - 2 * horizontalPadding; + if (innerWidth <= 0) return null; + const threshold = 10 / innerWidth; + let best: { x: number; y: number | null } | null = null; + let bestIndex = -1; + let bestDist = Infinity; + // verticalLines order is [lower25, median, upper75]. + for (const [index, line] of displayChart.verticalLines.entries()) { + const d = Math.abs(cursorX - line.x); + if (d < bestDist) { + bestDist = d; + best = line; + bestIndex = index; + } + } + if (!best || bestDist > threshold) return null; + const topLabel = + bestIndex === 0 + ? t("quartileLabelP25") + : bestIndex === 2 + ? t("quartileLabelP75") + : t("quartileLabelMedian"); + return { + // Anchor the chip where the curve meets the quartile line (i.e. where the + // cursor circle would be). + x: best.x, + y: best.y ?? 0, + topLabel, + label: getPredictionDisplayValue(best.x, { + questionType: question.type, + scaling: question.scaling, + actual_resolve_time: null, + }), + }; + }, [ + cursorQuartileTooltip, + cursorX, + discrete, + charts, + chartWidth, + horizontalPadding, + question.type, + question.scaling, + t, + ]); const CursorContainer = ( = ({ .map((chart) => ({ line: chart.graphLine, color: (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } switch (chart.color) { case "orange": return getThemeColor( @@ -544,6 +610,7 @@ const ContinuousAreaChart: FC = ({ paddingLeft={horizontalPadding} paddingRight={horizontalPadding} discrete={discrete} + suppress={!!quartileTooltip} /> } onCursorChange={(value: CursorCoordinatesPropType | null) => { @@ -551,8 +618,10 @@ const ContinuousAreaChart: FC = ({ if (isNil(x)) { onCursorChange?.(null); + if (cursorQuartileTooltip) setCursorX(null); return; } + if (cursorQuartileTooltip) setCursorX(x); const hoverState = charts.reduce( (acc, el) => { @@ -645,6 +714,9 @@ const ContinuousAreaChart: FC = ({ style={{ data: { fill: (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } if (extraTheme?.area?.style?.data?.fill) { return extraTheme.area.style.data.fill; } @@ -683,6 +755,9 @@ const ContinuousAreaChart: FC = ({ style={{ data: { fill: (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } if (extraTheme?.area?.style?.data?.fill) { return extraTheme.area.style.data.fill; } @@ -718,6 +793,9 @@ const ContinuousAreaChart: FC = ({ style={{ data: { stroke: (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } if (extraTheme?.line?.style?.data?.stroke) { return extraTheme?.line?.style?.data?.stroke; } @@ -819,6 +897,11 @@ const ContinuousAreaChart: FC = ({ style={{ data: { stroke: (() => { + // The x-axis baseline stays grayscale in the distributions + // view (colorOverride) regardless of subquestion color/state. + if (colorOverride) { + return getThemeColor(METAC_COLORS.gray["400"]); + } switch (chart.color) { case "orange": return getThemeColor(METAC_COLORS.orange["800"]); @@ -834,64 +917,79 @@ const ContinuousAreaChart: FC = ({ }} /> ))} - {/* Left/Right borders at bounds if requested */} - {(question.scaling.range_min ?? 1) <= - (globalScaling?.range_min ?? 0) && ( - - )} - {(question.scaling.range_max ?? 0) >= - (globalScaling?.range_max ?? 1) && ( - - )} - {charts.map((chart, k) => - chart.verticalLines.map((line, index) => ( + {/* Left/Right borders at bounds — only meaningful on a shared (global) + scale. Gated on globalScaling so standalone charts (prediction + inputs, single distributions) don't render an asymmetric edge line. */} + {globalScaling && + (question.scaling.range_min ?? 1) <= + (globalScaling.range_min ?? 0) && ( + + )} + {globalScaling && + (question.scaling.range_max ?? 0) >= + (globalScaling.range_max ?? 1) && ( { - switch (chart.color) { - case "orange": - return getThemeColor(METAC_COLORS.orange["700"]); - case "gray": - return getThemeColor(METAC_COLORS.gray["500"]); - case "purple": - return getThemeColor(METAC_COLORS.purple["700"]); - default: - return undefined; - } - })(), - strokeDasharray: CHART_DASH.quartile, + stroke: getThemeColor(METAC_COLORS.gray["500"]), + strokeWidth: 0.5, }, }} /> - )) + )} + {charts.map((chart, k) => + chart.verticalLines.map((line, index) => { + // The quartile line under the cursor becomes solid and thicker. + const isActiveQuartile = + !!quartileTooltip && quartileTooltip.x === line.x; + return ( + { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } + switch (chart.color) { + case "orange": + return getThemeColor(METAC_COLORS.orange["700"]); + case "gray": + return getThemeColor(METAC_COLORS.gray["500"]); + case "purple": + return getThemeColor(METAC_COLORS.purple["700"]); + default: + return undefined; + } + })(), + strokeDasharray: isActiveQuartile + ? undefined + : CHART_DASH.quartile, + strokeWidth: isActiveQuartile ? 2 : undefined, + }, + }} + /> + ); + }) )} {/* Today's date dot for date questions */} @@ -1056,16 +1154,32 @@ const ContinuousAreaChart: FC = ({ x: cursorEdge, y: 0, }} - chartData={charts.map((chart) => ({ - line: chart.graphLine, - color: getThemeColor( - chart.color === "orange" - ? METAC_COLORS.orange[chart.type === "user" ? "800" : "500"] - : METAC_COLORS.olive["700"] - ), - type: chart.type, - graphType: chart.graphType, - }))} + chartData={charts + .filter((chart) => chart.type !== "user_components") + .map((chart) => ({ + line: chart.graphLine, + color: (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } + switch (chart.color) { + case "orange": + return getThemeColor( + METAC_COLORS.orange[ + chart.type === "user" ? "800" : "500" + ] + ); + case "gray": + return getThemeColor(METAC_COLORS.gray["500"]); + case "purple": + return getThemeColor(METAC_COLORS.purple["700"]); + default: + return getThemeColor(METAC_COLORS.olive["700"]); + } + })(), + type: chart.type, + graphType: chart.graphType, + }))} chartHeight={height} yDomain={yDomain} xDomain={xDomain} @@ -1077,6 +1191,25 @@ const ContinuousAreaChart: FC = ({ barWidth={barWidth} /> )} + + {/* Value chip snapped to the nearest quartile line on hover */} + {quartileTooltip && ( + + quartileTooltip.label} + topLabel={quartileTooltip.topLabel} + /> + + } + /> + )} )} @@ -1135,6 +1268,16 @@ function generateNumericAreaGraph(data: { } else { if (graphType === "cdf") { cdf.forEach((value, index) => { + if (index === 0) { + // extend to the left edge so the area fills the full width + graph.push({ x: -1e-10, y: value }); + return; + } + if (index === cdf.length - 1) { + // extend to the right edge so the area fills the full width + graph.push({ x: 1 + 1e-10, y: value }); + return; + } graph.push({ x: (index - 0.5) / (cdf.length - 1), y: value }); }); } else { diff --git a/front_end/src/components/charts/primitives/chart_container.tsx b/front_end/src/components/charts/primitives/chart_container.tsx index a2262132d7..0eb276f8f0 100644 --- a/front_end/src/components/charts/primitives/chart_container.tsx +++ b/front_end/src/components/charts/primitives/chart_container.tsx @@ -95,13 +95,13 @@ const ChartContainer = forwardRef>( {(!!zoom || !!headerExtra) && (
{!!zoom && ( -
+
= (props) => { const { getThemeColor } = useAppTheme(); const { @@ -197,6 +199,7 @@ const ChartValueBox: FC<{ isDistributionChip, questionType, textAlignToSide, + topLabel, } = props; const CHIP_OFFSET = !isNil(resolution) ? 8 : 0; @@ -231,8 +234,30 @@ const ChartValueBox: FC<{ resolveToCssColor(getThemeColor, colorOverride) ?? getThemeColor(METAC_COLORS.olive["600"]); + // Optional one-word label above the chip (e.g. MEDIAN / 25TH %). + const TOP_LABEL_FONT_SIZE = 10; + const TOP_LABEL_GAP = 5; + const chipTopY = getRectY(placement, y, isDistributionChip, textAlignToSide); + const topLabelCenterY = chipTopY - TOP_LABEL_GAP - TOP_LABEL_FONT_SIZE / 2; + return ( + {!!topLabel && ( + + {topLabel} + + )} {hasResolution && questionType !== QuestionType.Binary && ( = { xDomain: Tuple; barWidth: number; discrete?: boolean; + suppress?: boolean; }; const LineCursorPoints = ({ @@ -45,8 +46,9 @@ const LineCursorPoints = ({ x, datum, discrete, + suppress, }: ComponentProps & Props) => { - if (isNil(datum?.x) || isNil(datum?.y) || isNil(x)) { + if (suppress || isNil(datum?.x) || isNil(datum?.y) || isNil(x)) { return null; } return ( diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx index 52542b8ed0..50c7920752 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx @@ -29,6 +29,8 @@ type Props = { compact?: boolean; onMouseEnter?: () => void; onMouseLeave?: () => void; + onClick?: () => void; + isActive?: boolean; className?: string; }; @@ -49,6 +51,8 @@ const ForecastChoiceBar: FC = ({ compact = false, onMouseEnter, onMouseLeave, + onClick, + isActive = false, className, }) => { const t = useTranslations(); @@ -57,14 +61,46 @@ const ForecastChoiceBar: FC = ({ const isCpRevealed = !isNaN(progress); const isResolutionSuccessful = isSuccessfullyResolved(resolution); + + // Outer glow for the active row uses the same color the fill resolves to + // (gray when closed), at 25% opacity with no blur. + const glowThemeColor = + isClosed && !forceColorful ? METAC_COLORS.gray["500"] : color; + const activeGlowStyle = isActive + ? { + boxShadow: `0 0 0 4px ${addOpacityToHex( + mounted ? getThemeColor(glowThemeColor) : glowThemeColor.DEFAULT, + 0.25 + )}`, + } + : undefined; + return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + } + : undefined + } + style={activeGlowStyle} className={cn( "relative flex w-full items-center justify-between gap-2 rounded-lg bg-transparent font-medium text-gray-800 dark:text-gray-800-dark", + onMouseEnter && "group transition-colors", onMouseEnter && - "group transition-colors hover:bg-blue-500/20 dark:hover:bg-blue-500-dark/20", + !isActive && + "hover:bg-blue-500/20 dark:hover:bg-blue-500-dark/20", + isActive && "bg-blue-500/30 dark:bg-blue-500-dark/30", + onClick && "cursor-pointer", className, isBordered ? "border border-blue-400 dark:border-blue-400-dark" @@ -122,9 +158,11 @@ const ForecastChoiceBar: FC = ({ className={cn( "absolute -inset-[1px] z-0 rounded-lg border", onMouseEnter && - "opacity-75 transition-opacity group-hover:opacity-100", + (isActive + ? "opacity-100 transition-opacity" + : "opacity-75 transition-opacity group-hover:opacity-100"), { - "border-2": resolution, + "border-2": resolution || isActive, } )} style={{ diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx index a12ff63465..b3fdb2e464 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx @@ -2,9 +2,10 @@ import { isNil } from "lodash"; import { useLocale, useTranslations } from "next-intl"; -import { FC, useState } from "react"; +import { FC, useEffect, useRef, useState } from "react"; import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell"; +import { hasSubquestionDistribution } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/group_distribution_utils"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { useHideCP } from "@/contexts/cp_context"; import { useOverlayMaxHeight } from "@/hooks/use_overlay_max_height"; @@ -44,14 +45,48 @@ const NumericForecastCard: FC = ({ const t = useTranslations(); const { hideCP } = useHideCP(); const [expanded, setExpanded] = useState(false); - const { setIsExpanded, setHoveredChoiceName, cursorTimestamp } = - useListChartExpanded(); + const { + setIsExpanded, + setHoveredChoiceName, + cursorTimestamp, + viewMode, + setViewMode, + selectedQuestionId, + setSelectedQuestionId, + } = useListChartExpanded(); const { containerRef, overlayMaxHeight } = useOverlayMaxHeight(expanded); + // Snapshot (updated during render below) so the effect can auto-expand the + // list when the active distribution row is hidden behind the "N others" fold. + const collapsedInfoRef = useRef<{ + hiddenCount: number; + visibleIds: Set; + }>({ hiddenCount: 0, visibleIds: new Set() }); + // Re-expand on every switch into distributions or change of selection (but not + // on unrelated re-renders), so a manual collapse followed by re-entering shows + // the active row again. + const prevExpandTriggerRef = useRef(null); + useEffect(() => { + const trigger = `${viewMode}:${selectedQuestionId ?? ""}`; + if (prevExpandTriggerRef.current === trigger) return; + prevExpandTriggerRef.current = trigger; + if (viewMode !== "distributions" || selectedQuestionId == null) return; + const { hiddenCount, visibleIds } = collapsedInfoRef.current; + if (hiddenCount > 0 && !visibleIds.has(selectedQuestionId)) { + setExpanded(true); + setIsExpanded(true); + } + }, [viewMode, selectedQuestionId, setIsExpanded]); + if (!isGroupOfQuestionsPost(post)) { return null; } + const isDistributions = viewMode === "distributions"; + const questionsById = new Map( + (post.group_of_questions?.questions ?? []).map((q) => [q.id, q]) + ); + const isDateGroup = checkGroupOfQuestionsPostType(post, QuestionType.Date); const visibleChoicesCount = getEffectiveVisibleCount( post.group_of_questions?.questions?.length ?? 0 @@ -79,6 +114,12 @@ const NumericForecastCard: FC = ({ const isPostClosed = post.status === PostStatus.CLOSED; const hiddenCount = Math.max(0, sortedChoices.length - visibleChoicesCount); const collapsedChoices = sortedChoices.slice(0, visibleChoicesCount); + collapsedInfoRef.current = { + hiddenCount, + visibleIds: new Set( + collapsedChoices.map((c) => c.id).filter((id): id is number => id != null) + ), + }; // Anchor all sub-questions to the same timestamp. Use the global latest // across all choices so resolved rows (sorted first) don't anchor to a @@ -174,6 +215,12 @@ const NumericForecastCard: FC = ({ minScaledValue, }); + const question = id != null ? questionsById.get(id) : undefined; + // Rows with a distribution are always clickable; clicking one switches + // into distributions mode and activates that row. + const isSelectable = + id != null && !!question && hasSubquestionDistribution(question); + return ( = ({ onMouseLeave={ index < hoverUpTo ? () => setHoveredChoiceName(null) : undefined } + onClick={ + isSelectable && id != null + ? () => { + setSelectedQuestionId(id); + setViewMode("distributions"); + } + : undefined + } + isActive={ + isDistributions && id != null && selectedQuestionId === id + } className={stretchBars ? "flex-1" : undefined} /> ); diff --git a/front_end/src/components/consumer_post_card/time_series_chart/index.tsx b/front_end/src/components/consumer_post_card/time_series_chart/index.tsx index f525156791..3257305d39 100644 --- a/front_end/src/components/consumer_post_card/time_series_chart/index.tsx +++ b/front_end/src/components/consumer_post_card/time_series_chart/index.tsx @@ -27,6 +27,7 @@ import { import { ThemeColor } from "@/types/theme"; import { calculateCharWidth } from "@/utils/charts/helpers"; import { getResolutionPosition } from "@/utils/charts/resolution"; +import cn from "@/utils/core/cn"; import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { formatResolution } from "@/utils/formatters/resolution"; import { truncateLabel } from "@/utils/formatters/string"; @@ -44,6 +45,8 @@ type Props = { variant?: "default" | "colorful"; onBarHover?: (label: string | null) => void; hoveredBarLabel?: string | null; + onBarClick?: (label: string) => void; + selectedBarLabel?: string | null; forFeedPage?: boolean; }; @@ -55,12 +58,56 @@ const CHART_PADDING_TOP = 20; const CHART_PADDING_BOTTOM = 30; const HIGHLIGHT_WIDTH = 52; +// Single source of truth for a bar's color so the fill and the selection +// highlight stay in sync (resolved → purple, closed → gray, else MC scale). +function resolveBarFillColor( + getThemeColor: (color: ThemeColor) => string, + datum: { + isEmpty?: boolean; + resolution?: Resolution | null; + isClosed?: boolean; + originalIndex?: number; + }, + index: number | undefined, + variant: "default" | "colorful" +): string { + if (variant === "colorful" && !datum.isEmpty) { + if (datum.resolution) { + return getThemeColor(METAC_COLORS.purple["500"]); + } + const safeIndex = + typeof datum.originalIndex === "number" + ? datum.originalIndex + : typeof index === "number" + ? index + : 0; + const color: ThemeColor = + MULTIPLE_CHOICE_COLOR_SCALE[ + safeIndex % MULTIPLE_CHOICE_COLOR_SCALE.length + ] ?? (METAC_COLORS.blue["400"] as ThemeColor); + return getThemeColor(color); + } + + if (datum.resolution) { + return getThemeColor( + ["no", "yes"].includes(datum.resolution as string) + ? METAC_COLORS.purple["400"] + : METAC_COLORS.purple["500"] + ); + } + + return datum.isClosed + ? getThemeColor(METAC_COLORS.gray["500"]) + : getThemeColor(METAC_COLORS.blue["400"]); +} + interface HighlightBarProps { x?: number; index?: number; hoveredIndex: number | null; chartHeight: number; fill: string; + fillOpacity?: number; } const HighlightBar: FC = ({ @@ -69,6 +116,7 @@ const HighlightBar: FC = ({ hoveredIndex, chartHeight, fill, + fillOpacity = 0.2, }) => { if (hoveredIndex === null || index !== hoveredIndex || x === undefined) { return null; @@ -80,7 +128,7 @@ const HighlightBar: FC = ({ width={HIGHLIGHT_WIDTH} height={chartHeight - CHART_PADDING_TOP - CHART_PADDING_BOTTOM} fill={fill} - fillOpacity={0.2} + fillOpacity={fillOpacity} rx={2} /> ); @@ -106,6 +154,8 @@ const TimeSeriesChart: FC = ({ variant = "default", onBarHover, hoveredBarLabel, + onBarClick, + selectedBarLabel, forFeedPage = false, }) => { const { theme, getThemeColor } = useAppTheme(); @@ -135,6 +185,22 @@ const TimeSeriesChart: FC = ({ return idx === -1 ? null : idx; }, [hoveredBarLabel, adjustedChartData]); const effectiveHoveredIndex = hoveredIndex ?? externalHoveredIndex; + + const selectedIndex = useMemo(() => { + if (!selectedBarLabel) return null; + const idx = adjustedChartData.findIndex((d) => d.x === selectedBarLabel); + return idx === -1 ? null : idx; + }, [selectedBarLabel, adjustedChartData]); + const selectedDatum = + selectedIndex !== null ? adjustedChartData[selectedIndex] : null; + const selectedBarColor = selectedDatum + ? resolveBarFillColor( + getThemeColor, + selectedDatum, + selectedIndex ?? undefined, + variant + ) + : undefined; const domainPaddingX = useMemo( () => getDomainPaddingX(chartWidth, adjustedChartData.length), [adjustedChartData.length, chartWidth] @@ -221,6 +287,36 @@ const TimeSeriesChart: FC = ({ if (onBarHover) onBarHover(null); }; + const closestIndexFromEvent = (e: React.MouseEvent) => { + const n = adjustedChartData.length; + if (n === 0) return null; + const rect = e.currentTarget.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const availableWidth = chartWidth - domainPaddingX * 2; + let closestIndex = 0; + let minDist = Infinity; + for (let i = 0; i < n; i++) { + const barX = + n === 1 + ? chartWidth / 2 + : domainPaddingX + (i * availableWidth) / (n - 1); + const dist = Math.abs(mouseX - barX); + if (dist < minDist) { + minDist = dist; + closestIndex = i; + } + } + return closestIndex; + }; + + const handleClick = (e: React.MouseEvent) => { + if (!onBarClick) return; + const idx = closestIndexFromEvent(e); + if (idx === null) return; + const datum = adjustedChartData[idx]; + if (datum && !datum.isEmpty) onBarClick(datum.x); + }; + const chartHeight = forFeedPage ? reservedChartHeight : allQuestionsEmpty @@ -230,10 +326,14 @@ const TimeSeriesChart: FC = ({ return (
{shouldDisplayChart && ( = ({ /> } > + {!allQuestionsEmpty && selectedIndex !== null && selectedBarColor && ( + ({ x, y }))} + dataComponent={ + + } + /> + )} {!allQuestionsEmpty && effectiveHoveredIndex !== null && ( ({ x, y }))} @@ -336,37 +449,13 @@ const TimeSeriesChart: FC = ({ } style={{ data: { - fill: ({ datum, index }) => { - if (variant === "colorful" && !datum.isEmpty) { - if (datum.resolution) { - return getThemeColor(METAC_COLORS.purple["500"]); - } - const safeIndex = - typeof datum.originalIndex === "number" - ? datum.originalIndex - : typeof index === "number" - ? index - : 0; - const color: ThemeColor = - MULTIPLE_CHOICE_COLOR_SCALE[ - safeIndex % MULTIPLE_CHOICE_COLOR_SCALE.length - ] ?? (METAC_COLORS.blue["400"] as ThemeColor); - - return getThemeColor(color); - } - - if (datum.resolution) { - return getThemeColor( - ["no", "yes"].includes(datum.resolution as string) - ? METAC_COLORS.purple["400"] - : METAC_COLORS.purple["500"] - ); - } - - return datum.isClosed - ? getThemeColor(METAC_COLORS.gray["500"]) - : getThemeColor(METAC_COLORS.blue["400"]); - }, + fill: ({ datum, index }) => + resolveBarFillColor( + getThemeColor, + datum, + typeof index === "number" ? index : undefined, + variant + ), stroke: "transparent", strokeWidth: 0, width: ({ datum }) =>