From 02684fd532c82d3babddb35f70e4355ee356c387 Mon Sep 17 00:00:00 2001 From: Nikita <93587872+ncarazon@users.noreply.github.com> Date: Thu, 7 May 2026 11:32:42 +0300 Subject: [PATCH 1/6] Mount consumer binary + continuous side-by-side with the detailed chart card & Timeline / Histogram toggle on binary bodies (#4692) * feat: add QuestionHeaderCPStatus next to timeline for consumer binary questions * fix: remove extra mt-8 top margin from consumer continuous timeline * feat: add Timeline/Histogram toggle to binary detailed chart cards and fix consumer binary radial padding * fix: resolve layout jumping between chart views, fix histogram full-width responsive scaling, and center binary radial charts on mobile * fix: prevent chart layout snapping with synchronous measurements, enable side-by-side continuous charts on sm breakpoints, and standardize spacing utility classes * fix: show fallback message when histogram tab has no data --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 1 + front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../components/question_page_shell/index.tsx | 43 ++++--- .../question_header_cp_status.tsx | 7 +- front_end/src/components/charts/histogram.tsx | 2 +- .../src/components/charts/numeric_chart.tsx | 2 +- .../components/charts/numeric_timeline.tsx | 2 +- .../charts/primitives/chart_container.tsx | 10 +- .../continuous_chart_card.tsx | 107 +++++++++++++++++- front_end/src/hooks/use_container_size.ts | 2 + 14 files changed, 158 insertions(+), 23 deletions(-) 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/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 0c67495458..7da588d633 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 @@ -38,6 +38,7 @@ import { QuestionVariantComposer } from "../question_variant_composer"; import ActionRow from "../question_view/action_row"; import ConsumerQuestionPrediction from "../question_view/consumer_question_view/prediction"; import QuestionTimeline from "../question_view/consumer_question_view/timeline"; +import QuestionHeaderCPStatus from "../question_view/forecaster_question_view/question_header/question_header_cp_status"; const baseSectionClassName = "relative z-10 flex w-[59rem] max-w-full flex-col gap-6 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:p-8"; @@ -152,8 +153,10 @@ export const ConsumerShell: FC<{ !isMultipleChoice; const showSideBySide = - (isMultipleChoice || isNonFanGroup || isBinarySingleQuestion) && - !isContinuousSingleQuestion; + isMultipleChoice || + isNonFanGroup || + isBinarySingleQuestion || + isContinuousSingleQuestion; const showClosedMessageMultipleChoice = isMultipleChoicePost(postData) && @@ -191,7 +194,7 @@ export const ConsumerShell: FC<{
-
+
{showClosedMessageMultipleChoice && (

{t("predictionClosedMessage")} @@ -204,17 +207,30 @@ 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) ? ( +
+ +
+ ) : ( +
+ +
+ )} {!isFanGraph && !isDateGroup && ( )} diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx index 814150e4e7..b0f70352c2 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status.tsx @@ -208,8 +208,8 @@ const QuestionHeaderCPStatus: FC = ({ className={cn( "flex flex-col", { - "gap-4": size === "lg", // Desktop: 16px gap - "gap-1.5": size === "md", // Mobile: 6px gap + "w-36 items-center": size === "lg", + "gap-1.5": size === "md", }, isEmbed && "[@container(max-width:375px)]:scale-[130%]" )} @@ -225,7 +225,8 @@ const QuestionHeaderCPStatus: FC = ({ = ({ } padding={{ top: 0, bottom: 20, left: 12, right: 12 }} height={75} - width={!!width ? width : undefined} + width={width || chartWidth || undefined} > ChartData; - chartTitle?: string; + chartTitle?: ReactNode; height?: number; hideCP?: boolean; defaultZoom?: TimelineChartZoomOption; diff --git a/front_end/src/components/charts/numeric_timeline.tsx b/front_end/src/components/charts/numeric_timeline.tsx index a6ec474b9a..54867f2919 100644 --- a/front_end/src/components/charts/numeric_timeline.tsx +++ b/front_end/src/components/charts/numeric_timeline.tsx @@ -51,7 +51,7 @@ type Props = { inboundOutcomeCount?: number | null; isEmbedded?: boolean; simplifiedCursor?: boolean; - title?: string; + title?: ReactNode; forecastAvailability?: ForecastAvailability; questionStatus?: QuestionStatus; cursorTooltip?: ReactNode; 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/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..739c3061ee 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,14 +1,18 @@ "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 { VictoryThemeDefinition } from "victory"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status"; +import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; 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 { EmbedChartType, TimelineChartZoomOption } from "@/types/charts"; import { KeyFactor } from "@/types/comment"; @@ -31,6 +35,10 @@ import { isContinuousQuestion, } from "@/utils/questions/helpers"; +const Histogram = dynamic(() => import("@/components/charts/histogram"), { + ssr: false, +}); + type Props = { question: QuestionWithNumericForecasts; hideCP?: boolean; @@ -47,6 +55,8 @@ type Props = { keyFactors?: KeyFactor[]; }; +type ChartView = "timeline" | "histogram"; + const DetailedContinuousChartCard: FC = ({ question, hideCP, @@ -71,6 +81,7 @@ const DetailedContinuousChartCard: FC = ({ const effectiveWithZoomPicker = withZoomPicker ?? true; const isConsumerView = isConsumerViewProp ?? !user; const [isChartReady, setIsChartReady] = useState(false); + const [activeView, setActiveView] = useState("timeline"); const aggregation = question.aggregations[question.default_aggregation_method]; @@ -212,8 +223,35 @@ const DetailedContinuousChartCard: FC = ({ const isBinary = question.type === QuestionType.Binary; - const timelineTitle = - !isEmbed && !hideTitle ? t("forecastTimelineHeading") : undefined; + const chartViewButtons: GroupButton[] = [ + { value: "timeline", label: t("timeline") }, + { value: "histogram", label: t("histogram") }, + ]; + + const viewToggle = isBinary ? ( +
+ {chartViewButtons.map(({ value, label }) => ( + + ))} +
+ ) : null; + + // 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; @@ -276,6 +314,67 @@ 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,6 +393,10 @@ const DetailedContinuousChartCard: FC = ({ ); } + if (isBinary && activeView === "histogram") { + return <>{renderHistogram()}; + } + return (
() => { return; } + onResize(); + const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(ref.current); From 624b3500fb260cd97c785fbac0822b3178696b29 Mon Sep 17 00:00:00 2001 From: Nikita <93587872+ncarazon@users.noreply.github.com> Date: Mon, 11 May 2026 17:32:36 +0300 Subject: [PATCH 2/6] Live cursor updates the side-by-side left card (#4694) * feat: live cursor updates left-panel value, range, and distribution for continuous consumer view, with dot indicator on timeline and no tooltip/dashed line in consumer mode * feat: animate consumer mobile mini chart on timeline cursor and fix mobile tooltip position tracking * fix: always serialize full forecast_values in aggregation history for cursor-time distribution animation * fix: memoize cursorChartData to skip cdfToPmf recalculation when forecast_values reference is unchanged * fix: resolve cursor dot color override through resolveToCssColor * refactor: lazily fetch full aggregation history from aggregation explorer on pointer enter for continuous consumer cursor animation, keeping posts endpoint lightweight * fix: pass include_bots_in_aggregates to aggregation explorer to match stored aggregation bot semantics, format aggregate_forecasts.py * fix: use stable setActiveForecast reference in effect deps to prevent infinite re-render loop on hover --- .../components/question_page_shell/index.tsx | 16 ++-- .../continuous_question_prediction.tsx | 31 ++++++- .../question_header_cp_status.tsx | 45 +++++++++- .../src/components/charts/numeric_chart.tsx | 82 ++++++++++++++++--- .../components/charts/numeric_timeline.tsx | 3 + .../charts/primitives/line_cursor_points.tsx | 10 +-- .../consumer_continuous_tile.tsx | 24 ++++-- .../continuous_chart_card.tsx | 67 +++++++++++++-- .../hooks/use_full_aggregation.ts | 38 +++++++++ .../question_tile/continuous_cp_bar.tsx | 20 +++-- .../continuous_chart_cursor_context.tsx | 45 ++++++++++ front_end/src/hooks/use_chart_tooltip.ts | 6 +- 12 files changed, 336 insertions(+), 51 deletions(-) create mode 100644 front_end/src/components/detailed_question_card/detailed_question_card/hooks/use_full_aggregation.ts create mode 100644 front_end/src/contexts/continuous_chart_cursor_context.tsx 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 7da588d633..c8ac6ed462 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 @@ -9,6 +9,7 @@ import DetailedGroupCard from "@/components/detailed_question_card/detailed_grou import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; import ForecastMaker from "@/components/forecast_maker"; import CommunityDisclaimer from "@/components/post_card/community_disclaimer"; +import { ContinuousChartCursorProvider } from "@/contexts/continuous_chart_cursor_context"; import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import { GroupOfQuestionsGraphType, @@ -235,7 +236,7 @@ export const ConsumerShell: FC<{ = ({ + + {/* Bridges timeline cursor to the mobile mini chart in consumer view */} + + } forecaster={ = ({ chartHeight, }) => { const forecastAvailability = getQuestionForecastAvailability(question); + const cursorCtx = useContinuousChartCursor(); + const cursorForecast = cursorCtx?.activeForecast ?? null; // Hide chart if no forecasts or CP not yet revealed const shouldHideChart = @@ -27,12 +37,29 @@ 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]); + return (
{!shouldHideChart && ( <> @@ -40,7 +67,7 @@ const ContinuousQuestionPrediction: React.FC = ({
= ({ @@ -36,6 +46,7 @@ const QuestionHeaderCPStatus: FC = ({ hideLabel = false, colorOverride, chartTheme, + cursorForecast, }) => { const locale = useLocale(); const t = useTranslations(); @@ -58,6 +69,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,6 +131,10 @@ 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 && ( @@ -148,6 +179,12 @@ const QuestionHeaderCPStatus: FC = ({ size={size} variant="question" colorOverride={colorOverride} + overrideCenter={cursorCenter} + overrideBounds={ + cursorLower !== null && cursorUpper !== null + ? [cursorLower, cursorUpper] + : null + } /> )}
@@ -161,7 +198,7 @@ const QuestionHeaderCPStatus: FC = ({ > = ({ nonInteractive = false, isEmbedded = false, simplifiedCursor = false, + hideCursorValueLabel = false, forecastAvailability, questionStatus, resolution, cursorTooltip, + isConsumerView, questionType, newsAnnotations, showNewsAnnotations, @@ -142,6 +145,11 @@ const NumericChart: FC = ({ 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( @@ -252,13 +260,17 @@ const NumericChart: FC = ({ } }} cursorComponent={ - + isContinuousConsumerView ? ( + + ) : ( + + ) } cursorLabelComponent={ @@ -304,6 +316,10 @@ const NumericChart: FC = ({ onTouchStart: () => { setIsCursorActive(true); }, + onTouchEnd: () => { + setIsCursorActive(false); + handleCursorChange(null); + }, onMouseEnter: () => { setIsCursorActive(true); }, @@ -385,8 +401,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 +496,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,6 +590,8 @@ 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; }; const NumericTimeline: FC = ({ @@ -98,6 +99,7 @@ const NumericTimeline: FC = ({ keyFactors, showNewsAnnotations, onToggleNewsAnnotations, + hideCursorValueLabel, }) => { const locale = useLocale(); const resolutionPoint = useMemo(() => { @@ -235,6 +237,7 @@ const NumericTimeline: FC = ({ newsAnnotations={newsAnnotations} showNewsAnnotations={showNewsAnnotations} onToggleNewsAnnotations={onToggleNewsAnnotations} + hideCursorValueLabel={hideCursorValueLabel} /> ); }; 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/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_question_card/continuous_chart_card.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/continuous_chart_card.tsx index 739c3061ee..0b836a2d81 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 @@ -2,7 +2,14 @@ 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"; @@ -14,10 +21,12 @@ import ContinuousPredictionChart from "@/components/forecast_maker/continuous_in 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"; @@ -35,6 +44,8 @@ import { isContinuousQuestion, } from "@/utils/questions/helpers"; +import { useFullAggregation } from "./hooks/use_full_aggregation"; + const Histogram = dynamic(() => import("@/components/charts/histogram"), { ssr: false, }); @@ -80,11 +91,26 @@ 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 [shouldFetchFull, setShouldFetchFull] = useState(false); + const { data: enrichedAggregation = null } = useFullAggregation( + question.id, + question.default_aggregation_method, + question.include_bots_in_aggregates, + isContinuousConsumer && shouldFetchFull + ); + + const handlePointerEnter = useCallback(() => { + if (isContinuousConsumer) setShouldFetchFull(true); + }, [isContinuousConsumer]); + const aggregation = question.aggregations[question.default_aggregation_method]; + const effectiveAggregation = (enrichedAggregation ?? + aggregation) as typeof aggregation; const isCpHidden = !!forecastAvailability?.cpRevealsOn; const [cursorTimestamp, setCursorTimestamp] = useState(null); @@ -103,7 +129,7 @@ const DetailedContinuousChartCard: FC = ({ }; } - const forecast = getCursorForecast(cursorTimestamp, aggregation); + const forecast = getCursorForecast(cursorTimestamp, effectiveAggregation); let timestamp = cursorTimestamp; if ( timestamp === null && @@ -127,7 +153,7 @@ const DetailedContinuousChartCard: FC = ({ }, [ isCpHidden, cursorTimestamp, - aggregation, + effectiveAggregation, question.my_forecasts, nrForecasters, ]); @@ -188,6 +214,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); }, []); @@ -221,8 +270,6 @@ 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") }, @@ -278,10 +325,11 @@ const DetailedContinuousChartCard: FC = ({ unit={question.unit} inboundOutcomeCount={question.inbound_outcome_count} simplifiedCursor={false} + hideCursorValueLabel={isContinuousConsumer && !isEmbed} title={timelineTitle} forecastAvailability={forecastAvailability} cursorTooltip={cursorTooltip} - isConsumerView={isConsumerView} + isConsumerView={isContinuousConsumer} isEmbedded={isEmbed} height={chartHeight} extraTheme={extraTheme} @@ -306,6 +354,7 @@ const DetailedContinuousChartCard: FC = ({ hideLabel={isContinuousQuestion(question)} colorOverride={cpColorOverride} chartTheme={extraTheme} + cursorForecast={activeForecast} /> ); @@ -403,8 +452,9 @@ const DetailedContinuousChartCard: FC = ({ "flex w-full flex-col", isChartReady ? "opacity-100" : "opacity-0" )} + onPointerEnter={isContinuousConsumer ? handlePointerEnter : undefined} > - {!isConsumerView ? ( + {isContinuousQuestion(question) && !isEmbed ? ( <> {/* Desktop */}
@@ -413,6 +463,9 @@ const DetailedContinuousChartCard: FC = ({ question={question} size="lg" hideLabel={true} + cursorForecast={ + isContinuousConsumer ? activeForecast : undefined + } /> )} 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/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([ From c32cab0c48e8667d4e4fbf837d3a9976432de123 Mon Sep 17 00:00:00 2001 From: Nikita <93587872+ncarazon@users.noreply.github.com> Date: Mon, 11 May 2026 21:28:56 +0300 Subject: [PATCH 3/6] Empty / pre-CP-reveal audit for the new side-by-side row (#4700) * fix: handle hideCP, cpRevealsOn, and isEmpty in the side-by-side left card for binary and continuous questions * fix: handle isEmpty, cpRevealsOn, and hideCP states in the side-by-side layout left panel and fix title overflow onto the CP block * fix: refine mobile hidden-CP states for binary and continuous in the forecaster title row and left panel * fix: prevent CP status from being squeezed off-screen in the mobile title row * fix: restore "No forecasts yet" overlay in feed cards and suppress it on the question detail page * fix: wire hideCP, cpRevealsOn, and isEmpty states into all question-type left panels for the side-by-side layout * fix: replace hardcoded isEmpty:false with suppressEmptyOverlay prop, fix missing useMemo deps, and align binary cpRevealsOn state with continuous * fix: correct import order in continuous_question_prediction.tsx --- .../key_factors_question_consumer_section.tsx | 49 +++- .../multiple_choices_chart_view/index.tsx | 3 +- .../components/question_page_shell/index.tsx | 51 +++- .../question_page_shell/title_row.tsx | 83 +++++- .../continuous_question_prediction.tsx | 12 +- .../question_header_cp_status.tsx | 253 +++++++++++------- front_end/src/components/charts/fan_chart.tsx | 2 +- .../src/components/charts/group_chart.tsx | 4 +- .../charts/multiple_choice_chart.tsx | 25 +- .../src/components/charts/numeric_chart.tsx | 19 +- .../components/charts/numeric_timeline.tsx | 3 + .../consumer_post_card/binary_cp_bar.tsx | 2 +- .../detailed_group_card/index.tsx | 96 +++---- .../continuous_chart_card.tsx | 12 +- .../detailed_question_card/index.tsx | 3 - 15 files changed, 410 insertions(+), 207 deletions(-) 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 === @@ -153,6 +158,11 @@ export const ConsumerShell: FC<{ !isContinuousSingleQuestion && !isMultipleChoice; + const binaryForecastAvailability = + isBinarySingleQuestion && isQuestionPost(postData) + ? getQuestionForecastAvailability(postData.question) + : null; + const showSideBySide = isMultipleChoice || isNonFanGroup || @@ -170,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 (
@@ -217,19 +234,39 @@ export const ConsumerShell: FC<{ > {isBinarySingleQuestion && isQuestionPost(postData) ? (
- + {hideCP ? ( + + ) : binaryForecastAvailability?.cpRevealsOn ? ( + + ) : ( + + )}
) : (
- + {hideCP && !isContinuousSingleQuestion ? ( + + ) : ( + + )}
)} {!isFanGraph && !isDateGroup && ( 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..0433151a5d 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,16 @@ 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 { 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 +26,8 @@ type Props = { }; const TitleRow: FC = ({ post, variant, className }) => { + const { hideCP } = useHideCP(); + if (isConditionalPost(post)) { return (
@@ -32,6 +37,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 9d8c06e4a1..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 @@ -2,6 +2,7 @@ import { useMemo } from "react"; +import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; import { ContinuousAreaGraphInput, getContinuousAreaChartData, @@ -9,6 +10,7 @@ import { 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"; @@ -27,8 +29,8 @@ const ContinuousQuestionPrediction: React.FC = ({ 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; @@ -53,6 +55,14 @@ const ContinuousQuestionPrediction: React.FC = ({ ]; }, [cursorForecastValues, question.status]); + if (hideCP) { + return ( +
+ +
+ ); + } + return (
= ({ 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 ) : ( = ({ }, ]} 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 c494e5c0e1..c987d37ca2 100644 --- a/front_end/src/components/charts/numeric_chart.tsx +++ b/front_end/src/components/charts/numeric_chart.tsx @@ -102,6 +102,7 @@ type Props = { showNewsAnnotations?: boolean; onToggleNewsAnnotations?: () => void; animate?: object; + suppressEmptyOverlay?: boolean; }; const BOTTOM_PADDING = 20; @@ -139,6 +140,7 @@ const NumericChart: FC = ({ showNewsAnnotations, onToggleNewsAnnotations, animate, + suppressEmptyOverlay = false, }) => { const { theme, getThemeColor } = useAppTheme(); const [isChartReady, setIsChartReady] = useState(false); @@ -229,7 +231,12 @@ const NumericChart: FC = ({ }, [rightPadding, MIN_RIGHT_PADDING]); const containerComponent = useMemo(() => { - if (nonInteractive) { + if ( + nonInteractive || + forecastAvailability?.isEmpty || + hideCP || + !!forecastAvailability?.cpRevealsOn + ) { return ( = ({ handleCursorChange, nonInteractive, isCursorActive, + forecastAvailability, + hideCP, + isContinuousConsumerView, ]); const chartEvents = useMemo(() => { @@ -594,7 +604,12 @@ const NumericChart: FC = ({ onTouchEnd={handleTouchEnd} > void; hideCursorValueLabel?: boolean; + suppressEmptyOverlay?: boolean; }; const NumericTimeline: FC = ({ @@ -100,6 +101,7 @@ const NumericTimeline: FC = ({ showNewsAnnotations, onToggleNewsAnnotations, hideCursorValueLabel, + suppressEmptyOverlay, }) => { const locale = useLocale(); const resolutionPoint = useMemo(() => { @@ -238,6 +240,7 @@ const NumericTimeline: FC = ({ showNewsAnnotations={showNewsAnnotations} onToggleNewsAnnotations={onToggleNewsAnnotations} hideCursorValueLabel={hideCursorValueLabel} + suppressEmptyOverlay={suppressEmptyOverlay} /> ); }; 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/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 0b836a2d81..f61c086fa3 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 @@ -14,7 +14,6 @@ import { VictoryThemeDefinition } from "victory"; import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status"; -import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; 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"; @@ -161,10 +160,6 @@ const DetailedContinuousChartCard: FC = ({ const discreteValueOptions = getDiscreteValueOptions(question); const cpCursorElement = useMemo(() => { - if (forecastAvailability?.isEmpty) { - return t("noForecastsYet"); - } - if (hideCP) { return "..."; } @@ -328,7 +323,8 @@ const DetailedContinuousChartCard: FC = ({ hideCursorValueLabel={isContinuousConsumer && !isEmbed} title={timelineTitle} forecastAvailability={forecastAvailability} - cursorTooltip={cursorTooltip} + suppressEmptyOverlay + cursorTooltip={forecastAvailability?.isEmpty ? undefined : cursorTooltip} isConsumerView={isContinuousConsumer} isEmbedded={isEmbed} height={chartHeight} @@ -409,9 +405,7 @@ const DetailedContinuousChartCard: FC = ({ className="flex w-full flex-col justify-center" style={{ height: chartHeight }} > - {hideCP || isCpHidden ? ( - - ) : ( + {!hideCP && !isCpHidden && ( = ({ embedChartType={embedChartType} keyFactors={keyFactors} /> - {hideCP && } ); case QuestionType.MultipleChoice: @@ -102,7 +100,6 @@ const DetailedQuestionCard: FC = ({ chartTheme={chartTheme} defaultZoom={defaultZoom} /> - {hideCP && } ); default: From 7771a5f8184e77bacadcbfec19e26aedf643a91d Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 12 May 2026 12:54:10 +0300 Subject: [PATCH 4/6] feat: enable live cursor sync on continuous questions for forecaster view --- .../continuous_chart_card.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 f61c086fa3..0017b5d807 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 @@ -94,17 +94,18 @@ const DetailedContinuousChartCard: FC = ({ 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, - isContinuousConsumer && shouldFetchFull + isContinuous && shouldFetchFull ); const handlePointerEnter = useCallback(() => { - if (isContinuousConsumer) setShouldFetchFull(true); - }, [isContinuousConsumer]); + if (isContinuous) setShouldFetchFull(true); + }, [isContinuous]); const aggregation = question.aggregations[question.default_aggregation_method]; @@ -446,7 +447,7 @@ const DetailedContinuousChartCard: FC = ({ "flex w-full flex-col", isChartReady ? "opacity-100" : "opacity-0" )} - onPointerEnter={isContinuousConsumer ? handlePointerEnter : undefined} + onPointerEnter={isContinuous ? handlePointerEnter : undefined} > {isContinuousQuestion(question) && !isEmbed ? ( <> @@ -457,9 +458,7 @@ const DetailedContinuousChartCard: FC = ({ question={question} size="lg" hideLabel={true} - cursorForecast={ - isContinuousConsumer ? activeForecast : undefined - } + cursorForecast={activeForecast} /> )} From d97ed5f77efb1617ab9567e451302dc112d384d7 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 12 May 2026 12:54:15 +0300 Subject: [PATCH 5/6] feat: bridge timeline cursor to forecaster mobile CP status chip --- .../[id]/components/question_page_shell/index.tsx | 12 +++++++----- .../components/question_page_shell/title_row.tsx | 4 ++++ 2 files changed, 11 insertions(+), 5 deletions(-) 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 87a2cd1977..7df8d1f510 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 @@ -328,11 +328,13 @@ const QuestionPageShell: FC = ({ } 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 0433151a5d..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 @@ -6,6 +6,7 @@ import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/quest 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 { QuestionType, QuestionWithForecasts } from "@/types/question"; @@ -27,6 +28,8 @@ type Props = { const TitleRow: FC = ({ post, variant, className }) => { const { hideCP } = useHideCP(); + const cursorCtx = useContinuousChartCursor(); + const cursorForecast = cursorCtx?.activeForecast ?? null; if (isConditionalPost(post)) { return ( @@ -65,6 +68,7 @@ const TitleRow: FC = ({ post, variant, className }) => { question={post.question as QuestionWithForecasts} size="md" hideLabel={isContinuous} + cursorForecast={isContinuous ? cursorForecast : undefined} /> )}
From df30316ab7a66f982401adcb4c7449d613b007d8 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 12 May 2026 12:54:19 +0300 Subject: [PATCH 6/6] fix: hide cursor tooltip for continuous questions in forecaster view --- .../detailed_question_card/continuous_chart_card.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0017b5d807..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 @@ -325,7 +325,11 @@ const DetailedContinuousChartCard: FC = ({ title={timelineTitle} forecastAvailability={forecastAvailability} suppressEmptyOverlay - cursorTooltip={forecastAvailability?.isEmpty ? undefined : cursorTooltip} + cursorTooltip={ + forecastAvailability?.isEmpty || isContinuous + ? undefined + : cursorTooltip + } isConsumerView={isContinuousConsumer} isEmbedded={isEmbed} height={chartHeight}