diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx index ef33407427..c8dc69c65b 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx @@ -23,6 +23,7 @@ import QuestionHeaderInfo from "../components/question_header_info"; import QuestionResolutionStatus from "../components/question_resolution_status"; import Sidebar from "../components/sidebar"; import { SLUG_POST_SUB_QUESTION_ID } from "../search_params"; +import ContinuousGroupTimeline from "../components/forecast_timeline_drawer"; type Props = { params: { id: number; slug: string[] }; @@ -147,6 +148,11 @@ export default async function IndividualQuestion({ } /> )} + + {!!postData.group_of_questions && ( + + )} + = Math.min(...timestamps) && + cursorTimestamp <= Math.max(...timestamps); + if (!hasValue) { + return "?"; + } + + const closestTimestamp = findPreviousTimestamp(timestamps, cursorTimestamp); + const cursorIndex = timestamps.findIndex( + (timestamp) => timestamp === closestTimestamp + ); + + return getDisplayValue(values[cursorIndex], question); +} + +function generateList( + questions: QuestionWithNumericForecasts[], + preselectedQuestionId?: number +) { + return generateChoiceItemsFromBinaryGroup(questions, { + withMinMax: true, + activeCount: MAX_VISIBLE_CHECKBOXES, + preselectedQuestionId, + }); +} + +type Props = { + questions: QuestionWithNumericForecasts[]; + timestamps: number[]; +}; + +const ContinuousGroupTimeline: FC = ({ questions, timestamps }) => { + const t = useTranslations(); + const { user } = useAuth(); + const [isChartReady, setIsChartReady] = useState(false); + const handleChartReady = useCallback(() => { + setIsChartReady(true); + }, []); + + const [choiceItems, setChoiceItems] = useState( + generateList(questions) + ); + const userForecasts = user ? generateUserForecasts(questions) : undefined; + const timestampsCount = timestamps.length; + const prevTimestampsCount = usePrevious(timestampsCount); + // sync BE driven data with local state + useEffect(() => { + if (prevTimestampsCount && prevTimestampsCount !== timestampsCount) { + setChoiceItems(generateList(questions)); + } + }, [questions, prevTimestampsCount, timestampsCount]); + + const [cursorTimestamp, tooltipDate, handleCursorChange] = + useTimestampCursor(timestamps); + + const tooltipChoices = useMemo( + () => + choiceItems + .filter(({ active }) => active) + .map( + ({ choice, values, color, timestamps: optionTimestamps }, index) => { + return { + choiceLabel: choice, + color, + valueLabel: getQuestionTooltipLabel( + optionTimestamps ?? timestamps, + values, + cursorTimestamp, + questions[index] + ), + }; + } + ), + [choiceItems, cursorTimestamp, timestamps] + ); + + const { + isActive: isTooltipActive, + getReferenceProps, + getFloatingProps, + refs, + floatingStyles, + } = useChartTooltip(); + + const handleChoiceChange = useCallback((choice: string, checked: boolean) => { + setChoiceItems((prev) => + prev.map((item) => + item.choice === choice + ? { ...item, active: checked, highlighted: false } + : item + ) + ); + }, []); + const handleChoiceHighlight = useCallback( + (choice: string, highlighted: boolean) => { + setChoiceItems((prev) => + prev.map((item) => + item.choice === choice ? { ...item, highlighted } : item + ) + ); + }, + [] + ); + const toggleSelectAll = useCallback((isAllSelected: boolean) => { + if (isAllSelected) { + setChoiceItems((prev) => + prev.map((item) => ({ ...item, active: false, highlighted: false })) + ); + } else { + setChoiceItems((prev) => prev.map((item) => ({ ...item, active: true }))); + } + }, []); + + return ( +
+
+

+ {t("forecastTimelineHeading")} +

+
+
+ +
+ +
+ +
+ + {isTooltipActive && !!tooltipChoices.length && ( +
+ +
+ )} +
+ ); +}; + +export default ContinuousGroupTimeline; diff --git a/front_end/src/app/(main)/questions/[id]/components/forecast_timeline_drawer.tsx b/front_end/src/app/(main)/questions/[id]/components/forecast_timeline_drawer.tsx new file mode 100644 index 0000000000..c2a5e50882 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/forecast_timeline_drawer.tsx @@ -0,0 +1,38 @@ +import { FC } from "react"; + +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; +import { PostWithForecasts } from "@/types/post"; +import { getGroupQuestionsTimestamps } from "@/utils/charts"; +import { sortGroupPredictionOptions } from "@/utils/questions"; +import ContinuousGroupTimeline from "./continuous_group_timeline"; + +type Props = { + post: PostWithForecasts; +}; + +const ForecastTimelineDrawer: FC = ({ post }) => { + const questions = post.group_of_questions + ?.questions as QuestionWithNumericForecasts[]; + const groupType = questions?.at(0)?.type; + + if ( + !groupType || + ![QuestionType.Numeric, QuestionType.Date].includes(groupType) + ) { + return null; + } + + const sortedQuestions = sortGroupPredictionOptions( + questions as QuestionWithNumericForecasts[] + ); + const timestamps = getGroupQuestionsTimestamps(sortedQuestions); + + return ( + + ); +}; + +export default ForecastTimelineDrawer; diff --git a/front_end/src/components/charts/multiple_choice_chart.tsx b/front_end/src/components/charts/multiple_choice_chart.tsx index d3facfed22..c334926453 100644 --- a/front_end/src/components/charts/multiple_choice_chart.tsx +++ b/front_end/src/components/charts/multiple_choice_chart.tsx @@ -35,13 +35,16 @@ import { findPreviousTimestamp, generateNumericDomain, generatePercentageYScale, + generateTicksY, generateTimestampXScale, + getDisplayValue, } from "@/utils/charts"; import ChartContainer from "./primitives/chart_container"; import ChartCursorLabel from "./primitives/chart_cursor_label"; import XTickLabel from "./primitives/x_tick_label"; import { useTranslations } from "next-intl"; +import { QuestionType, Scaling } from "@/types/question"; type Props = { timestamps: number[]; @@ -54,6 +57,8 @@ type Props = { onChartReady?: () => void; extraTheme?: VictoryThemeDefinition; userForecasts?: UserChoiceItem[]; + questionType?: QuestionType; + scaling?: Scaling; }; const MultipleChoiceChart: FC = ({ @@ -67,6 +72,8 @@ const MultipleChoiceChart: FC = ({ onChartReady, extraTheme, userForecasts, + questionType, + scaling, }) => { const t = useTranslations(); const { @@ -93,6 +100,8 @@ const MultipleChoiceChart: FC = ({ width: chartWidth, height: chartHeight, zoom, + questionType, + scaling, }), [timestamps, choiceItems, chartWidth, chartHeight, zoom] ); @@ -135,12 +144,9 @@ const MultipleChoiceChart: FC = ({ cursorLabelComponent={} onCursorChange={(value: CursorCoordinatesPropType) => { if (typeof value === "number" && onCursorChange) { - const lastTimestamp = timestamps[timestamps.length - 1] + const lastTimestamp = timestamps[timestamps.length - 1]; if (value === lastTimestamp) { - onCursorChange( - lastTimestamp, - xScale.tickFormat - ); + onCursorChange(lastTimestamp, xScale.tickFormat); return; } @@ -304,12 +310,16 @@ function buildChartData({ choiceItems, timestamps, zoom, + questionType, + scaling, }: { timestamps: number[]; choiceItems: ChoiceItem[]; width: number; height: number; zoom: TimelineChartZoomOption; + questionType?: QuestionType; + scaling?: Scaling; }): ChartData { const xDomain = generateNumericDomain(timestamps, zoom); @@ -369,10 +379,30 @@ function buildChartData({ return item; } ); + let yScale = generatePercentageYScale(height); + if (!!scaling && !!questionType) { + console.log(choiceItems); + const minChoiceRange = choiceItems[0].rangeMin ?? 0; + const maxChoiceRange = choiceItems[0].rangeMax ?? 1; + console.log(minChoiceRange); + console.log(maxChoiceRange); + const { ticks, majorTicks } = generateTicksY( + height, + [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], + 20 + ); + const tickFormat = (value: number): string => { + if (!majorTicks.includes(value)) { + return ""; + } + return getDisplayValue(value, questionType!, scaling!); + }; + yScale = { ticks, tickFormat }; + } return { xScale: generateTimestampXScale(xDomain, width), - yScale: generatePercentageYScale(height), + yScale, graphs: graphs, xDomain, }; diff --git a/front_end/src/components/charts/numeric_chart.tsx b/front_end/src/components/charts/numeric_chart.tsx index 3e24f165d8..9d27be1516 100644 --- a/front_end/src/components/charts/numeric_chart.tsx +++ b/front_end/src/components/charts/numeric_chart.tsx @@ -42,6 +42,7 @@ import { } from "@/types/question"; import { generateNumericDomain, + generateTicksY, generateTimestampXScale, getDisplayValue, unscaleNominalLocation, @@ -343,27 +344,13 @@ function buildChartData({ const yDomain: Tuple = [0, 1]; const desiredMajorTicks = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]; - const minorTicksPerMajor = 9; const desiredMajorTickDistance = 20; - const maxMajorTicks = Math.floor(height / desiredMajorTickDistance); - - let majorTicks = desiredMajorTicks; - if (maxMajorTicks < desiredMajorTicks.length) { - // adjust major ticks on small height - const step = 1 / (maxMajorTicks - 1); - majorTicks = Array.from({ length: maxMajorTicks }, (_, i) => i * step); - } - const ticks = []; - for (let i = 0; i < majorTicks.length - 1; i++) { - ticks.push(majorTicks[i]); - const step = (majorTicks[i + 1] - majorTicks[i]) / (minorTicksPerMajor + 1); - for (let j = 1; j <= minorTicksPerMajor; j++) { - ticks.push(majorTicks[i] + step * j); - } - } - ticks.push(majorTicks[majorTicks.length - 1]); - + const { ticks, majorTicks } = generateTicksY( + height, + desiredMajorTicks, + desiredMajorTickDistance + ); const tickFormat = (value: number): string => { if (!majorTicks.includes(value)) { return ""; diff --git a/front_end/src/utils/charts.ts b/front_end/src/utils/charts.ts index 7721f63d3e..3049ab8f33 100644 --- a/front_end/src/utils/charts.ts +++ b/front_end/src/utils/charts.ts @@ -410,18 +410,22 @@ export function generateChoiceItemsFromBinaryGroup( choice: label, values: history.map((forecast) => forecast.centers![0]), minValues: history.map( - (forecast) => forecast.interval_lower_bounds![order] + (forecast) => + forecast.interval_lower_bounds![order] ?? + forecast.interval_lower_bounds![0] ), maxValues: history.map( - (forecast) => forecast.interval_upper_bounds![order] + (forecast) => + forecast.interval_upper_bounds![order] ?? + forecast.interval_upper_bounds![0] ), timestamps: history.map((forecast) => forecast.start_time), color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"], active: !!activeCount ? index <= activeCount - 1 : true, highlighted: false, resolution: question.resolution, - rangeMin: 0, - rangeMax: 1, + rangeMin: question.scaling.range_min ?? 0, + rangeMax: question.scaling.range_min ?? 1, }; }); } @@ -489,9 +493,9 @@ export const interpolateYValue = (xValue: number, line: Line) => { return p1.y + t * (p2.y - p1.y); }; -export function generateTicksY(height: number, desiredMajorTicks: number[]) { +export function generateTicksY(height: number, desiredMajorTicks: number[], majorTickDistance?: number) { const minorTicksPerMajor = 9; - const desiredMajorTickDistance = 50; + const desiredMajorTickDistance = majorTickDistance ?? 50; let majorTicks = desiredMajorTicks; const maxMajorTicks = Math.floor(height / desiredMajorTickDistance); @@ -514,5 +518,5 @@ export function generateTicksY(height: number, desiredMajorTicks: number[]) { } return value.toString(); }; - return { ticks, tickFormat }; + return { ticks, tickFormat, majorTicks }; }