Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };
Expand Down Expand Up @@ -147,6 +148,11 @@ export default async function IndividualQuestion({
}
/>
)}

{!!postData.group_of_questions && (
<ContinuousGroupTimeline post={postData} />
)}

<BackgroundInfo post={postData} />
<HistogramDrawer post={postData} />
<Sidebar
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"use client";

import classNames from "classnames";
import { useTranslations } from "next-intl";
import React, { FC, useCallback, useEffect, useMemo, useState } from "react";

import MultipleChoiceChart from "@/components/charts/multiple_choice_chart";
import { useAuth } from "@/contexts/auth_context";
import useChartTooltip from "@/hooks/use_chart_tooltip";
import usePrevious from "@/hooks/use_previous";
import useTimestampCursor from "@/hooks/use_timestamp_cursor";
import { ChoiceItem, ChoiceTooltipItem } from "@/types/choices";
import { Question, QuestionWithNumericForecasts } from "@/types/question";
import {
findPreviousTimestamp,
generateChoiceItemsFromBinaryGroup,
getDisplayValue,
} from "@/utils/charts";
import { generateUserForecasts } from "@/utils/questions";
import ChoicesLegend from "./choices_legend";
import ChoicesTooltip from "./choices_tooltip";

const MAX_VISIBLE_CHECKBOXES = 6;

function getQuestionTooltipLabel(
timestamps: number[],
values: number[],
cursorTimestamp: number,
question: Question
) {
const hasValue =
cursorTimestamp >= 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<Props> = ({ questions, timestamps }) => {
const t = useTranslations();
const { user } = useAuth();
const [isChartReady, setIsChartReady] = useState(false);
const handleChartReady = useCallback(() => {
setIsChartReady(true);
}, []);

const [choiceItems, setChoiceItems] = useState<ChoiceItem[]>(
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<ChoiceTooltipItem[]>(
() =>
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 (
<div
className={classNames(
"flex w-full flex-col",
isChartReady ? "opacity-100" : "opacity-0"
)}
>
<div className="flex items-center">
<h3 className="m-0 text-base font-normal leading-5">
{t("forecastTimelineHeading")}
</h3>
</div>
<div ref={refs.setReference} {...getReferenceProps()}>
<MultipleChoiceChart
timestamps={timestamps}
choiceItems={choiceItems}
yLabel={t("communityPredictionLabel")}
onChartReady={handleChartReady}
onCursorChange={handleCursorChange}
userForecasts={userForecasts}
questionType={questions[0].type}
scaling={questions[0].scaling}
/>
</div>

<div className="mt-3">
<ChoicesLegend
choices={choiceItems}
onChoiceChange={handleChoiceChange}
onChoiceHighlight={handleChoiceHighlight}
maxLegendChoices={MAX_VISIBLE_CHECKBOXES}
onToggleAll={toggleSelectAll}
/>
</div>

{isTooltipActive && !!tooltipChoices.length && (
<div
className="pointer-events-none z-20 rounded bg-gray-0 p-2 leading-4 shadow-lg dark:bg-gray-0-dark"
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<ChoicesTooltip date={tooltipDate} choices={tooltipChoices} />
</div>
)}
</div>
);
};

export default ContinuousGroupTimeline;
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<ContinuousGroupTimeline
questions={sortedQuestions}
timestamps={timestamps}
/>
);
};

export default ForecastTimelineDrawer;
42 changes: 36 additions & 6 deletions front_end/src/components/charts/multiple_choice_chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -54,6 +57,8 @@ type Props = {
onChartReady?: () => void;
extraTheme?: VictoryThemeDefinition;
userForecasts?: UserChoiceItem[];
questionType?: QuestionType;
scaling?: Scaling;
};

const MultipleChoiceChart: FC<Props> = ({
Expand All @@ -67,6 +72,8 @@ const MultipleChoiceChart: FC<Props> = ({
onChartReady,
extraTheme,
userForecasts,
questionType,
scaling,
}) => {
const t = useTranslations();
const {
Expand All @@ -93,6 +100,8 @@ const MultipleChoiceChart: FC<Props> = ({
width: chartWidth,
height: chartHeight,
zoom,
questionType,
scaling,
}),
[timestamps, choiceItems, chartWidth, chartHeight, zoom]
);
Expand Down Expand Up @@ -135,12 +144,9 @@ const MultipleChoiceChart: FC<Props> = ({
cursorLabelComponent={<ChartCursorLabel positionY={height - 10} />}
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;
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
};
Expand Down
Loading