diff --git a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx index bdf4d40f92..5577b5ff8a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx @@ -52,11 +52,14 @@ type Props = QuestionsDataProps & { embedMode?: boolean; withLegend?: boolean; className?: string; + externalHighlightedChoice?: string | null; prioritizeOpen?: boolean; timelineMarkers?: GroupTimelineMarker[]; activeTimelineMarkerId?: string | null; onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void; onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void; + withHighlightArea?: boolean; + withHighlightEndpoint?: boolean; }; /** @@ -86,6 +89,9 @@ const GroupTimeline: FC = ({ activeTimelineMarkerId, onTimelineMarkerEnter, onTimelineMarkerLeave, + externalHighlightedChoice, + withHighlightArea, + withHighlightEndpoint, }) => { const t = useTranslations(); const { user } = useAuth(); @@ -168,6 +174,17 @@ const GroupTimeline: FC = ({ setChoiceItems(generateList(questions, group, preselectedQuestionId)); }, [questions, preselectedQuestionId, generateList, group]); + // apply external highlight from parent (e.g. consumer row hover) + useEffect(() => { + if (externalHighlightedChoice === undefined) return; + setChoiceItems((prev) => + prev.map((item) => ({ + ...item, + highlighted: item.choice === externalHighlightedChoice, + })) + ); + }, [externalHighlightedChoice]); + const [cursorTimestamp, _tooltipDate, handleCursorChange] = useTimestampCursor(timestamps); const tooltipChoices = useMemo(() => { @@ -335,6 +352,8 @@ const GroupTimeline: FC = ({ activeTimelineMarkerId={activeTimelineMarkerId} onTimelineMarkerEnter={onTimelineMarkerEnter} onTimelineMarkerLeave={onTimelineMarkerLeave} + withHighlightArea={withHighlightArea} + withHighlightEndpoint={withHighlightEndpoint} /> ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx index 336da2af11..46291776c9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx @@ -49,6 +49,8 @@ type Props = { activeTimelineMarkerId?: string | null; onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void; onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void; + withHighlightArea?: boolean; + withHighlightEndpoint?: boolean; }; const MultiChoicesChartView: FC = ({ @@ -82,6 +84,8 @@ const MultiChoicesChartView: FC = ({ activeTimelineMarkerId, onTimelineMarkerEnter, onTimelineMarkerLeave, + withHighlightArea = true, + withHighlightEndpoint = false, }) => { const { user } = useAuth(); const isInteracted = useRef(false); @@ -243,6 +247,8 @@ const MultiChoicesChartView: FC = ({ forceAutoZoom: isInteracted.current, forecastAvailability, attachRef, + withHighlightArea, + withHighlightEndpoint, } as const; return ( 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 7df8d1f510..f026476578 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 @@ -5,6 +5,9 @@ import { FC, Fragment, ReactNode, useEffect } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; +import NumericForecastCard from "@/components/consumer_post_card/group_forecast_card/numeric_forecast_card"; +import PercentageForecastCard from "@/components/consumer_post_card/group_forecast_card/percentage_forecast_card"; +import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import UpcomingCP from "@/components/consumer_post_card/upcoming_cp"; import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; @@ -15,14 +18,15 @@ import { useHideCP } from "@/contexts/cp_context"; import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import { GroupOfQuestionsGraphType, + GroupOfQuestionsPost, PostStatus, PostWithForecasts, QuestionStatus, } from "@/types/post"; import { TournamentType } from "@/types/projects"; -import { QuestionType } from "@/types/question"; -import cn from "@/utils/core/cn"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; +import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import { checkGroupOfQuestionsPostType, isContinuousQuestion, @@ -40,15 +44,17 @@ import PostScoreData from "../post_score_data"; import { QuestionLayoutProvider } from "../question_layout/question_layout_context"; import { QuestionVariantComposer } from "../question_variant_composer"; import ActionRow from "../question_view/action_row"; +import ConsumerGroupChart from "../question_view/consumer_question_view/consumer_group_chart"; +import ConsumerListChartShell from "../question_view/consumer_question_view/consumer_list_chart_shell"; 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"; import RevealCPButton from "../reveal_cp_button"; 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"; + "relative 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"; -const mainSectionClassName = `${baseSectionClassName} bg-gray-0 dark:bg-gray-0-dark`; +const mainSectionClassName = `${baseSectionClassName} z-10 bg-gray-0 dark:bg-gray-0-dark`; const commentSectionClassName = `${baseSectionClassName} bg-blue-100 dark:bg-gray-0-dark`; type ShellProps = { @@ -147,9 +153,6 @@ export const ConsumerShell: FC<{ const isMultipleChoice = isMultipleChoicePost(postData); const isNonFanGroup = isGroupOfQuestionsPost(postData) && !isFanGraph; - const reverseOrder = - (isMultipleChoice || isGroupOfQuestionsPost(postData)) && !isDateGroup; - const isContinuousSingleQuestion = isQuestionPost(postData) && isContinuousQuestion(postData.question); @@ -158,17 +161,21 @@ export const ConsumerShell: FC<{ !isContinuousSingleQuestion && !isMultipleChoice; + const isNRowBody = + isMultipleChoice || (isNonFanGroup && !isDateGroup) || isFanGraph; + + const isContinuousNumericGroup = + isNonFanGroup && + !isDateGroup && + !isMultipleChoice && + (checkGroupOfQuestionsPostType(postData, QuestionType.Numeric) || + checkGroupOfQuestionsPostType(postData, QuestionType.Discrete)); + const binaryForecastAvailability = isBinarySingleQuestion && isQuestionPost(postData) ? getQuestionForecastAvailability(postData.question) : null; - const showSideBySide = - isMultipleChoice || - isNonFanGroup || - isBinarySingleQuestion || - isContinuousSingleQuestion; - const showClosedMessageMultipleChoice = isMultipleChoicePost(postData) && postData.question.status === QuestionStatus.CLOSED; @@ -176,6 +183,14 @@ export const ConsumerShell: FC<{ const showClosedMessageFanGraph = isFanGraph && postData.status === PostStatus.CLOSED; + const fanGraphQuestions = isFanGraph + ? sortGroupPredictionOptions( + (postData.group_of_questions?.questions ?? + []) as QuestionWithNumericForecasts[], + postData.group_of_questions + ) + : null; + const questionLinkAggregates = aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? []; const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0; @@ -218,21 +233,8 @@ export const ConsumerShell: FC<{ {t("predictionClosedMessage")}

)} -
- {isBinarySingleQuestion && isQuestionPost(postData) ? ( + {isBinarySingleQuestion && isQuestionPost(postData) ? ( +
{hideCP ? ( @@ -247,47 +249,85 @@ export const ConsumerShell: FC<{ /> )}
- ) : ( -
- {hideCP && !isContinuousSingleQuestion ? ( - - ) : ( - - )} + +
+ ) : isContinuousSingleQuestion ? ( +
+
+
- )} - {!isFanGraph && !isDateGroup && ( - )} - {showClosedMessageFanGraph && ( -

- {t("predictionClosedMessage")} -

- )} -
+
+ ) : isNRowBody ? ( + <> + + ) : isFanGraph && fanGraphQuestions ? ( + + ) : isContinuousNumericGroup ? ( + + ) : ( + + ) + } + chartContent={ + isFanGraph ? ( + + } + preselectedQuestionId={preselectedGroupQuestionId} + /> + ) : isContinuousNumericGroup ? ( + + } + preselectedQuestionId={preselectedGroupQuestionId} + /> + ) : ( + + ) + } + /> + {showClosedMessageFanGraph && ( +

+ {t("predictionClosedMessage")} +

+ )} + + ) : ( + + )}
{shouldShowKeyFactorsSection && (
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx new file mode 100644 index 0000000000..b02b4f9b7e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { FC } from "react"; + +import GroupTimeline from "@/app/(main)/questions/[id]/components/group_timeline"; +import { GroupOfQuestionsPost, PostStatus } from "@/types/post"; +import { QuestionWithNumericForecasts } from "@/types/question"; +import { getPostDrivenTime } from "@/utils/questions/helpers"; + +import { useListChartExpanded } from "./consumer_list_chart_shell"; + +type Props = { + post: GroupOfQuestionsPost; + preselectedQuestionId?: number; +}; + +const ConsumerGroupChart: FC = ({ post, preselectedQuestionId }) => { + const { hoveredChoiceName, setHoveredChoiceName } = useListChartExpanded(); + const { open_time, actual_close_time, scheduled_close_time, status } = post; + const refCloseTime = actual_close_time ?? scheduled_close_time; + + return ( +
setHoveredChoiceName(null)}> + +
+ ); +}; + +export default ConsumerGroupChart; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx new file mode 100644 index 0000000000..68c52ab4fc --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { createContext, ReactNode, useContext, useMemo, useState } from "react"; + +import cn from "@/utils/core/cn"; + +type ListChartExpandedContextType = { + setIsExpanded: (value: boolean) => void; + hoveredChoiceName: string | null; + setHoveredChoiceName: (name: string | null) => void; +}; + +const ListChartExpandedContext = createContext({ + setIsExpanded: () => {}, + hoveredChoiceName: null, + setHoveredChoiceName: () => {}, +}); + +export const useListChartExpanded = () => useContext(ListChartExpandedContext); + +type Props = { + listContent: ReactNode; + chartContent: ReactNode; + stretchListContent?: boolean; + className?: string; +}; + +const ConsumerListChartShell: React.FC = ({ + listContent, + chartContent, + stretchListContent = false, + className, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [hoveredChoiceName, setHoveredChoiceName] = useState( + null + ); + + // Memoize so isExpanded changes don't re-render context consumers (e.g. the chart). + const contextValue = useMemo( + () => ({ setIsExpanded, hoveredChoiceName, setHoveredChoiceName }), + [hoveredChoiceName, setHoveredChoiceName, setIsExpanded] + ); + + return ( + +
+
+ {listContent} +
+ +
+
+ ); +}; + +export default ConsumerListChartShell; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx index eca1efc3cd..c2d89805cc 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx @@ -4,6 +4,7 @@ import PercentageForecastCard from "@/components/consumer_post_card/group_foreca import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; import { getGroupForecastAvailability } from "@/utils/questions/forecastAvailability"; import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import { @@ -13,9 +14,13 @@ import { type Props = { postData: PostWithForecasts; + className?: string; }; -const GroupOfQuestionsPrediction: React.FC = ({ postData }) => { +const GroupOfQuestionsPrediction: React.FC = ({ + postData, + className, +}) => { let content: React.ReactNode | null = null; if ( @@ -72,7 +77,7 @@ const GroupOfQuestionsPrediction: React.FC = ({ postData }) => { ? "md:mb-7" : "md:mt-7"; - return
{content}
; + return
{content}
; }; export default GroupOfQuestionsPrediction; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx index a7c3d670c3..4b9f6cf4d5 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx @@ -14,9 +14,13 @@ import SingleQuestionPrediction from "./single_question_prediction"; type Props = { postData: PostWithForecasts; + className?: string; }; -const ConsumerQuestionPrediction: React.FC = ({ postData }) => { +const ConsumerQuestionPrediction: React.FC = ({ + postData, + className, +}) => { const { user } = useAuth(); if (isQuestionPost(postData) && !isMultipleChoicePost(postData)) { @@ -29,7 +33,9 @@ const ConsumerQuestionPrediction: React.FC = ({ postData }) => { } if (isMultipleChoicePost(postData) || isGroupOfQuestionsPost(postData)) { - return ; + return ( + + ); } return null; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx index af7e4f7fd1..bd28107aad 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx @@ -87,6 +87,7 @@ const QuestionTimeline: React.FC = ({ )}
diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx index f5a730f78d..8a990a77a9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx @@ -36,14 +36,19 @@ const SimilarPredictionChip: FC = ({ post, variant }) => { ) { return (
- +
); } if (isGroupOfQuestionsPost(post)) { return (
- +
); } diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index a00a87f399..0b11507936 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -90,6 +90,8 @@ type Props = { onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void; animate?: object; leftPadding?: number; + withHighlightArea?: boolean; + withHighlightEndpoint?: boolean; }; const LABEL_FONT_FAMILY = "Inter"; @@ -133,6 +135,8 @@ const GroupChart: FC = ({ onTimelineMarkerLeave, animate, leftPadding = 0, + withHighlightArea = true, + withHighlightEndpoint = false, }) => { const t = useTranslations(); const { @@ -377,10 +381,14 @@ const GroupChart: FC = ({ } }, onMouseLeave: () => { - if (!onCursorChange) return; inPlotRef.current = false; setIsCursorActive(false); setLocalCursorTimestamp(null); + // Reset to last timestamp so lines don't stay frozen at last hovered position. + const lastTs = timestamps.at(-1); + if (onCursorChange && !isNil(lastTs)) { + onCursorChange(lastTs, () => ""); + } }, }, }, @@ -509,13 +517,45 @@ const GroupChart: FC = ({ : highlighted ? 1 : 0.3, - strokeWidth: 1.5, + strokeWidth: isHighlightActive && highlighted ? 3 : 1.5, }, }} interpolation="stepAfter" /> ); })} + {/* Line endpoint dot */} + {withHighlightEndpoint && + graphs.map( + ({ color, active, line, highlighted, isClosed }, index) => { + if (!active) return null; + const filteredLine = filteredLines[index]; + if (!filteredLine) return null; + const point = { + x: isClosed + ? line?.at(-1)?.x ?? Number(xDomain[1]) + : Number(xDomain[1]), + y: line?.at(-1)?.y ?? 0, + }; + return ( + + ); + } + )} {/* Line cursor points */} {graphs.map( ({ color, active, line, highlighted, isClosed }, index) => { @@ -569,7 +609,7 @@ const GroupChart: FC = ({ style={{ data: { fill: getThemeColor(color), - opacity: highlighted ? 0.3 : 0, + opacity: withHighlightArea && highlighted ? 0.3 : 0, }, }} interpolation="stepAfter" diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx index 2154e2ca44..99397f4dc8 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx @@ -1,3 +1,9 @@ +import { + faChevronDown, + faChevronUp, + faEllipsis, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; import { FC, PropsWithChildren } from "react"; @@ -5,83 +11,83 @@ import cn from "@/utils/core/cn"; type Props = { otherItemsCount: number; - othersTotal?: number; expanded?: boolean; onExpand?: () => void; - hideOthersValue?: boolean; + onCollapse?: () => void; compact?: boolean; + buttonVariant?: "primary" | "minimal"; + className?: string; }; const ForecastCardWrapper: FC> = ({ otherItemsCount, - othersTotal = 0, expanded = false, onExpand, - hideOthersValue = false, + onCollapse, compact = false, + buttonVariant = "primary", + className, children, }) => { const t = useTranslations(); - const showRow = !expanded && otherItemsCount > 0; + const showExpandRow = !expanded && otherItemsCount > 0; + + const isMinimal = buttonVariant === "minimal"; + + const toggleButtonClassName = cn( + "flex w-full self-stretch items-center gap-2 rounded-lg px-[13px]", + "font-medium leading-4", + compact ? "h-6 md:h-8" : "h-8", + isMinimal + ? "border border-gray-300 text-xs text-gray-700 sm:text-sm dark:border-gray-300-dark dark:text-gray-700-dark" + : "bg-blue-500/20 text-sm text-blue-700 dark:bg-blue-500-dark/20 dark:text-blue-700-dark" + ); return (
{children} - {showRow && ( + {showExpandRow && + (isMinimal ? ( +
+ + {t("otherWithCount", { count: otherItemsCount })} +
+ ) : ( + + ))} + + {expanded && onCollapse && ( )}
diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx index 10dab1bcb0..f33590faf7 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx @@ -25,6 +25,9 @@ type Props = { unit?: string; forceColorful?: boolean; compact?: boolean; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + className?: string; }; const WIDTH_ADJUSTMENT = 2; @@ -37,10 +40,13 @@ const ForecastChoiceBar: FC = ({ displayedResolution, resolution, color, - isBordered = false, + isBordered = true, unit, forceColorful = false, compact = false, + onMouseEnter, + onMouseLeave, + className, }) => { const t = useTranslations(); const { getThemeColor } = useAppTheme(); @@ -50,13 +56,20 @@ const ForecastChoiceBar: FC = ({ const isResolutionSuccessful = isSuccessfullyResolved(resolution); return (
= ({
= ({ mounted ? getThemeColor(METAC_COLORS.gray["500"]) : METAC_COLORS.gray["500"].DEFAULT, - 0.3 + 0.4 ); } return addOpacityToHex( mounted ? getThemeColor(color) : color.DEFAULT, - 0.3 + 0.4 ); })(), borderColor: (() => { diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx index 47797ae3f9..b784bfed6d 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx @@ -17,9 +17,10 @@ import PercentageForecastCard from "./percentage_forecast_card"; type Props = { post: PostWithForecasts; compact?: boolean; + buttonVariant?: "primary" | "minimal"; }; -const GroupForecastCard: FC = ({ post, compact }) => { +const GroupForecastCard: FC = ({ post, compact, buttonVariant }) => { // Check forecast availability for group posts const forecastAvailability = post.group_of_questions ? getGroupForecastAvailability(post.group_of_questions.questions) @@ -47,20 +48,38 @@ const GroupForecastCard: FC = ({ post, compact }) => { isMultipleChoicePost(post) || checkGroupOfQuestionsPostType(post, QuestionType.Binary) ) { - return ; + return ( + + ); } if ( checkGroupOfQuestionsPostType(post, QuestionType.Numeric) || checkGroupOfQuestionsPostType(post, QuestionType.Discrete) ) { - return ; + return ( + + ); } if ( post.group_of_questions && checkGroupOfQuestionsPostType(post, QuestionType.Date) ) { if (compact) { - return ; + return ( + + ); } return ( diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx index 578bafd4c0..e073856a05 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx @@ -4,9 +4,11 @@ import { isNil } from "lodash"; import { useLocale, useTranslations } from "next-intl"; import { FC, useState } from "react"; +import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { QuestionType, Scaling } from "@/types/question"; +import cn from "@/utils/core/cn"; import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { scaleInternalLocation } from "@/utils/math"; import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices"; @@ -22,12 +24,21 @@ type Props = { post: PostWithForecasts; forceColorful?: boolean; compact?: boolean; + buttonVariant?: "primary" | "minimal"; + fillHeight?: boolean; }; -const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { +const NumericForecastCard: FC = ({ + post, + forceColorful, + compact, + buttonVariant, + fillHeight = false, +}) => { const locale = useLocale(); const t = useTranslations(); const [expanded, setExpanded] = useState(false); + const { setIsExpanded, setHoveredChoiceName } = useListChartExpanded(); if (!isGroupOfQuestionsPost(post)) { return null; @@ -58,10 +69,8 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { }); const isPostClosed = post.status === PostStatus.CLOSED; - - const visibleChoices = expanded - ? sortedChoices - : sortedChoices.slice(0, visibleChoicesCount); + const hiddenCount = Math.max(0, sortedChoices.length - visibleChoicesCount); + const collapsedChoices = sortedChoices.slice(0, visibleChoicesCount); const scaledValues = [...sortedChoices] .filter((choice) => isNil(choice.resolution)) @@ -78,18 +87,14 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { const maxScaledValue = Math.max(...scaledValues); const minScaledValue = Math.min(...scaledValues); - return ( - setExpanded(true)} - hideOthersValue - compact={compact} - > - {visibleChoices.map( - ({ + const renderBars = ( + choices: typeof sortedChoices, + stretchBars = false, + hoverUpTo = Infinity + ) => + choices.map( + ( + { closeTime, aggregationValues, scaling, @@ -100,57 +105,98 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { choice, actual_resolve_time, unit, - }) => { - const isChoiceClosed = closeTime ? closeTime < Date.now() : false; - const rawChoiceValue = - aggregationValues[aggregationValues.length - 1] ?? null; - const normalizedScaling: Scaling = { - range_min: scaling?.range_min ?? 0, - range_max: scaling?.range_max ?? 1, - zero_point: scaling?.zero_point ?? null, - }; - const formattedChoiceValue = getPredictionDisplayValue( - rawChoiceValue, - { - questionType: isDateGroup - ? QuestionType.Date - : QuestionType.Numeric, - scaling: normalizedScaling, - actual_resolve_time: actual_resolve_time ?? null, - emptyLabel: t("Upcoming"), + }, + index + ) => { + const isChoiceClosed = closeTime ? closeTime < Date.now() : false; + const rawChoiceValue = + aggregationValues[aggregationValues.length - 1] ?? null; + const normalizedScaling: Scaling = { + range_min: scaling?.range_min ?? 0, + range_max: scaling?.range_max ?? 1, + zero_point: scaling?.zero_point ?? null, + }; + const formattedChoiceValue = getPredictionDisplayValue(rawChoiceValue, { + questionType: isDateGroup ? QuestionType.Date : QuestionType.Numeric, + scaling: normalizedScaling, + actual_resolve_time: actual_resolve_time ?? null, + emptyLabel: t("Upcoming"), + }); + const scaledChoiceValue = !isNil(rawChoiceValue) + ? scaleInternalLocation(rawChoiceValue, normalizedScaling) + : NaN; + const relativeWidth = !isNil(resolution) + ? 100 + : calculateRelativeWidth({ + scaledChoiceValue, + maxScaledValue, + minScaledValue, + }); + + return ( + setHoveredChoiceName(choice) : undefined } - ); - - const scaledChoiceValue = !isNil(rawChoiceValue) - ? scaleInternalLocation(rawChoiceValue, normalizedScaling) - : NaN; - - const relativeWidth = !isNil(resolution) - ? 100 - : calculateRelativeWidth({ - scaledChoiceValue, - maxScaledValue, - minScaledValue, - }); - - return ( - - ); - } + onMouseLeave={ + index < hoverUpTo ? () => setHoveredChoiceName(null) : undefined + } + className={stretchBars ? "flex-1" : undefined} + /> + ); + } + ); + + // Only fill height when all items are visible (no expand button). + const effectiveFillHeight = fillHeight && hiddenCount === 0; + + return ( +
+ { + setExpanded(true); + setIsExpanded(true); + }} + compact={compact} + buttonVariant={buttonVariant} + className={effectiveFillHeight ? "flex-1" : undefined} + > + {renderBars(collapsedChoices, effectiveFillHeight)} + + + {expanded && ( +
+ { + setExpanded(false); + setIsExpanded(false); + }} + compact={compact} + buttonVariant={buttonVariant} + > + {renderBars(sortedChoices, false, visibleChoicesCount)} + +
)} - +
); }; diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx index 64891034d6..8167f58d42 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx @@ -3,9 +3,11 @@ import { useLocale, useTranslations } from "next-intl"; import { FC, useMemo, useState } from "react"; +import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { generateChoiceItemsFromGroupQuestions, @@ -23,23 +25,23 @@ type Props = { post: PostWithForecasts; forceColorful?: boolean; compact?: boolean; + buttonVariant?: "primary" | "minimal"; + fillHeight?: boolean; }; const PercentageForecastCard: FC = ({ post, forceColorful, compact, + buttonVariant, + fillHeight = false, }) => { const locale = useLocale(); const t = useTranslations(); const [expanded, setExpanded] = useState(false); + const { setIsExpanded } = useListChartExpanded(); const isMC = isMultipleChoicePost(post); - const isGroupBinary = - isGroupOfQuestionsPost(post) && - post.group_of_questions?.questions?.every( - (q) => q.type === QuestionType.Binary - ); const cpRevealTime = post.question?.cp_reveal_time; const emptyLabel = cpRevealTime && new Date(cpRevealTime).getTime() > Date.now() @@ -87,43 +89,66 @@ const PercentageForecastCard: FC = ({ const isPostClosed = post.status === PostStatus.CLOSED; - const visible = expanded - ? allChoices - : allChoices.slice(0, visibleChoicesCount); - const hidden = expanded ? [] : allChoices.slice(visibleChoicesCount); + const collapsedChoices = allChoices.slice(0, visibleChoicesCount); + const hiddenCount = allChoices.length - visibleChoicesCount; - const visibleSumMC = visible.reduce((s, c) => s + c.percent, 0); - const othersTotal = isMC - ? Math.max(0, Math.min(100, 100 - Math.round(visibleSumMC))) - : 0; + const renderBars = (choices: typeof allChoices, stretchBars = false) => + choices.map((choice) => ( + + )); + + // Only fill height when all items are visible (no expand button). + const effectiveFillHeight = fillHeight && hiddenCount === 0; return ( - setExpanded(true)} - hideOthersValue={isGroupBinary} - compact={compact} +
- {visible.map((choice) => ( - - ))} - + { + setExpanded(true); + setIsExpanded(true); + }} + compact={compact} + buttonVariant={buttonVariant} + className={effectiveFillHeight ? "flex-1" : undefined} + > + {renderBars(collapsedChoices, effectiveFillHeight)} + + {expanded && ( +
+ { + setExpanded(false); + setIsExpanded(false); + }} + compact={compact} + buttonVariant={buttonVariant} + > + {renderBars(allChoices)} + +
+ )} +
); }; + function generateChoiceItems( post: PostWithForecasts, visibleChoicesCount: number, @@ -150,4 +175,5 @@ function generateChoiceItems( } return []; } + export default PercentageForecastCard; diff --git a/front_end/src/components/consumer_post_card/index.tsx b/front_end/src/components/consumer_post_card/index.tsx index 33bcc0953a..1c48c25179 100644 --- a/front_end/src/components/consumer_post_card/index.tsx +++ b/front_end/src/components/consumer_post_card/index.tsx @@ -50,7 +50,9 @@ const ConsumerPostCard: FC = ({ )} {(isGroupOfQuestionsPost(post) || isMultipleChoicePost(post)) && ( - +
+ +
)} {[PostStatus.PENDING_RESOLUTION, PostStatus.CLOSED].includes( 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 d64adce9f1..b4aa05053c 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 @@ -44,6 +44,7 @@ type Props = { onLegendHeightChange?: (height: number) => void; chartTheme?: VictoryThemeDefinition; defaultZoom?: TimelineChartZoomOption; + withLegend?: boolean; }; const DetailedGroupCard: FC = ({ @@ -56,6 +57,7 @@ const DetailedGroupCard: FC = ({ onLegendHeightChange, chartTheme, defaultZoom, + withLegend, }) => { const { open_time, @@ -186,6 +188,7 @@ const DetailedGroupCard: FC = ({ chartHeight={embedChartHeight} chartTheme={chartTheme} defaultZoom={defaultZoom} + withLegend={withLegend} /> ); } 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 95c8353717..e56c3c7d14 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 @@ -99,6 +99,7 @@ const DetailedQuestionCard: FC = ({ onLegendHeightChange={onLegendHeightChange} chartTheme={chartTheme} defaultZoom={defaultZoom} + isConsumerView={isConsumerView} /> ); diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx index ff10c37c28..0df9789082 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx @@ -33,6 +33,7 @@ type Props = { forecastAvailability?: ForecastAvailability; onLegendHeightChange?: (height: number) => void; hideTitle?: boolean; + isConsumerView?: boolean; }; const DetailedMultipleChoiceChartCard: FC = ({ @@ -45,6 +46,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ forecastAvailability, onLegendHeightChange, hideTitle, + isConsumerView, }) => { const t = useTranslations(); const [isChartHovered, setIsChartHovered] = useState(false); @@ -308,6 +310,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ defaultZoom={defaultZoom} forecastAvailability={forecastAvailability} openTime={openTime} + withLegend={!isConsumerView} /> ); };