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..3bb54698f0 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"; @@ -80,6 +89,7 @@ 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"); @@ -188,6 +198,28 @@ const DetailedContinuousChartCard: FC = ({ discreteValueOptions, ]); + const isBinary = question.type === QuestionType.Binary; + + const activeForecast = useMemo(() => { + if ( + isCpHidden || + cursorTimestamp === null || + !isContinuousQuestion(question) + ) + return null; + return getCursorForecast( + cursorTimestamp, + aggregation + ) as NumericAggregateForecast | null; + }, [isCpHidden, cursorTimestamp, aggregation, question]); + + const cursorCtx = useContinuousChartCursor(); + useEffect(() => { + if (!isContinuousQuestion(question) || !cursorCtx) return; + cursorCtx.setActiveForecast(activeForecast); + return () => cursorCtx.setActiveForecast(null); + }, [activeForecast, cursorCtx, question]); + const handleCursorChange = useCallback((value: number | null) => { setCursorTimestamp(value); }, []); @@ -221,8 +253,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 +308,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 +337,7 @@ const DetailedContinuousChartCard: FC = ({ hideLabel={isContinuousQuestion(question)} colorOverride={cpColorOverride} chartTheme={extraTheme} + cursorForecast={activeForecast} /> ); @@ -404,7 +436,7 @@ const DetailedContinuousChartCard: FC = ({ isChartReady ? "opacity-100" : "opacity-0" )} > - {!isConsumerView ? ( + {isContinuousQuestion(question) && !isEmbed ? ( <> {/* Desktop */}
@@ -413,6 +445,9 @@ const DetailedContinuousChartCard: FC = ({ question={question} size="lg" hideLabel={true} + cursorForecast={ + isContinuousConsumer ? activeForecast : undefined + } /> )} 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/questions/serializers/aggregate_forecasts.py b/questions/serializers/aggregate_forecasts.py index 14b950f8e5..e4e12d625b 100644 --- a/questions/serializers/aggregate_forecasts.py +++ b/questions/serializers/aggregate_forecasts.py @@ -128,9 +128,7 @@ def serialize_question_aggregations( for method, forecasts in aggregate_forecasts_by_method.items(): serialized_data[method]["history"] = [ - serialize_aggregate_forecast( - forecast, question.type, full=full_forecast_values - ) + serialize_aggregate_forecast(forecast, question.type, full=True) for forecast in forecasts ] serialized_data[method]["latest"] = (