diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 0a1cd6fd24..b5d76cd9dd 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2169,5 +2169,6 @@ "whyTrustMetaculusLessNoise": "Předpovědi Metaculus, poháněné davem, prořezávají šum tím, že zakládají každou předpověď na transparentních důkazech, odpovědném skóre a desetiletí doložené přesnosti. Metaculus vybavuje politiky, výzkumníky, novináře a korporátní organizace předpověďmi založenými na důkazech, které poskytují jasný, kvantifikovatelný vhled do nejkritičtějších nejistot světa. Prozkoumejte naši nabídku Business řešení a zjistěte, jak může Metaculus zlepšit rozhodování vaší organizace.", "publishTimeLockedDescription": "Čas publikace nelze po vytvoření změnit.", "nMore": "{count} více...", + "noHistogramData": "Žádná data histogramu nejsou k dispozici", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index d4b729c939..b69fb4691f 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1185,6 +1185,7 @@ "deselectAll": "deselect all", "forecastDataIsEmpty": "Forecast data is empty", "noForecastsYet": "No forecasts yet", + "noHistogramData": "No histogram data available", "RevealTemporarily": "Reveal Temporarily", "CPIsHidden": "Community Prediction is hidden", "createdByUserOnDate": "Created by {user} on {date}", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index dd79b0d44b..a549d019ba 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2169,5 +2169,6 @@ "whyTrustMetaculusLessNoise": "Los pronósticos impulsados por la multitud de Metaculus cortan el ruido al basar cada predicción en evidencia transparente, puntuaciones responsables y una década de precisión demostrada. Metaculus equipa a los responsables de políticas, investigadores, periodistas y organizaciones corporativas con pronósticos basados en evidencia que ofrecen una visión clara y cuantificable de las incertidumbres más críticas del mundo. Explora nuestra suite de Soluciones Empresariales para aprender cómo Metaculus puede mejorar la toma de decisiones de tu organización.", "publishTimeLockedDescription": "La hora de publicación no puede cambiarse después de la creación.", "nMore": "{count} más...", + "noHistogramData": "No hay datos de histograma disponibles", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index b6f289b566..dd627147cf 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2167,5 +2167,6 @@ "whyTrustMetaculusLessNoise": "As previsões baseadas na multidão do Metaculus cortam o ruído ao basear cada previsão em evidências transparentes, pontuações responsáveis e uma década de precisão demonstrada. O Metaculus equipa formuladores de políticas, pesquisadores, jornalistas e organizações corporativas com previsões baseadas em evidências que oferecem insights claros e quantificáveis sobre as incertezas mais críticas do mundo. Explore nosso conjunto de Soluções Empresariais para saber mais sobre como o Metaculus pode melhorar a tomada de decisões da sua organização.", "publishTimeLockedDescription": "O horário de publicação não pode ser alterado após a criação.", "nMore": "{count} a mais...", + "noHistogramData": "Não há dados de histograma disponíveis", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index b16ee57d94..48d1d4a1bc 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2166,5 +2166,6 @@ "whyTrustMetaculusLessNoise": "Metaculus 的群眾驅動預測通過將每一個預測植根於透明的證據、負責的計分和十年的已證準確性,抑制了噪音。Metaculus 為政策制定者、研究人員、記者和企業組織提供基於證據的預測,為全球最重要的不確定性提供清晰、可量化的洞察。探索我們的 企業解決方案,了解 Metaculus 如何改善貴組織的決策。", "publishTimeLockedDescription": "建立後將無法更改發佈時間。", "nMore": "還有 {count} 個...", + "noHistogramData": "沒有可用的直方圖數據", "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 6862ba5ce3..916dd4f143 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2171,5 +2171,6 @@ "whyTrustMetaculusLessNoise": "Metaculus 的众包预测通过以透明证据、可追溯的评分以及十年证明的准确性为基础来削减预测中的噪音。Metaculus 为政策制定者、研究人员、记者和企业组织提供基于证据的预测,为世界上最重要的不确定性提供清晰、可量化的洞察。了解我们的企业解决方案,了解 Metaculus 如何改善贵组织的决策。", "publishTimeLockedDescription": "创建后将无法更改发布时间。", "nMore": "{count} 更多...", + "noHistogramData": "没有可用的直方图数据", "thousandsOfOpenQuestions": "20,000+ 开放问题" } diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx index 0c29532cd1..8140cc7829 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_consumer_section.tsx @@ -7,12 +7,15 @@ import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider" import { openKeyFactorsSectionAndScrollTo } from "@/app/(main)/questions/[id]/components/key_factors/utils"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; +import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; +import { isQuestionPost } from "@/utils/questions/helpers"; import { MAX_TOP_KEY_FACTORS, useTopKeyFactorsCarouselItems, } from "./hooks/use_top_key_factors_carousel_items"; import KeyFactorDetailOverlay from "./key_factor_detail_overlay"; +import KeyFactorsCarousel from "./key_factors_carousel"; import KeyFactorsConsumerCarousel from "./key_factors_consumer_carousel"; import { useShouldHideKeyFactors } from "./use_should_hide_key_factors"; import { useQuestionLayout } from "../question_layout/question_layout_context"; @@ -41,14 +44,21 @@ const KeyFactorsQuestionConsumerSection: FC = ({ post }) => { if (post.status === PostStatus.RESOLVED) return null; + const postForecastAvailability = isQuestionPost(post) + ? getQuestionForecastAvailability(post.question) + : null; + const forecastIsEmpty = + !!postForecastAvailability?.isEmpty && + !postForecastAvailability?.cpRevealsOn; + + if (topItems.length === 0 && !forecastIsEmpty) return null; + const openKeyFactorsElement = (selector: string) => { requestKeyFactorsExpand?.(); openKeyFactorsSectionAndScrollTo({ selector, mobileOnly: false }); sendAnalyticsEvent("KeyFactorClick", { event_label: "fromTopList" }); }; - if (topItems.length === 0) return null; - return (
= ({ post }) => {
{t("topKeyFactors")}
- + {!forecastIsEmpty && ( + + )}
- + {forecastIsEmpty ? ( + ( +
+ )} + /> + ) : ( + + )} {keyFactorOverlay?.kind === "keyFactor" && ( = ({ )} {isTooltipActive && + !hideCP && + !forecastAvailability?.cpRevealsOn && !activeTimelineMarkerId && (tooltipChoices.length > 0 || !!tooltipUserChoices?.length || - !!forecastAvailability?.cpRevealsOn || !!forecastAvailability?.isEmpty) && (
= ({ postData, preselectedGroupQuestionId, mobileSidebar }) => { const t = useTranslations(); const { aggregateCoherenceLinks } = useCoherenceLinksContext(); + const { hideCP } = useHideCP(); const isFanGraph = postData.group_of_questions?.graph_type === @@ -151,9 +158,16 @@ export const ConsumerShell: FC<{ !isContinuousSingleQuestion && !isMultipleChoice; + const binaryForecastAvailability = + isBinarySingleQuestion && isQuestionPost(postData) + ? getQuestionForecastAvailability(postData.question) + : null; + const showSideBySide = - (isMultipleChoice || isNonFanGroup || isBinarySingleQuestion) && - !isContinuousSingleQuestion; + isMultipleChoice || + isNonFanGroup || + isBinarySingleQuestion || + isContinuousSingleQuestion; const showClosedMessageMultipleChoice = isMultipleChoicePost(postData) && @@ -166,7 +180,14 @@ export const ConsumerShell: FC<{ aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? []; const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0; const hasQuestionLinks = questionLinkAggregates.length > 0; - const shouldShowKeyFactorsSection = hasKeyFactors || hasQuestionLinks; + const questionForecastAvailability = isQuestionPost(postData) + ? getQuestionForecastAvailability(postData.question) + : null; + const isForecastEmpty = + !!questionForecastAvailability?.isEmpty && + !questionForecastAvailability?.cpRevealsOn; + const shouldShowKeyFactorsSection = + hasKeyFactors || hasQuestionLinks || isForecastEmpty; return (
@@ -191,7 +212,7 @@ export const ConsumerShell: FC<{
-
+
{showClosedMessageMultipleChoice && (

{t("predictionClosedMessage")} @@ -204,26 +225,60 @@ export const ConsumerShell: FC<{ !isMultipleChoice && !isNonFanGroup && "flex-col-reverse", - showSideBySide && "sm:flex-row sm:items-center sm:gap-8" + showSideBySide && + cn("sm:flex-row sm:items-center", { + "sm:gap-0 md:gap-8": isBinarySingleQuestion, + "sm:gap-8": !isBinarySingleQuestion, + }) )} > -

- -
+ {isBinarySingleQuestion && isQuestionPost(postData) ? ( +
+ {hideCP ? ( + + ) : binaryForecastAvailability?.cpRevealsOn ? ( + + ) : ( + + )} +
+ ) : ( +
+ {hideCP && !isContinuousSingleQuestion ? ( + + ) : ( + + )} +
+ )} {!isFanGraph && !isDateGroup && ( )} @@ -263,18 +318,23 @@ const QuestionPageShell: FC = ({ + + {/* Bridges timeline cursor to the mobile mini chart in consumer view */} + + } forecaster={ - + + + } /> diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx index 69b40e9597..f60629117a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/title_row.tsx @@ -4,13 +4,17 @@ import { FC } from "react"; import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status"; import QuestionTitle from "@/app/(main)/questions/[id]/components/question_view/shared/question_title"; +import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; import ConditionalTile from "@/components/conditional_tile"; +import { useContinuousChartCursor } from "@/contexts/continuous_chart_cursor_context"; +import { useHideCP } from "@/contexts/cp_context"; import { PostWithForecasts } from "@/types/post"; -import { QuestionWithForecasts } from "@/types/question"; +import { QuestionType, QuestionWithForecasts } from "@/types/question"; import cn from "@/utils/core/cn"; import { isConditionalPost, isContinuousQuestion, + isGroupOfQuestionsPost, isQuestionPost, } from "@/utils/questions/helpers"; @@ -23,6 +27,10 @@ type Props = { }; const TitleRow: FC = ({ post, variant, className }) => { + const { hideCP } = useHideCP(); + const cursorCtx = useContinuousChartCursor(); + const cursorForecast = cursorCtx?.activeForecast ?? null; + if (isConditionalPost(post)) { return (
@@ -32,6 +40,9 @@ const TitleRow: FC = ({ post, variant, className }) => { } if (variant === "forecaster" && isQuestionPost(post)) { + const isMultipleChoice = post.question.type === QuestionType.MultipleChoice; + const isContinuous = isContinuousQuestion(post.question); + return (
= ({ post, variant, className }) => { className )} > -
-
- +
+
+ {post.title} -
+
+ {isMultipleChoice ? ( + hideCP && + ) : ( + + )} +
+
+
+ {!isContinuous && ( +
+ {isMultipleChoice && hideCP ? ( + + ) : ( -
+ )} +
+ )} +
+ ); + } + + if (variant === "forecaster" && isGroupOfQuestionsPost(post)) { + return ( +
+
+
+ + {post.title} + + {hideCP && ( +
+ +
+ )}
- {!isContinuousQuestion(post.question) && ( -
- + {hideCP && ( +
+
)}
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/continuous_question_prediction.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/continuous_question_prediction.tsx index c6fbcf3084..1f69a87ad0 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/continuous_question_prediction.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/single_question_prediction/continuous_question_prediction.tsx @@ -1,10 +1,20 @@ "use client"; -import { getContinuousAreaChartData } from "@/components/charts/continuous_area_chart"; +import { useMemo } from "react"; + +import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; +import { + ContinuousAreaGraphInput, + getContinuousAreaChartData, +} from "@/components/charts/continuous_area_chart"; import MinifiedContinuousAreaChart from "@/components/charts/minified_continuous_area_chart"; import ConsumerContinuousTile from "@/components/consumer_post_card/consumer_question_tile/consumer_continuous_tile"; +import { useContinuousChartCursor } from "@/contexts/continuous_chart_cursor_context"; +import { useHideCP } from "@/contexts/cp_context"; +import { ContinuousAreaType } from "@/types/charts"; import { QuestionStatus } from "@/types/post"; import { QuestionWithNumericForecasts } from "@/types/question"; +import { cdfToPmf } from "@/utils/math"; import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; type Props = { @@ -17,8 +27,10 @@ const ContinuousQuestionPrediction: React.FC = ({ chartHeight, }) => { const forecastAvailability = getQuestionForecastAvailability(question); + const cursorCtx = useContinuousChartCursor(); + const cursorForecast = cursorCtx?.activeForecast ?? null; + const { hideCP } = useHideCP(); - // Hide chart if no forecasts or CP not yet revealed const shouldHideChart = forecastAvailability.isEmpty || !!forecastAvailability.cpRevealsOn; @@ -27,12 +39,37 @@ const ContinuousQuestionPrediction: React.FC = ({ isClosed: question.status === QuestionStatus.CLOSED, }); + const cursorForecastValues = cursorForecast?.forecast_values ?? null; + + // Null when cursor is inactive — chart falls back to the latest aggregate. + const cursorChartData = useMemo(() => { + if (!cursorForecastValues) return null; + return [ + { + pmf: cdfToPmf(cursorForecastValues), + cdf: cursorForecastValues, + type: (question.status === QuestionStatus.CLOSED + ? "community_closed" + : "community") as ContinuousAreaType, + }, + ]; + }, [cursorForecastValues, question.status]); + + if (hideCP) { + return ( +
+ +
+ ); + } + return (
{!shouldHideChart && ( <> @@ -40,7 +77,7 @@ const ContinuousQuestionPrediction: React.FC = ({
= ({ @@ -36,6 +48,7 @@ const QuestionHeaderCPStatus: FC = ({ hideLabel = false, colorOverride, chartTheme, + cursorForecast, }) => { const locale = useLocale(); const t = useTranslations(); @@ -58,6 +71,22 @@ const QuestionHeaderCPStatus: FC = ({ const isEmbedBelow376 = isEmbed && (w ?? 0) > 0 && (w ?? 0) < 376; const isEmbedWide = isEmbed && (w ?? 0) >= 500; + const cursorForecastValues = cursorForecast?.forecast_values ?? null; + const cursorAreaChartData = useMemo(() => { + if (!cursorForecastValues) return null; + return [ + { + pmf: cdfToPmf(cursorForecastValues), + cdf: cursorForecastValues, + type: (question.status === QuestionStatus.RESOLVED + ? "community_resolved" + : question.status === QuestionStatus.CLOSED + ? "community_closed" + : "community") as ContinuousAreaType, + }, + ]; + }, [cursorForecastValues, question.status]); + if (question.status === QuestionStatus.RESOLVED && question.resolution) { // Resolved/Annulled/Ambiguous const formatedResolution = formatResolution({ @@ -104,112 +133,191 @@ const QuestionHeaderCPStatus: FC = ({ ? "border-[0.5px] border-gray-500 p-3 dark:border-gray-500-dark md:p-3" : "border-[0.5px] border-olive-500 p-3 dark:border-olive-500-dark md:p-3"; + const cursorCenter = cursorForecast?.centers?.[0] ?? null; + const cursorLower = cursorForecast?.interval_lower_bounds?.[0] ?? null; + const cursorUpper = cursorForecast?.interval_upper_bounds?.[0] ?? null; + if (isContinuous) { - return ( - !forecastAvailability.isEmpty && ( + const containerClassName = cn( + "flex min-w-[110px] flex-col rounded-md border border-olive-800/20 p-2 dark:border-olive-800 md:px-3 md:py-2.5", + { + "min-h-full w-[200px]": size === "lg" && hideLabel, + "w-max max-w-[200px]": size === "lg" && !hideLabel, + "max-w-[130px]": + !forecastAvailability.cpRevealsOn && + (size === "md" || (isEmbed && !isEmbedBelow376 && !isEmbedWide)), + "gap-1": !hideLabel && size === "lg", + "gap-0": size === "md", + "-gap-2": size === "md" && hideLabel, + [embedBorderClass]: isEmbed, + "max-w-[195px]": isEmbedWide, + "min-w-[200px] border-none p-0": isEmbedBelow376, + } + ); + + // No forecasts and no upcoming reveal — render empty outline box to preserve column width + if (forecastAvailability.isEmpty && !forecastAvailability.cpRevealsOn) { + return (
-
- {!hideLabel && ( - - )} - {isEmbedBelow376 && ( -

- {t("currentEstimate")} -

- )} - {!hideCP && ( - - )} +

+ {t("currentEstimate")} +

+
+ ); + } + + // CP reveals in the future — center the countdown, skip the mini chart + if (forecastAvailability.cpRevealsOn) { + return ( +
+ +
+ ); + } + + // CP hidden by user preference — center the reveal button, skip the mini chart + if (hideCP && !isEmbed) { + if (size === "md") { + return ( +
+
- {!!continuousAreaChartData && ( + ); + } + return ( +
+ +
+ ); + } + + return ( +
+
+ {!hideLabel && ( )} - {!hideCP && ( - span]:whitespace-normal", - { - "-mt-2 text-center": size === "md" && hideLabel, - "text-center": size === "md" && !hideLabel, - "mt-0 md:[&>span]:whitespace-nowrap": isEmbed, - } - )} - size={"sm"} - unit={size === "md" ? "" : undefined} - boldValueUnit={true} + {isEmbedBelow376 && ( +

+ {t("currentEstimate")} +

+ )} + {!hideCP && !forecastAvailability.cpRevealsOn && ( + )}
- ) + {!!continuousAreaChartData && !forecastAvailability.cpRevealsOn && ( +
+ +
+ )} + {!hideCP && !forecastAvailability.cpRevealsOn && ( + span]:whitespace-normal", + { + "-mt-2 text-center": size === "md" && hideLabel, + "text-center": size === "md" && !hideLabel, + "mt-0 md:[&>span]:whitespace-nowrap": isEmbed, + } + )} + size={"sm"} + unit={size === "md" ? "" : undefined} + boldValueUnit={true} + /> + )} +
); } else if (question.type === QuestionType.Binary) { + if (hideCP && !isEmbed) { + return ( +
+ +
+ ); + } + + if (forecastAvailability.cpRevealsOn && !isEmbed) { + return ( +
+ +
+ ); + } + return (
= ({ = ({ domainPadding={v.domainPadding(variantArgs)} padding={chartPadding} containerComponent={ - withTooltip ? ( + withTooltip && !hideCP && !forecastAvailability?.cpRevealsOn ? ( containerWithTooltip ) : ( = ({ }, ]} containerComponent={ - onCursorChange ? ( + onCursorChange && + !hideCP && + !forecastAvailability?.cpRevealsOn ? ( CursorContainer ) : ( = ({ } padding={{ top: 0, bottom: 20, left: 12, right: 12 }} height={75} - width={!!width ? width : undefined} + width={width || chartWidth || undefined} > = ({ }, ]} containerComponent={ - onCursorChange ? ( + onCursorChange && + !hideCP && + !forecastAvailability?.cpRevealsOn ? ( CursorContainer ) : ( = ({ axis: { stroke: "transparent", }, - grid: isEmptyDomain - ? { - stroke: getThemeColor(METAC_COLORS.gray["300"]), - strokeWidth: 1, - strokeDasharray: "2, 5", - } - : { - stroke: "transparent", - }, + grid: + isEmptyDomain || hideCP + ? { + stroke: getThemeColor(METAC_COLORS.gray["300"]), + strokeWidth: 1, + strokeDasharray: "2, 5", + } + : { + stroke: "transparent", + }, }} label={yLabel} offsetX={ diff --git a/front_end/src/components/charts/numeric_chart.tsx b/front_end/src/components/charts/numeric_chart.tsx index d8ccecc738..c987d37ca2 100644 --- a/front_end/src/components/charts/numeric_chart.tsx +++ b/front_end/src/components/charts/numeric_chart.tsx @@ -73,9 +73,10 @@ type ChartData = { type Props = { buildChartData: (width: number, zoom: TimelineChartZoomOption) => ChartData; - chartTitle?: string; + chartTitle?: ReactNode; height?: number; hideCP?: boolean; + hideCursorValueLabel?: boolean; defaultZoom?: TimelineChartZoomOption; withZoomPicker?: boolean; resolutionPoint?: LinePoint[]; @@ -101,6 +102,7 @@ type Props = { showNewsAnnotations?: boolean; onToggleNewsAnnotations?: () => void; animate?: object; + suppressEmptyOverlay?: boolean; }; const BOTTOM_PADDING = 20; @@ -127,21 +129,29 @@ const NumericChart: FC = ({ nonInteractive = false, isEmbedded = false, simplifiedCursor = false, + hideCursorValueLabel = false, forecastAvailability, questionStatus, resolution, cursorTooltip, + isConsumerView, questionType, newsAnnotations, showNewsAnnotations, onToggleNewsAnnotations, animate, + suppressEmptyOverlay = false, }) => { const { theme, getThemeColor } = useAppTheme(); const [isChartReady, setIsChartReady] = useState(false); const [zoom, setZoom] = useState(defaultZoom); const [isCursorActive, setIsCursorActive] = useState(false); + const isContinuousConsumerView = + isConsumerView && + (questionType === QuestionType.Numeric || + questionType === QuestionType.Date || + questionType === QuestionType.Discrete); const { ref: chartContainerRef, width: chartWidth } = useContainerSize(); const { line, area, points, yDomain, xDomain, yScale, xScale } = useMemo( @@ -221,7 +231,12 @@ const NumericChart: FC = ({ }, [rightPadding, MIN_RIGHT_PADDING]); const containerComponent = useMemo(() => { - if (nonInteractive) { + if ( + nonInteractive || + forecastAvailability?.isEmpty || + hideCP || + !!forecastAvailability?.cpRevealsOn + ) { return ( = ({ } }} cursorComponent={ - + isContinuousConsumerView ? ( + + ) : ( + + ) } cursorLabelComponent={ @@ -292,6 +311,9 @@ const NumericChart: FC = ({ handleCursorChange, nonInteractive, isCursorActive, + forecastAvailability, + hideCP, + isContinuousConsumerView, ]); const chartEvents = useMemo(() => { @@ -304,6 +326,10 @@ const NumericChart: FC = ({ onTouchStart: () => { setIsCursorActive(true); }, + onTouchEnd: () => { + setIsCursorActive(false); + handleCursorChange(null); + }, onMouseEnter: () => { setIsCursorActive(true); }, @@ -385,8 +411,26 @@ const NumericChart: FC = ({ return xDomain; }, [xDomain, isEmbedded]); + const [touchPoint, setTouchPoint] = useState<{ + x: number; + y: number; + } | null>(null); const { getReferenceProps, getFloatingProps, refs, floatingStyles } = - useChartTooltip({ placement: "top", tooltipOffset: 15 }); + useChartTooltip({ + placement: "top", + tooltipOffset: touchPoint ? 50 : 15, + x: touchPoint?.x ?? null, + y: touchPoint?.y ?? null, + }); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + setTouchPoint({ x: touch.clientX, y: touch.clientY }); + }, []); + const handleTouchEnd = useCallback(() => { + setTouchPoint(null); + }, []); const { isActive: isDiamondActive, getReferenceProps: getDiamondRefProps, @@ -462,6 +506,13 @@ const NumericChart: FC = ({ return typeof fromTheme === "number" ? fromTheme : 0.3; }, [hasExternalTheme, themeAreaData?.opacity]); + const cursorDotFill = useMemo( + () => + resolveToCssColor(getThemeColor, colorOverride) ?? + getThemeColor(colorPalette.chip), + [getThemeColor, colorOverride, colorPalette.chip] + ); + const rightPad = Math.max(rightPadding, MIN_RIGHT_PADDING); const yAxisLabel = !isNil(yLabel) ? `(${yLabel})` : undefined; const leftPad = 10; @@ -549,9 +600,16 @@ const NumericChart: FC = ({ )} ref={refs.setReference} {...getReferenceProps()} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} > = ({ ) : null} - {!isDiamondActive && !isNil(highlightedPoint) && !hideCP ? ( + {isCursorActive && + !isNil(highlightedPoint) && + !hideCP && + isContinuousConsumerView ? ( + + ) : null} + + {!isDiamondActive && + !isNil(highlightedPoint) && + !hideCP && + !(hideCursorValueLabel && isCursorActive) ? ( = ({
{/* Forecaster view tooltip */} - {isCursorActive && !!cursorTooltip ? ( + {isCursorActive && !!cursorTooltip && !isContinuousConsumerView ? (
void; + hideCursorValueLabel?: boolean; + suppressEmptyOverlay?: boolean; }; const NumericTimeline: FC = ({ @@ -98,6 +100,8 @@ const NumericTimeline: FC = ({ keyFactors, showNewsAnnotations, onToggleNewsAnnotations, + hideCursorValueLabel, + suppressEmptyOverlay, }) => { const locale = useLocale(); const resolutionPoint = useMemo(() => { @@ -235,6 +239,8 @@ const NumericTimeline: FC = ({ newsAnnotations={newsAnnotations} showNewsAnnotations={showNewsAnnotations} onToggleNewsAnnotations={onToggleNewsAnnotations} + hideCursorValueLabel={hideCursorValueLabel} + suppressEmptyOverlay={suppressEmptyOverlay} /> ); }; diff --git a/front_end/src/components/charts/primitives/chart_container.tsx b/front_end/src/components/charts/primitives/chart_container.tsx index ced56dea0f..d4ee57948c 100644 --- a/front_end/src/components/charts/primitives/chart_container.tsx +++ b/front_end/src/components/charts/primitives/chart_container.tsx @@ -1,7 +1,13 @@ "use client"; import { Tab, TabGroup, TabList } from "@headlessui/react"; import { isNil } from "lodash"; -import { forwardRef, Fragment, PropsWithChildren, useState } from "react"; +import { + forwardRef, + Fragment, + PropsWithChildren, + ReactNode, + useState, +} from "react"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import { TimelineChartZoomOption } from "@/types/charts"; @@ -12,7 +18,7 @@ type Props = { height: number; zoom?: TimelineChartZoomOption; onZoomChange?: (zoom: TimelineChartZoomOption) => void; - chartTitle?: string; + chartTitle?: ReactNode; leftLegend?: React.ReactNode; headerExtra?: React.ReactNode; }; diff --git a/front_end/src/components/charts/primitives/line_cursor_points.tsx b/front_end/src/components/charts/primitives/line_cursor_points.tsx index 15f21f6f1d..0f36610ce5 100644 --- a/front_end/src/components/charts/primitives/line_cursor_points.tsx +++ b/front_end/src/components/charts/primitives/line_cursor_points.tsx @@ -10,8 +10,6 @@ import { } from "@/utils/charts/helpers"; const SIZE = 4; -// https://commerce.nearform.com/open-source/victory/docs/api/victory-cursor-container#cursorlabeloffset -const DEFAULT_X_OFFSET = 5; type Props = { chartData: Array<{ @@ -87,13 +85,13 @@ const LineCursorPoints = ({ ) : ( ); diff --git a/front_end/src/components/consumer_post_card/binary_cp_bar.tsx b/front_end/src/components/consumer_post_card/binary_cp_bar.tsx index 4a84ea67d7..2633c6a840 100644 --- a/front_end/src/components/consumer_post_card/binary_cp_bar.tsx +++ b/front_end/src/components/consumer_post_card/binary_cp_bar.tsx @@ -33,7 +33,7 @@ const BinaryCPBar: FC = ({ question.aggregations[question.default_aggregation_method]?.latest ?.centers?.[0]; - if (question.type !== QuestionType.Binary || !questionCP) { + if (question.type !== QuestionType.Binary) { return null; } diff --git a/front_end/src/components/consumer_post_card/consumer_question_tile/consumer_continuous_tile.tsx b/front_end/src/components/consumer_post_card/consumer_question_tile/consumer_continuous_tile.tsx index a7eeeafbda..4aa72c1537 100644 --- a/front_end/src/components/consumer_post_card/consumer_question_tile/consumer_continuous_tile.tsx +++ b/front_end/src/components/consumer_post_card/consumer_question_tile/consumer_continuous_tile.tsx @@ -12,25 +12,33 @@ type Props = { question: QuestionWithForecasts; forecastAvailability: ForecastAvailability; variant?: "feed" | "question"; + overrideCenter?: number | null; }; const ConsumerContinuousTile: FC = ({ question, forecastAvailability, variant = "feed", + overrideCenter, }) => { const locale = useLocale(); const latest = question.aggregations[question.default_aggregation_method]?.latest; - const communityPredictionDisplayValue = latest - ? getPredictionDisplayValue(latest.centers?.[0], { - questionType: question.type, - scaling: question.scaling, - actual_resolve_time: question.actual_resolve_time ?? null, - unit: question.unit, - }) - : null; + // Cursor position overrides the latest CP center when hovering the timeline. + const effectiveCenter = + overrideCenter !== null && overrideCenter !== undefined + ? overrideCenter + : latest?.centers?.[0]; + const communityPredictionDisplayValue = + effectiveCenter !== undefined + ? getPredictionDisplayValue(effectiveCenter, { + questionType: question.type, + scaling: question.scaling, + actual_resolve_time: question.actual_resolve_time ?? null, + unit: question.unit, + }) + : null; // Resolved/Annulled/Ambiguous if (question.resolution) { diff --git a/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx b/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx index 9be6aed4b5..d64adce9f1 100644 --- a/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx +++ b/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx @@ -5,7 +5,6 @@ import { VictoryThemeDefinition } from "victory"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import GroupTimeline from "@/app/(main)/questions/[id]/components/group_timeline"; -import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; import FanChart from "@/components/charts/fan_chart"; import { MultipleChoiceTile } from "@/components/post_card/multiple_choice_tile"; import { @@ -147,68 +146,59 @@ const DetailedGroupCard: FC = ({ ); return ( - <> - - {hideCP && } - - ); - } - - return ( - <> - - {hideCP && } - + ); + } + + return ( + ); } case GroupOfQuestionsGraphType.FanGraph: return ( - <> - - {hideCP && } - + ); default: return null; diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/continuous_chart_card.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/continuous_chart_card.tsx index 5707b95416..5fec30471e 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/continuous_chart_card.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/continuous_chart_card.tsx @@ -1,7 +1,15 @@ "use client"; import { isNil } from "lodash"; +import dynamic from "next/dynamic"; import { useTranslations } from "next-intl"; -import { FC, ReactNode, useCallback, useMemo, useState } from "react"; +import { + FC, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { VictoryThemeDefinition } from "victory"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; @@ -9,11 +17,15 @@ import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/quest import NumericTimeline from "@/components/charts/numeric_timeline"; import QuestionPredictionTooltip from "@/components/charts/primitives/question_prediction_tooltip"; import ContinuousPredictionChart from "@/components/forecast_maker/continuous_input/continuous_prediction_chart"; +import Button from "@/components/ui/button"; +import { GroupButton } from "@/components/ui/button_group"; import { useAuth } from "@/contexts/auth_context"; +import { useContinuousChartCursor } from "@/contexts/continuous_chart_cursor_context"; import { EmbedChartType, TimelineChartZoomOption } from "@/types/charts"; import { KeyFactor } from "@/types/comment"; import { ForecastAvailability, + NumericAggregateForecast, QuestionType, QuestionWithNumericForecasts, } from "@/types/question"; @@ -31,6 +43,12 @@ import { isContinuousQuestion, } from "@/utils/questions/helpers"; +import { useFullAggregation } from "./hooks/use_full_aggregation"; + +const Histogram = dynamic(() => import("@/components/charts/histogram"), { + ssr: false, +}); + type Props = { question: QuestionWithNumericForecasts; hideCP?: boolean; @@ -47,6 +65,8 @@ type Props = { keyFactors?: KeyFactor[]; }; +type ChartView = "timeline" | "histogram"; + const DetailedContinuousChartCard: FC = ({ question, hideCP, @@ -70,10 +90,27 @@ const DetailedContinuousChartCard: FC = ({ const effectiveWithZoomPicker = withZoomPicker ?? true; const isConsumerView = isConsumerViewProp ?? !user; + const isContinuousConsumer = isConsumerView && isContinuousQuestion(question); const [isChartReady, setIsChartReady] = useState(false); + const [activeView, setActiveView] = useState("timeline"); + + const isContinuous = isContinuousQuestion(question); + const [shouldFetchFull, setShouldFetchFull] = useState(false); + const { data: enrichedAggregation = null } = useFullAggregation( + question.id, + question.default_aggregation_method, + question.include_bots_in_aggregates, + isContinuous && shouldFetchFull + ); + + const handlePointerEnter = useCallback(() => { + if (isContinuous) setShouldFetchFull(true); + }, [isContinuous]); const aggregation = question.aggregations[question.default_aggregation_method]; + const effectiveAggregation = (enrichedAggregation ?? + aggregation) as typeof aggregation; const isCpHidden = !!forecastAvailability?.cpRevealsOn; const [cursorTimestamp, setCursorTimestamp] = useState(null); @@ -92,7 +129,7 @@ const DetailedContinuousChartCard: FC = ({ }; } - const forecast = getCursorForecast(cursorTimestamp, aggregation); + const forecast = getCursorForecast(cursorTimestamp, effectiveAggregation); let timestamp = cursorTimestamp; if ( timestamp === null && @@ -116,7 +153,7 @@ const DetailedContinuousChartCard: FC = ({ }, [ isCpHidden, cursorTimestamp, - aggregation, + effectiveAggregation, question.my_forecasts, nrForecasters, ]); @@ -124,10 +161,6 @@ const DetailedContinuousChartCard: FC = ({ const discreteValueOptions = getDiscreteValueOptions(question); const cpCursorElement = useMemo(() => { - if (forecastAvailability?.isEmpty) { - return t("noForecastsYet"); - } - if (hideCP) { return "..."; } @@ -177,6 +210,29 @@ const DetailedContinuousChartCard: FC = ({ discreteValueOptions, ]); + const isBinary = question.type === QuestionType.Binary; + + const activeForecast = useMemo(() => { + if ( + isCpHidden || + cursorTimestamp === null || + !isContinuousQuestion(question) + ) + return null; + return getCursorForecast( + cursorTimestamp, + effectiveAggregation + ) as NumericAggregateForecast | null; + }, [isCpHidden, cursorTimestamp, effectiveAggregation, question]); + + const cursorCtx = useContinuousChartCursor(); + const setCursorForecast = cursorCtx?.setActiveForecast; + useEffect(() => { + if (!isContinuousQuestion(question) || !setCursorForecast) return; + setCursorForecast(activeForecast); + return () => setCursorForecast(null); + }, [activeForecast, setCursorForecast, question]); + const handleCursorChange = useCallback((value: number | null) => { setCursorTimestamp(value); }, []); @@ -210,10 +266,35 @@ const DetailedContinuousChartCard: FC = ({ !forecastAvailability?.cpRevealsOn && (question.type === QuestionType.Binary || isContinuousQuestion(question)); - const isBinary = question.type === QuestionType.Binary; + const chartViewButtons: GroupButton[] = [ + { value: "timeline", label: t("timeline") }, + { value: "histogram", label: t("histogram") }, + ]; + + const viewToggle = isBinary ? ( +
+ {chartViewButtons.map(({ value, label }) => ( + + ))} +
+ ) : null; - const timelineTitle = - !isEmbed && !hideTitle ? t("forecastTimelineHeading") : undefined; + // Binary gets the toggle as the title node; continuous keeps the text heading. + let timelineTitle: ReactNode; + if (!isEmbed && !hideTitle) { + timelineTitle = isBinary ? viewToggle : t("forecastTimelineHeading"); + } const chartHeight = embedChartHeight ?? 150; @@ -240,10 +321,16 @@ const DetailedContinuousChartCard: FC = ({ unit={question.unit} inboundOutcomeCount={question.inbound_outcome_count} simplifiedCursor={false} + hideCursorValueLabel={isContinuousConsumer && !isEmbed} title={timelineTitle} forecastAvailability={forecastAvailability} - cursorTooltip={cursorTooltip} - isConsumerView={isConsumerView} + suppressEmptyOverlay + cursorTooltip={ + forecastAvailability?.isEmpty || isContinuous + ? undefined + : cursorTooltip + } + isConsumerView={isContinuousConsumer} isEmbedded={isEmbed} height={chartHeight} extraTheme={extraTheme} @@ -268,6 +355,7 @@ const DetailedContinuousChartCard: FC = ({ hideLabel={isContinuousQuestion(question)} colorOverride={cpColorOverride} chartTheme={extraTheme} + cursorForecast={activeForecast} /> ); @@ -276,6 +364,65 @@ const DetailedContinuousChartCard: FC = ({ !hideCP && !forecastAvailability?.cpRevealsOn; + const renderHistogram = () => { + const aggregationLatest = + question.aggregations[question.default_aggregation_method].latest; + const histogram = aggregationLatest?.histogram?.at(0); + if (!histogram?.length) { + return ( +
+ {!isEmbed && !hideTitle && ( +
+
+ {viewToggle} +
+
+ )} +
+ + {t("noHistogramData")} + +
+
+ ); + } + + const histogramData = histogram.map((value, index) => ({ + x: index, + y: value, + })); + const median = aggregationLatest?.centers?.[0]; + const mean = aggregationLatest?.means?.[0]; + + return ( +
+ {!isEmbed && !hideTitle && ( +
+
+ {viewToggle} +
+
+ )} +
+ {!hideCP && !isCpHidden && ( + + )} +
+
+ ); + }; + if (canRenderCurrentEmbed) { return (
@@ -294,14 +441,19 @@ const DetailedContinuousChartCard: FC = ({ ); } + if (isBinary && activeView === "histogram") { + return <>{renderHistogram()}; + } + return (
- {!isConsumerView ? ( + {isContinuousQuestion(question) && !isEmbed ? ( <> {/* Desktop */}
@@ -310,6 +462,7 @@ const DetailedContinuousChartCard: FC = ({ question={question} size="lg" hideLabel={true} + cursorForecast={activeForecast} /> )} diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/hooks/use_full_aggregation.ts b/front_end/src/components/detailed_question_card/detailed_question_card/hooks/use_full_aggregation.ts new file mode 100644 index 0000000000..95a981d2ca --- /dev/null +++ b/front_end/src/components/detailed_question_card/detailed_question_card/hooks/use_full_aggregation.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import ClientAggregationExplorerApi from "@/services/api/aggregation_explorer/aggregation_explorer.client"; +import { NumericAggregateForecastHistory } from "@/types/question"; + +export function useFullAggregation( + questionId: number, + defaultAggregationMethod: string, + includeBots: boolean, + enabled: boolean +) { + return useQuery({ + queryKey: [ + "full-aggregation", + questionId, + defaultAggregationMethod, + includeBots, + ], + enabled, + queryFn: async () => { + const result = await ClientAggregationExplorerApi.getAggregations({ + questionId, + aggregationMethods: defaultAggregationMethod, + includeBots, + }); + return ( + ( + result.aggregations as Record< + string, + NumericAggregateForecastHistory | undefined + > + )[defaultAggregationMethod] ?? null + ); + }, + }); +} diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx index d0da1e1fdd..95c8353717 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx @@ -3,7 +3,6 @@ import { FC, useEffect } from "react"; import { VictoryThemeDefinition } from "victory"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; -import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; import { useHideCP } from "@/contexts/cp_context"; import { EmbedChartType, TimelineChartZoomOption } from "@/types/charts"; import { KeyFactor } from "@/types/comment"; @@ -85,7 +84,6 @@ const DetailedQuestionCard: FC = ({ embedChartType={embedChartType} keyFactors={keyFactors} /> - {hideCP && } ); case QuestionType.MultipleChoice: @@ -102,7 +100,6 @@ const DetailedQuestionCard: FC = ({ chartTheme={chartTheme} defaultZoom={defaultZoom} /> - {hideCP && } ); default: diff --git a/front_end/src/components/post_card/question_tile/continuous_cp_bar.tsx b/front_end/src/components/post_card/question_tile/continuous_cp_bar.tsx index fa521cdb62..8cea6383f9 100644 --- a/front_end/src/components/post_card/question_tile/continuous_cp_bar.tsx +++ b/front_end/src/components/post_card/question_tile/continuous_cp_bar.tsx @@ -19,6 +19,8 @@ type Props = { size?: "md" | "lg"; variant?: "feed" | "question"; colorOverride?: string; + overrideCenter?: number | null; + overrideBounds?: [number, number] | null; }; const ContinuousCPBar: FC = ({ @@ -26,6 +28,8 @@ const ContinuousCPBar: FC = ({ size = "md", variant = "feed", colorOverride, + overrideCenter, + overrideBounds, }) => { const latest = question.aggregations[question.default_aggregation_method]?.latest; @@ -41,18 +45,20 @@ const ContinuousCPBar: FC = ({ } const discreteValueOptions = getDiscreteValueOptions(question); + const effectiveCenter = overrideCenter ?? latest.centers?.[0]; + const effectiveLower = + overrideBounds?.[0] ?? latest.interval_lower_bounds?.[0]; + const effectiveUpper = + overrideBounds?.[1] ?? latest.interval_upper_bounds?.[0]; + const displayValue = getPredictionDisplayValue( - latest.centers?.[0], + effectiveCenter, { questionType: question.type, scaling: question.scaling, range: - !isNil(latest?.interval_lower_bounds?.[0]) && - !isNil(latest?.interval_upper_bounds?.[0]) - ? [ - latest?.interval_lower_bounds?.[0] as number, - latest?.interval_upper_bounds?.[0] as number, - ] + !isNil(effectiveLower) && !isNil(effectiveUpper) + ? [effectiveLower as number, effectiveUpper as number] : [], unit: question.unit, actual_resolve_time: question.actual_resolve_time ?? null, diff --git a/front_end/src/contexts/continuous_chart_cursor_context.tsx b/front_end/src/contexts/continuous_chart_cursor_context.tsx new file mode 100644 index 0000000000..8be2963a74 --- /dev/null +++ b/front_end/src/contexts/continuous_chart_cursor_context.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { + createContext, + FC, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +import { NumericAggregateForecast } from "@/types/question"; + +type CursorContextValue = { + activeForecast: NumericAggregateForecast | null; + setActiveForecast: (f: NumericAggregateForecast | null) => void; +}; + +const ContinuousChartCursorContext = createContext( + null +); + +export const ContinuousChartCursorProvider: FC<{ children: ReactNode }> = ({ + children, +}) => { + const [activeForecast, setActiveForecast] = + useState(null); + const stableSet = useCallback( + (f: NumericAggregateForecast | null) => setActiveForecast(f), + [] + ); + const value = useMemo( + () => ({ activeForecast, setActiveForecast: stableSet }), + [activeForecast, stableSet] + ); + return ( + + {children} + + ); +}; + +export const useContinuousChartCursor = () => + useContext(ContinuousChartCursorContext); diff --git a/front_end/src/hooks/use_chart_tooltip.ts b/front_end/src/hooks/use_chart_tooltip.ts index da53f40498..25f3c7fa55 100644 --- a/front_end/src/hooks/use_chart_tooltip.ts +++ b/front_end/src/hooks/use_chart_tooltip.ts @@ -15,11 +15,15 @@ import { useState } from "react"; type Props = { placement?: Placement; tooltipOffset?: number; + x?: number | null; + y?: number | null; }; const useChartTooltip = ({ placement = "left", tooltipOffset = 24, + x, + y, }: Props = {}) => { const [isActive, setIsActive] = useState(false); const { refs, floatingStyles, context } = useFloating({ @@ -34,7 +38,7 @@ const useChartTooltip = ({ placement, whileElementsMounted: autoUpdate, }); - const clientPoint = useClientPoint(context); + const clientPoint = useClientPoint(context, { x, y }); const dismiss = useDismiss(context); const hover = useHover(context); const { getReferenceProps, getFloatingProps } = useInteractions([ diff --git a/front_end/src/hooks/use_container_size.ts b/front_end/src/hooks/use_container_size.ts index 8cee018533..5153c3717b 100644 --- a/front_end/src/hooks/use_container_size.ts +++ b/front_end/src/hooks/use_container_size.ts @@ -30,6 +30,8 @@ const useContainerSize = () => { return; } + onResize(); + const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(ref.current);