diff --git a/front_end/src/app/(embed)/questions/assets/metaculus-dark.png b/front_end/src/app/(embed)/questions/assets/metaculus-dark.png new file mode 100644 index 0000000000..4c6ee1b192 Binary files /dev/null and b/front_end/src/app/(embed)/questions/assets/metaculus-dark.png differ diff --git a/front_end/src/app/(embed)/questions/assets/metaculus-light.png b/front_end/src/app/(embed)/questions/assets/metaculus-light.png new file mode 100644 index 0000000000..a31595da21 Binary files /dev/null and b/front_end/src/app/(embed)/questions/assets/metaculus-light.png differ diff --git a/front_end/src/app/(embed)/questions/components/embed_question_card.tsx b/front_end/src/app/(embed)/questions/components/embed_question_card.tsx new file mode 100644 index 0000000000..042f8a8cfb --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/embed_question_card.tsx @@ -0,0 +1,96 @@ +import { useEffect, useMemo, useState } from "react"; + +import { PostWithForecasts } from "@/types/post"; + +import EmbedQuestionFooter from "./embed_question_footer"; +import EmbedQuestionHeader from "./embed_question_header"; +import EmbedQuestionPlot from "./embed_question_plot"; +import { QuestionViewModeProvider } from "./question_view_mode_context"; +import { EmbedTheme } from "../constants/embed_theme"; +import { EmbedSize, getEmbedChartHeight } from "../helpers/embed_chart_height"; + +type Props = { + post: PostWithForecasts; + ogMode?: boolean; + size: EmbedSize; + theme?: EmbedTheme; + titleOverride?: string; + containerWidth?: number; +}; + +const EmbedQuestionCard: React.FC = ({ + post, + ogMode, + size, + theme, + titleOverride, + containerWidth, +}) => { + const [headerHeight, setHeaderHeight] = useState(0); + const [legendHeight, setLegendHeight] = useState(0); + const [ogReady, setOgReady] = useState(!ogMode); + + const chartHeight = useMemo(() => { + if (ogMode) { + return getEmbedChartHeight({ + post, + ogMode, + size, + headerHeight, + legendHeight, + }); + } + + return getEmbedChartHeight({ + post, + ogMode, + size, + headerHeight, + legendHeight, + }); + }, [post, ogMode, size, headerHeight, legendHeight]); + + useEffect(() => { + if (!ogMode) return; + + if (!headerHeight) { + setOgReady(false); + return; + } + + let raf1 = 0; + let raf2 = 0; + + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + setOgReady(true); + }); + }); + + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [ogMode, headerHeight, legendHeight]); + + return ( + + + + + + ); +}; + +export default EmbedQuestionCard; diff --git a/front_end/src/app/(embed)/questions/components/embed_question_footer.tsx b/front_end/src/app/(embed)/questions/components/embed_question_footer.tsx new file mode 100644 index 0000000000..f1d64251df --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/embed_question_footer.tsx @@ -0,0 +1,54 @@ +import Image from "next/image"; +import React from "react"; + +import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; +import CommentStatus from "@/components/post_card/basic_post_card/comment_status"; +import { PostWithForecasts } from "@/types/post"; + +import metaculusDarkLogo from "../assets/metaculus-dark.png"; +import metaculusLightLogo from "../assets/metaculus-light.png"; + +type Props = { + post: PostWithForecasts; + ogReady?: boolean; +}; + +const EmbedQuestionFooter: React.FC = ({ post, ogReady }) => { + return ( +
+
+ + +
+ + {ogReady && ( +
+ Metaculus Logo + Metaculus Logo +
+ )} +
+ ); +}; + +export default EmbedQuestionFooter; diff --git a/front_end/src/app/(embed)/questions/components/embed_question_header.tsx b/front_end/src/app/(embed)/questions/components/embed_question_header.tsx new file mode 100644 index 0000000000..edd9c2ea31 --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/embed_question_header.tsx @@ -0,0 +1,130 @@ +import { CSSProperties, useEffect, useMemo, useRef } from "react"; + +import QuestionHeaderCPStatus from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_cp_status"; +import { ContinuousQuestionTypes } from "@/constants/questions"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionType, QuestionWithForecasts } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { + isContinuousQuestion, + isGroupOfQuestionsPost, + isQuestionPost, +} from "@/utils/questions/helpers"; + +import { useIsEmbedMode } from "./question_view_mode_context"; +import TruncatableQuestionTitle from "./truncatable_question_title"; +import { EmbedTheme } from "../constants/embed_theme"; +import { getEmbedAccentColor } from "../helpers/embed_theme"; + +type Props = { + post: PostWithForecasts; + onHeightChange?: (height: number) => void; + titleStyle?: CSSProperties; + titleOverride?: string; + theme?: EmbedTheme; +}; + +const EmbedQuestionHeader: React.FC = ({ + post, + onHeightChange, + titleStyle, + titleOverride, + theme, +}) => { + const containerRef = useRef(null); + const isEmbed = useIsEmbedMode(); + + useEffect(() => { + if (!onHeightChange) return; + const el = containerRef.current; + if (!el) return; + + const update = () => { + onHeightChange(el.getBoundingClientRect().height); + }; + + update(); + + const observer = new ResizeObserver(() => { + update(); + }); + observer.observe(el); + + return () => observer.disconnect(); + }, [onHeightChange]); + + const maxLines = useMemo(() => { + if (isGroupOfQuestionsPost(post)) { + const firstType = post.group_of_questions.questions[0]?.type; + const isBinaryGroup = firstType === QuestionType.Binary; + const isContinuousGroup = ContinuousQuestionTypes.some( + (t) => t === firstType + ); + + if (isBinaryGroup || isContinuousGroup) return 2; + return 3; + } + + if (!isQuestionPost(post)) return 3; + const q = post.question; + + if (q.type === QuestionType.MultipleChoice) return 2; + return q.type === QuestionType.Binary || isContinuousQuestion(q) ? 4 : 3; + }, [post]); + + const titleMinHeightClass = useMemo(() => { + if (isGroupOfQuestionsPost(post)) { + const firstType = post.group_of_questions.questions[0]?.type; + const isBinaryGroup = firstType === QuestionType.Binary; + const isContinuousGroup = ContinuousQuestionTypes.some( + (t) => t === firstType + ); + + return isBinaryGroup || isContinuousGroup ? "min-h-[2.5em]" : ""; + } + + if (!isQuestionPost(post)) return ""; + const q = post.question; + + const needsMinHeight = + q.type === QuestionType.MultipleChoice || + q.type === QuestionType.Binary || + isContinuousQuestion(q); + + return needsMinHeight ? "min-h-[2.5em]" : ""; + }, [post]); + + const predictionColor = getEmbedAccentColor(theme); + + return ( +
+ + {titleOverride ?? post.title} + + {isQuestionPost(post) && ( +
+ +
+ )} +
+ ); +}; + +export default EmbedQuestionHeader; diff --git a/front_end/src/app/(embed)/questions/components/embed_question_plot.tsx b/front_end/src/app/(embed)/questions/components/embed_question_plot.tsx new file mode 100644 index 0000000000..45f1c83ceb --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/embed_question_plot.tsx @@ -0,0 +1,50 @@ +import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; +import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; +import { PostWithForecasts } from "@/types/post"; +import { + isGroupOfQuestionsPost, + isQuestionPost, +} from "@/utils/questions/helpers"; + +import { EmbedTheme } from "../constants/embed_theme"; +import { getEmbedAccentColor } from "../helpers/embed_theme"; + +type Props = { + post: PostWithForecasts; + chartHeight?: number; + onLegendHeightChange?: (height: number) => void; + theme?: EmbedTheme; +}; + +const EmbedQuestionPlot: React.FC = ({ + post, + chartHeight, + onLegendHeightChange, + theme, +}) => { + const isGroup = isGroupOfQuestionsPost(post); + const accent = getEmbedAccentColor(theme); + return ( + <> + {isQuestionPost(post) && ( + + )} + {isGroup && ( + + )} + + ); +}; + +export default EmbedQuestionPlot; diff --git a/front_end/src/app/(embed)/questions/components/embed_screen.tsx b/front_end/src/app/(embed)/questions/components/embed_screen.tsx new file mode 100644 index 0000000000..ad5e09d80d --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/embed_screen.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; + +import { ContinuousQuestionTypes } from "@/constants/questions"; +import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; +import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { isGroupOfQuestionsPost } from "@/utils/questions/helpers"; + +import EmbedQuestionCard from "./embed_question_card"; +import { EmbedTheme } from "../constants/embed_theme"; +import { EmbedSize } from "../helpers/embed_chart_height"; + +type Props = { + post: PostWithForecasts; + targetWidth?: number; + targetHeight?: number; + theme?: EmbedTheme; + titleOverride?: string; +}; + +const MIN_EMBED_WIDTH = 360; +const DYNAMIC_BELOW_WIDTH = 440; + +function getBinaryContinuousSize(containerWidth: number): EmbedSize { + if (containerWidth >= 550) return { width: 550, height: 360 }; + if (containerWidth >= 440) return { width: 440, height: 360 }; + return { width: 360, height: 360 }; +} + +function getOtherSize(containerWidth: number): EmbedSize { + if (containerWidth >= 550) return { width: 550, height: 270 }; + if (containerWidth >= 440) return { width: 440, height: 270 }; + if (containerWidth >= 400) return { width: 400, height: 360 }; + return { width: 360, height: 360 }; +} + +function getSizeForPost(post: PostWithForecasts, containerWidth: number) { + const isBinaryOrContinuous = + !!post.question && + (post.question.type === QuestionType.Binary || + ContinuousQuestionTypes.some((t) => t === post.question?.type)); + + const isFanChart = + isGroupOfQuestionsPost(post) && + post.group_of_questions?.graph_type === GroupOfQuestionsGraphType.FanGraph; + + return isBinaryOrContinuous || isFanChart + ? getBinaryContinuousSize(containerWidth) + : getOtherSize(containerWidth); +} + +const EmbedScreen: React.FC = ({ + post, + targetWidth, + targetHeight, + theme, + titleOverride, +}) => { + const frameRef = useRef(null); + + const [containerWidth, setContainerWidth] = useState(MIN_EMBED_WIDTH); + + useEffect(() => { + if (!frameRef.current) return; + + const el = frameRef.current; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + const rawWidth = entry?.contentRect.width ?? MIN_EMBED_WIDTH; + setContainerWidth(rawWidth); + }); + + observer.observe(el); + return () => observer.disconnect(); + }, [post]); + + const ogMode = + typeof targetWidth === "number" && typeof targetHeight === "number"; + + const isDynamic = !ogMode && containerWidth < DYNAMIC_BELOW_WIDTH; + + const effectiveWidthForSizing = isDynamic + ? containerWidth + : Math.max(containerWidth, MIN_EMBED_WIDTH); + + const snapped = getSizeForPost(post, effectiveWidthForSizing); + + const baseWidth = isDynamic + ? effectiveWidthForSizing + : snapped.width || MIN_EMBED_WIDTH; + + const baseHeight = snapped.height || MIN_EMBED_WIDTH; + + const scale = ogMode + ? Math.min(targetWidth / baseWidth, targetHeight / baseHeight) + : 1; + + const isBinary = post.question?.type === QuestionType.Binary; + const isContinuous = + post.question && + ContinuousQuestionTypes.some((t) => t === post.question?.type); + + const frameStyle: React.CSSProperties = ogMode + ? { + width: targetWidth, + height: targetHeight, + minWidth: MIN_EMBED_WIDTH, + boxSizing: "border-box", + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + } + : { + width: isDynamic ? "100%" : baseWidth, + ...(isDynamic ? {} : { minWidth: MIN_EMBED_WIDTH }), + ...(isDynamic ? {} : { height: baseHeight, minHeight: baseHeight }), + + boxSizing: "border-box", + }; + + return ( +
+
+
+ +
+
+
+ ); +}; + +export default EmbedScreen; diff --git a/front_end/src/app/(embed)/questions/components/question_view_mode_context.tsx b/front_end/src/app/(embed)/questions/components/question_view_mode_context.tsx new file mode 100644 index 0000000000..dcfb6b4598 --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/question_view_mode_context.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React, { createContext, useContext } from "react"; + +export type QuestionViewMode = "default" | "embed"; + +type QuestionViewContextValue = { + mode: QuestionViewMode; + containerWidth?: number; +}; + +const QuestionViewModeContext = createContext({ + mode: "default", + containerWidth: undefined, +}); + +type ProviderProps = { + mode?: QuestionViewMode; + containerWidth?: number; + children: React.ReactNode; +}; + +export const QuestionViewModeProvider: React.FC = ({ + mode = "default", + containerWidth, + children, +}) => ( + + {children} + +); + +export const useQuestionViewMode = () => + useContext(QuestionViewModeContext).mode; + +export const useIsEmbedMode = () => useQuestionViewMode() === "embed"; + +export const useEmbedContainerWidth = () => + useContext(QuestionViewModeContext).containerWidth; + +export const useIsEmbedNarrow = (maxWidthPx: number) => { + const isEmbed = useIsEmbedMode(); + const w = useEmbedContainerWidth(); + return !!isEmbed && typeof w === "number" && w > 0 && w < maxWidthPx; +}; diff --git a/front_end/src/app/(embed)/questions/components/truncatable_question_title.tsx b/front_end/src/app/(embed)/questions/components/truncatable_question_title.tsx new file mode 100644 index 0000000000..6468c168f3 --- /dev/null +++ b/front_end/src/app/(embed)/questions/components/truncatable_question_title.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { HTMLAttributes, useEffect, useRef, useState } from "react"; + +import QuestionTitle from "@/app/(main)/questions/[id]/components/question_view/shared/question_title"; +import cn from "@/utils/core/cn"; + +type TruncatableQuestionTitleProps = HTMLAttributes & { + maxLines?: number; + revealOnHoverOrTap?: boolean; +}; + +const GRADIENT_EXTRA_PX = 12; + +const TruncatableQuestionTitle: React.FC = ({ + children, + className, + style, + maxLines = 4, + revealOnHoverOrTap = true, + onMouseEnter, + onMouseLeave, + onFocus, + onBlur, + onClick, + ...rest +}) => { + const [expanded, setExpanded] = useState(false); + const [isTruncated, setIsTruncated] = useState(false); + const [contentHeight, setContentHeight] = useState(null); + const [clampedHeight, setClampedHeight] = useState(null); + const titleRef = useRef(null); + + const hasClamp = maxLines > 0; + + const clampStyle: React.CSSProperties = hasClamp + ? { + display: "-webkit-box", + WebkitLineClamp: maxLines, + WebkitBoxOrient: "vertical", + overflow: "hidden", + } + : {}; + + useEffect(() => { + const el = titleRef.current; + if (!el || !hasClamp) { + setIsTruncated(false); + setContentHeight(null); + setClampedHeight(null); + return; + } + + const checkTruncation = () => { + const client = el.clientHeight; + const scroll = el.scrollHeight; + + setClampedHeight(client); + setContentHeight(scroll); + setIsTruncated(scroll > client + 1); + }; + + checkTruncation(); + + const observer = new ResizeObserver(checkTruncation); + observer.observe(el); + return () => observer.disconnect(); + }, [children, maxLines, hasClamp]); + + const handleRevealOn = () => { + if (!revealOnHoverOrTap || !isTruncated) return; + setExpanded(true); + }; + + const handleRevealOff = () => { + if (!revealOnHoverOrTap || !isTruncated) return; + setExpanded(false); + }; + + const handleToggleClick = () => { + if (!revealOnHoverOrTap || !isTruncated) return; + setExpanded((prev) => !prev); + }; + + const showOverlay = revealOnHoverOrTap && isTruncated && expanded; + + const baseHeight = showOverlay ? contentHeight : clampedHeight; + const gradientHeight = + baseHeight != null ? baseHeight + GRADIENT_EXTRA_PX : undefined; + + return ( +
{ + onMouseEnter?.(e); + handleRevealOn(); + }} + onMouseLeave={(e) => { + onMouseLeave?.(e); + handleRevealOff(); + }} + onFocus={(e) => { + onFocus?.(e); + handleRevealOn(); + }} + onBlur={(e) => { + onBlur?.(e); + handleRevealOff(); + }} + onClick={(e) => { + onClick?.(e); + handleToggleClick(); + }} + > + + {children} + + + {showOverlay && ( + <> +
+ + {children} + +
+ +
+ + )} +
+ ); +}; + +export default TruncatableQuestionTitle; diff --git a/front_end/src/app/(embed)/questions/embed/[id]/page.tsx b/front_end/src/app/(embed)/questions/embed/[id]/page.tsx index 8831fcc583..ab98826147 100644 --- a/front_end/src/app/(embed)/questions/embed/[id]/page.tsx +++ b/front_end/src/app/(embed)/questions/embed/[id]/page.tsx @@ -1,98 +1,51 @@ -import Link from "next/link"; -import { getTranslations } from "next-intl/server"; - -import ForecastCard from "@/components/forecast_card"; -import CommunityDisclaimer from "@/components/post_card/community_disclaimer"; -import { - CHART_TYPE_PARAM, - EMBED_QUESTION_TITLE, - GRAPH_ZOOM_PARAM, - HIDE_ZOOM_PICKER, -} from "@/constants/global_search_params"; +import { EMBED_QUESTION_TITLE } from "@/constants/global_search_params"; import ServerPostsApi from "@/services/api/posts/posts.server"; -import { EmbedChartType, TimelineChartZoomOption } from "@/types/charts"; import { SearchParams } from "@/types/navigation"; -import "./styles.scss"; -import { TournamentType } from "@/types/projects"; +import EmbedScreen from "../../components/embed_screen"; import { getEmbedTheme } from "../../helpers/embed_theme"; +import "./styles.scss"; + +const OG_WIDTH = 1200; +const OG_HEIGHT = 630; + export default async function GenerateQuestionPreview(props: { params: Promise<{ id: number }>; searchParams: Promise; }) { - const searchParams = await props.searchParams; - const params = await props.params; - const t = await getTranslations(); + const [searchParams, params] = await Promise.all([ + props.searchParams, + props.params, + ]); + const post = await ServerPostsApi.getPostAnonymous(params.id); - if (!post) { - return null; - } - const isCommunityQuestion = - post.projects.default_project.type === TournamentType.Community; + if (!post) return null; const embedTheme = getEmbedTheme( searchParams["embed_theme"], searchParams["css_variables"] ); - const nonInteractiveParam = searchParams["non-interactive"]; - - const chartZoomParam = searchParams[GRAPH_ZOOM_PARAM]; - let chartZoom: TimelineChartZoomOption | undefined = undefined; - if (typeof chartZoomParam === "string") { - chartZoom = - (chartZoomParam as TimelineChartZoomOption) ?? - TimelineChartZoomOption.TwoMonths; - } - const hideZoomPickerParam = searchParams[HIDE_ZOOM_PICKER]; - const embedTitle = searchParams[EMBED_QUESTION_TITLE] as string | undefined; - const chartTypeParam = searchParams[CHART_TYPE_PARAM] as - | EmbedChartType + const titleOverride = searchParams[EMBED_QUESTION_TITLE] as + | string | undefined; - return ( -
- {isCommunityQuestion && ( - - )} - -
-

- {t("forecastDisclaimer", { - predictionCount: post.forecasts_count ?? 0, - forecasterCount: post.nr_forecasters, - })} -

- - {t("metaculus")} - -
-
+ const isOgCapture = searchParams["og"] === "1"; + + const commonProps = { + post, + theme: embedTheme, + titleOverride, + }; + + return isOgCapture ? ( + + ) : ( + ); } diff --git a/front_end/src/app/(embed)/questions/embed/[id]/styles.scss b/front_end/src/app/(embed)/questions/embed/[id]/styles.scss index dc24a55969..3289cbcb92 100644 --- a/front_end/src/app/(embed)/questions/embed/[id]/styles.scss +++ b/front_end/src/app/(embed)/questions/embed/[id]/styles.scss @@ -188,3 +188,67 @@ body { } } } + +.EmbedQuestionCard { + --embed-title-size: 28px; + --embed-chip-size: 28px; + --embed-chip-line: 1.1; + --embed-label-size: 14px; + --embed-label-line: 16px; + --embed-icon-size: 12px; + --embed-ellipsis-size: 22px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.EmbedQuestionCard .ForecastCard-header > h2 { + font-size: var(--embed-title-size); + line-height: 1.2; +} + +.EmbedQuestionCard .ForecastCard-prediction .InternalChip, +.EmbedQuestionCard .ForecastCard-prediction .InternalLabel { + font-size: var(--embed-chip-size); + line-height: var(--embed-chip-line); +} + +.EmbedQuestionCard .ForecastCard-prediction .InternalChip { + padding-left: 14px; + padding-right: 14px; + padding-top: 22px; + padding-bottom: 22px; +} + +.EmbedQuestionCard .embed-gap { + gap: 10px; +} + +.EmbedQuestionCard .MultipleChoiceTile .resize-label, +.EmbedQuestionCard .MultiTimeSeriesTile .resize-label { + font-size: var(--embed-label-size); + line-height: var(--embed-label-line); +} + +.EmbedQuestionCard .MultipleChoiceTile .resize-icon, +.EmbedQuestionCard .MultiTimeSeriesTile .resize-icon { + width: var(--embed-icon-size); + height: var(--embed-icon-size); +} + +.EmbedQuestionCard .MultipleChoiceTile .resize-ellipsis, +.EmbedQuestionCard .MultiTimeSeriesTile .resize-ellipsis { + font-size: var(--embed-ellipsis-size); + line-height: var(--embed-label-line); + width: auto; +} + +.EmbedQuestionCard .MultipleChoiceTile { + @media screen and (min-width: $embeds-max-width) { + gap: 1.25rem; + + .resize-label { + padding-left: 0px; + margin-left: -2px; + } + } +} diff --git a/front_end/src/app/(embed)/questions/helpers/embed_chart_height.ts b/front_end/src/app/(embed)/questions/helpers/embed_chart_height.ts new file mode 100644 index 0000000000..c6f983e23e --- /dev/null +++ b/front_end/src/app/(embed)/questions/helpers/embed_chart_height.ts @@ -0,0 +1,114 @@ +import { ContinuousQuestionTypes } from "@/constants/questions"; +import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; +import { QuestionType } from "@/types/question"; +import { isGroupOfQuestionsPost } from "@/utils/questions/helpers"; + +export type EmbedSize = { + width: number; + height: number; +}; + +const HEADER = { + MIN: 50, + MAX: 100, +} as const; + +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)); +} + +function headerT(headerHeight: number) { + if (!headerHeight) return null; + const clamped = clamp(headerHeight, HEADER.MIN, HEADER.MAX); + return (clamped - HEADER.MIN) / (HEADER.MAX - HEADER.MIN); +} + +type ChartRange = { + min: number; + max: number; + fudge: number; +}; + +function getChartRange(args: { + post: PostWithForecasts; + ogMode?: boolean; + size: EmbedSize; + legendHeight?: number; + headerHeight?: number; +}): ChartRange { + const { post, ogMode, size, legendHeight, headerHeight } = args; + + const isMC = post.question?.type === QuestionType.MultipleChoice; + const isGroup = isGroupOfQuestionsPost(post); + const isDate = post.question?.type === QuestionType.Date; + + const header = headerHeight ?? 0; + let min = !ogMode + ? 152 + : header >= 175 || (header > 80 && header < 90) + ? isDate + ? 130 + : 150 + : 176; + + let max = ogMode ? 320 : 202; + let fudge = 8; + + if (isDate && !ogMode) { + min = 132; + } + + if (isMC) { + min = ogMode ? 120 : 73; + max = size.width <= 400 ? 202 - (legendHeight ?? 0) : 124; + fudge = 0; + return { min, max, fudge }; + } + + if (isGroup) { + const firstType = post.group_of_questions.questions[0]?.type; + const isBinaryGroup = firstType === QuestionType.Binary; + const isContinuousGroup = ContinuousQuestionTypes.some( + (t) => t === firstType + ); + + if ( + (isBinaryGroup || isContinuousGroup) && + post.group_of_questions.graph_type !== GroupOfQuestionsGraphType.FanGraph + ) { + min = ogMode ? 120 : 73; + max = size.width <= 400 ? 202 - (legendHeight ?? 0) : 124; + fudge = 0; + return { min, max, fudge }; + } + + min = ogMode ? 120 : 73; + max = 196 - (legendHeight ?? 0); + return { min, max, fudge }; + } + + return { min, max, fudge }; +} + +export function getEmbedChartHeight(args: { + post: PostWithForecasts; + ogMode?: boolean; + size: EmbedSize; + headerHeight: number; + legendHeight?: number; +}): number { + const { headerHeight, post, ogMode, size, legendHeight } = args; + + const { min, max, fudge } = getChartRange({ + post, + ogMode, + size, + legendHeight, + headerHeight, + }); + + const t = headerT(headerHeight); + if (t === null) return max; + + return Math.round(max + fudge - t * (max - min)); +} diff --git a/front_end/src/app/(embed)/questions/helpers/embed_theme.ts b/front_end/src/app/(embed)/questions/helpers/embed_theme.ts index 81ccbccfdf..622b0e1dcf 100644 --- a/front_end/src/app/(embed)/questions/helpers/embed_theme.ts +++ b/front_end/src/app/(embed)/questions/helpers/embed_theme.ts @@ -57,10 +57,7 @@ function getEmbeddedChartTheme( theme: VictoryThemeDefinition | undefined, cssVariables: Record ): VictoryThemeDefinition { - const baseTheme: VictoryThemeDefinition = { - axis: { style: { tickLabels: { fontSize: 16 } } }, - line: { style: { data: { strokeWidth: 2 } } }, - }; + const baseTheme: VictoryThemeDefinition = {}; if (theme) { return merge(baseTheme, theme); @@ -112,3 +109,12 @@ function getEmbeddedChipTheme( return {}; } + +export const getEmbedAccentColor = (theme?: EmbedTheme): string | undefined => { + const stroke = theme?.chart?.line?.style?.data?.stroke as unknown as + | string + | undefined; + return ( + stroke ?? (theme?.predictionChip?.backgroundColor as string | undefined) + ); +}; diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx index a022d88d4e..1198a330e4 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx @@ -10,9 +10,13 @@ import { EmbedModalContextProvider } from "@/contexts/embed_modal_context"; import { PostSubscriptionProvider } from "@/contexts/post_subscription_context"; import ServerProjectsApi from "@/services/api/projects/projects.server"; import { SearchParams } from "@/types/navigation"; +import { GroupOfQuestionsGraphType } from "@/types/post"; import { TournamentType } from "@/types/projects"; import cn from "@/utils/core/cn"; -import { getPostTitle } from "@/utils/questions/helpers"; +import { + getPostTitle, + isGroupOfQuestionsPost, +} from "@/utils/questions/helpers"; import NotebookRedirect from "../components/notebook_redirect"; import QuestionEmbedModal from "../components/question_embed_modal"; @@ -21,6 +25,7 @@ import QuestionView from "../components/question_view"; import Sidebar from "../components/sidebar"; import { SLUG_POST_SUB_QUESTION_ID } from "../search_params"; import { cachedGetPost } from "./utils/get_post"; + import "../components/key_factors/key-factors.css"; const CommunityDisclaimer = dynamic( @@ -49,6 +54,10 @@ const IndividualQuestionPage: FC<{ extractPreselectedGroupQuestionId(searchParams); const questionTitle = getPostTitle(postData); + const isFanChart = + isGroupOfQuestionsPost(postData) && + postData.group_of_questions?.graph_type === + GroupOfQuestionsGraphType.FanGraph; return ( @@ -104,6 +113,7 @@ const IndividualQuestionPage: FC<{ postId={postData.id} postTitle={postData.title} questionType={postData.question?.type} + isFanChart={isFanChart} /> diff --git a/front_end/src/app/(main)/questions/[id]/components/question_embed_modal.tsx b/front_end/src/app/(main)/questions/[id]/components/question_embed_modal.tsx index 91b0e7b249..e63721f743 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_embed_modal.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_embed_modal.tsx @@ -2,16 +2,24 @@ import { FC } from "react"; import EmbedModal from "@/components/embed_modal"; +import { ContinuousQuestionTypes } from "@/constants/questions"; import useEmbedModalContext from "@/contexts/embed_modal_context"; import { useEmbedUrl } from "@/hooks/share"; import { QuestionType } from "@/types/question"; + type Props = { postId: number; postTitle?: string; questionType?: QuestionType; + isFanChart?: boolean; }; -const QuestionEmbedModal: FC = ({ postId, postTitle, questionType }) => { +const QuestionEmbedModal: FC = ({ + postId, + postTitle, + questionType, + isFanChart, +}) => { const embedUrl = useEmbedUrl(`/questions/embed/${postId}`); const { isOpen, updateIsOpen } = useEmbedModalContext(); @@ -19,12 +27,20 @@ const QuestionEmbedModal: FC = ({ postId, postTitle, questionType }) => { return null; } + const isBinaryOrContinuous = + !!questionType && + (questionType === QuestionType.Binary || + ContinuousQuestionTypes.some((type) => type === questionType)); + + const embedWidth = 550; + const embedHeight = isBinaryOrContinuous || isFanChart ? 390 : 290; + return ( = ({ isClosed: question.status === QuestionStatus.CLOSED, }); + const isEmbed = useIsEmbedMode(); + return (
= ({ { "text-gray-700 dark:text-gray-700-dark": !successfullyResolved, "text-base": size === "lg", + "mb-0 truncate text-sm md:text-sm": isEmbed, } )} > 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 9d74d0a7f7..a8efebedec 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 @@ -1,7 +1,12 @@ "use client"; import { useLocale, useTranslations } from "next-intl"; import React, { FC } from "react"; +import { VictoryThemeDefinition } from "victory"; +import { + useEmbedContainerWidth, + useIsEmbedMode, +} from "@/app/(embed)/questions/components/question_view_mode_context"; import QuestionHeaderContinuousResolutionChip from "@/app/(main)/questions/[id]/components/question_view/forecaster_question_view/question_header/question_header_continuous_resolution_chip"; import { getContinuousAreaChartData } from "@/components/charts/continuous_area_chart"; import MinifiedContinuousAreaChart from "@/components/charts/minified_continuous_area_chart"; @@ -25,12 +30,16 @@ type Props = { question: QuestionWithForecasts; size: "md" | "lg"; hideLabel?: boolean; + colorOverride?: string; + chartTheme?: VictoryThemeDefinition; }; const QuestionHeaderCPStatus: FC = ({ question, size, hideLabel = false, + colorOverride, + chartTheme, }) => { const locale = useLocale(); const t = useTranslations(); @@ -46,6 +55,10 @@ const QuestionHeaderCPStatus: FC = ({ QuestionType.Date, ].includes(question.type); + const isEmbed = useIsEmbedMode(); + const w = useEmbedContainerWidth(); + const isEmbedBelow376 = isEmbed && (w ?? 0) > 0 && (w ?? 0) < 376; + if (question.status === QuestionStatus.RESOLVED && question.resolution) { // Resolved/Annulled/Ambiguous const formatedResolution = formatResolution({ @@ -81,10 +94,15 @@ const QuestionHeaderCPStatus: FC = ({ ); } + const borderStyle = colorOverride + ? { borderColor: `${colorOverride}33` } + : undefined; + if (isContinuous) { return ( !forecastAvailability.isEmpty && (
= ({ "max-w-[130px]": size === "md", "gap-1": !hideLabel && size === "lg", "gap-0": size === "md", // Remove gap for mobile (both hideLabel true/false) - "-gap-2": size === "md" && hideLabel, // More negative gap for mobile continuous questions + "-gap-2": size === "md" && hideLabel, // More negative gap for mobile continuous questions, + "border-[0.5px] border-olive-500 p-3 dark:border-olive-500-dark md:p-3": + isEmbed, + "min-w-[200px] border-none p-0": isEmbedBelow376, } )} >
{!hideLabel && ( -
+ )} + {isEmbedBelow376 && ( +

+ {t("currentEstimate")} +

+ )} {!hideCP && ( )}
@@ -117,15 +148,28 @@ const QuestionHeaderCPStatus: FC = ({ className={cn({ "flex min-h-0 flex-1 items-center": hideLabel, // Desktop timeline: flex and center "": !hideLabel, // Mobile: no special styling + "mt-1.5": isEmbed, })} >
{!hideCP && ( @@ -149,13 +193,21 @@ const QuestionHeaderCPStatus: FC = ({ } else if (question.type === QuestionType.Binary) { return (
{!hideCP && ( - + )} {!hideCP && ( > = ({ - children, - className, - ...props -}) => { +const QuestionTitle = forwardRef< + HTMLHeadingElement, + HTMLAttributes +>(({ children, className, ...props }, ref) => { return (

> = ({ {children}

); -}; +}); + +QuestionTitle.displayName = "QuestionTitle"; + export default QuestionTitle; diff --git a/front_end/src/app/(main)/questions/[id]/image-preview/route.ts b/front_end/src/app/(main)/questions/[id]/image-preview/route.ts index 1121596741..663dba5573 100644 --- a/front_end/src/app/(main)/questions/[id]/image-preview/route.ts +++ b/front_end/src/app/(main)/questions/[id]/image-preview/route.ts @@ -18,7 +18,7 @@ export async function GET( const theme = request.nextUrl.searchParams.get("theme") ?? "dark"; const { PUBLIC_APP_URL } = getPublicSettings(); - const imageUrl = `${PUBLIC_APP_URL}/questions/embed/${id}/?${ENFORCED_THEME_PARAM}=${theme}&${HIDE_ZOOM_PICKER}=true&non-interactive=true`; + const imageUrl = `${PUBLIC_APP_URL}/questions/embed/${id}/?${ENFORCED_THEME_PARAM}=${theme}&${HIDE_ZOOM_PICKER}=true&non-interactive=true&og=1`; const payload = { url: imageUrl, diff --git a/front_end/src/components/charts/fan_chart.tsx b/front_end/src/components/charts/fan_chart.tsx index 4fa1c9b17d..0a035e025a 100644 --- a/front_end/src/components/charts/fan_chart.tsx +++ b/front_end/src/components/charts/fan_chart.tsx @@ -1,7 +1,7 @@ "use client"; import { isNil, merge } from "lodash"; -import { FC, useCallback, useMemo, useState } from "react"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { Tuple, VictoryArea, @@ -76,6 +76,7 @@ import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import { isUnsuccessfullyResolved } from "@/utils/questions/resolution"; import { FanChartVariant, fanVariants } from "./fan_chart_variants"; +import EmbedFanLegend from "./primitives/embed_fan_legend"; import IndexValueTooltip from "./primitives/index_value_tooltip"; type Props = { @@ -92,6 +93,7 @@ type Props = { isEmbedded?: boolean; optionsLimit?: number; forFeedPage?: boolean; + onLegendHeightChange?: (height: number) => void; }; type NormalizedFanDatum = { @@ -121,9 +123,18 @@ const FanChart: FC = ({ isEmbedded = false, optionsLimit, forFeedPage, + onLegendHeightChange, }) => { const effectiveVariant: FanChartVariant = variant ?? "default"; + const { ref: embedLegendRef, height: embedLegendHeight } = + useContainerSize(); + + useEffect(() => { + if (!isEmbedded) return; + if (!onLegendHeightChange) return; + onLegendHeightChange(embedLegendHeight); + }, [isEmbedded, embedLegendHeight, onLegendHeightChange]); const { ref: chartContainerRef, width: chartWidth } = useContainerSize(); const { theme, getThemeColor } = useAppTheme(); @@ -220,18 +231,69 @@ const FanChart: FC = ({ const v = fanVariants[effectiveVariant]; const palette = v.palette({ getThemeColor }); - const shouldDisplayChart = !!chartWidth; + const embedGridTicks = useMemo(() => { + if (!isEmbedded) return null; + return getEvenTicks(yDomain as Tuple, 5); + }, [isEmbedded, yDomain]); - const variantArgs = { - chartWidth, - yLabel, + const embedLabelTicks = useMemo(() => { + if (!isEmbedded) return null; + return getEvenTicks(yDomain as Tuple, 3); + }, [isEmbedded, yDomain]); + + const EMBED_SIDE_PAD = 10; + + const effectiveMaxLeftPadding = isEmbedded ? EMBED_SIDE_PAD : maxLeftPadding; + const effectiveMaxRightPadding = isEmbedded + ? Math.max(maxRightPadding, rightPadding, MIN_RIGHT_PADDING, 28) + : maxRightPadding; + + const baseYAxisStyle = v.yAxisStyle({ tickLabelFontSize, - maxLeftPadding: isEmbedded ? maxLeftPadding : maxLeftPadding, - maxRightPadding: isEmbedded - ? Math.max(10, maxRightPadding) - : maxRightPadding, + maxLeftPadding: effectiveMaxLeftPadding, + maxRightPadding: effectiveMaxRightPadding, getThemeColor, - }; + }); + + const shouldDisplayChart = !!chartWidth; + + const variantArgs = useMemo( + () => ({ + chartWidth, + yLabel, + tickLabelFontSize, + maxLeftPadding: effectiveMaxLeftPadding, + maxRightPadding: effectiveMaxRightPadding, + isEmbedded, + getThemeColor, + }), + [ + chartWidth, + yLabel, + tickLabelFontSize, + effectiveMaxLeftPadding, + effectiveMaxRightPadding, + isEmbedded, + getThemeColor, + ] + ); + + const chartPadding = useMemo(() => { + const p = v.padding(variantArgs); + if (!isEmbedded) return p; + + const safeRight = Math.max( + typeof p.right === "number" ? p.right : 0, + effectiveMaxRightPadding, + MIN_RIGHT_PADDING, + 28 + ); + return { + ...p, + left: EMBED_SIDE_PAD, + right: safeRight, + }; + }, [v, variantArgs, isEmbedded, effectiveMaxRightPadding, MIN_RIGHT_PADDING]); const bottomPadForPoints = v.padding(variantArgs).bottom; @@ -326,184 +388,260 @@ const FanChart: FC = ({ /> ); + const embedLegendNames = useMemo(() => { + if (!isEmbedded) return []; + + const names = normOptions.map((o) => o.name).filter(Boolean); + const n = names.length; + + if (n <= 3) return names; + + const first = names[0]; + const mid = names[Math.floor(n / 2)]; + const last = names[n - 1]; + + return Array.from(new Set([first, mid, last])).filter( + (v): v is string => typeof v === "string" && v.length > 0 + ); + }, [isEmbedded, normOptions]); + + const embedLegendItems = useMemo(() => { + if (!isEmbedded || embedLegendNames.length === 0) return []; + + const map = new Map(normOptions.map((o) => [o.name, o])); + + return embedLegendNames.map((name) => { + const o = map.get(name); + + const raw = + o?.resolved && typeof o.resolvedValue === "number" + ? o.resolvedValue + : o?.communityQuartiles?.median ?? null; + + const valueText = + typeof raw === "number" && Number.isFinite(raw) + ? yScale.tickFormat(raw) + : "—"; + + return { name, valueText }; + }); + }, [isEmbedded, embedLegendNames, normOptions, yScale]); + console.log("chartWidth", chartWidth); + const isCompactEmbed = isEmbedded && !!chartWidth && chartWidth < 400; + return ( -
- {shouldDisplayChart && ( - - ) - } - events={[ - { - target: "parent", - eventHandlers: { - onMouseOutCapture: () => setActivePoint(null), - }, - }, - ]} - > - } - /> +
+ {isEmbedded && ( +
+ +
+ )} - - o.name)} - tickFormat={hideCP ? () => "" : (_, i) => labels[i] ?? ""} - style={v.xAxisStyle({ - tickLabelFontSize, - maxLeftPadding, - maxRightPadding, - getThemeColor, - })} - /> - - - {!hideCP && - communityAreas.map((area, idx) => ( - - datum?.resolved - ? getThemeColor(METAC_COLORS.purple["500"]) - : palette.communityArea, + {!isCompactEmbed && ( +
+ {shouldDisplayChart && ( + + ) + } + events={[ + { + target: "parent", + eventHandlers: { + onMouseOutCapture: () => setActivePoint(null), }, - }} - /> - ))} - {!hideCP && - communityLines.map((line, idx) => ( - + - datum?.resolved - ? getThemeColor(METAC_COLORS.purple["700"]) - : palette.communityLine, - }, + ...baseYAxisStyle, + grid: isEmbedded ? { display: "none" } : baseYAxisStyle?.grid, }} + offsetX={ + isEmbedded ? undefined : v.axisLabelOffsetX(variantArgs) + } + axisLabelComponent={ + isEmbedded ? undefined : + } /> - ))} - - } - /> - {!hideCP && !forecastAvailability?.cpRevealsOn && ( - ({ - ...p, - resolved: false, - symbol: "square", - }))} - style={{ - data: { - fill: () => palette.communityPoint, - stroke: () => palette.communityPoint, - strokeWidth: 6, - strokeOpacity: ({ datum }) => - activePoint === datum.x ? 0.3 : 0, - }, - }} - dataComponent={ - ""} + orientation="right" + style={{ + ...baseYAxisStyle, + tickLabels: { + ...baseYAxisStyle?.tickLabels, + display: "none", + }, + grid: { + ...baseYAxisStyle?.grid, + strokeDasharray: "2,4", + }, + }} /> - } - /> - )} + )} + + + o.name)} + tickFormat={hideCP ? () => "" : (_, i) => labels[i] ?? ""} + style={v.xAxisStyle({ + tickLabelFontSize, + maxLeftPadding: effectiveMaxLeftPadding, + maxRightPadding: effectiveMaxRightPadding, + getThemeColor, + })} + /> + + + {!hideCP && + communityAreas.map((area, idx) => ( + + datum?.resolved + ? getThemeColor(METAC_COLORS.purple["500"]) + : palette.communityArea, + }, + }} + /> + ))} + {!hideCP && + communityLines.map((line, idx) => ( + + datum?.resolved + ? getThemeColor(METAC_COLORS.purple["700"]) + : palette.communityLine, + }, + }} + /> + ))} + + } + /> - {resolutionPoints.map((point) => ( - palette.resolutionStroke, - strokeWidth: 2, - strokeOpacity: 1, - }, - }} - dataComponent={ - ({ + ...p, + resolved: false, + symbol: "square", + }))} + style={{ + data: { + fill: () => palette.communityPoint, + stroke: () => palette.communityPoint, + strokeWidth: 6, + strokeOpacity: ({ datum }) => + activePoint === datum.x ? 0.3 : 0, + }, + }} + dataComponent={ + + } /> - } - /> - ))} - {emptyPoints.map((point) => ( - ( + palette.resolutionStroke, + strokeWidth: 2, + strokeOpacity: 1, + }, + }} + dataComponent={ + + } /> - } - /> - ))} - - )} + ))} + {emptyPoints.map((point) => ( + + } + /> + ))} + + )} - {!withTooltip && ( - + {!withTooltip && ( + + )} +
)}
); @@ -1022,4 +1160,16 @@ function getFanOptionsFromBinaryGroup( }); } +function getEvenTicks(domain: Tuple, count: number): number[] { + const [lo, hi] = domain; + if (count <= 1) return [lo]; + const step = (hi - lo) / (count - 1); + const out: number[] = []; + for (let i = 0; i < count; i++) { + const v = lo + step * i; + out.push(Math.abs(v) < 1e-9 ? 0 : v); + } + return out; +} + export default FanChart; diff --git a/front_end/src/components/charts/fan_chart_variants.ts b/front_end/src/components/charts/fan_chart_variants.ts index 3ee239520e..f954838f9e 100644 --- a/front_end/src/components/charts/fan_chart_variants.ts +++ b/front_end/src/components/charts/fan_chart_variants.ts @@ -44,7 +44,7 @@ export type VariantConfig = { }; axisLabelOffsetX: (args: VariantArgs) => number; forceTickCount?: number; - palette: (args: Pick) => { + palette: (args: Pick) => { communityArea: string; communityLine: string; userArea: string; @@ -82,7 +82,8 @@ export const fanVariants: Record = { ticks: { stroke: "transparent" }, axis: { stroke: "transparent" }, }), - domainPadding: () => ({ x: [150 / 2, 150 / 2] }), + domainPadding: ({ isEmbedded }) => + isEmbedded ? { x: [16, 16] } : { x: [150 / 2, 150 / 2] }, padding: ({ maxLeftPadding }) => ({ left: maxLeftPadding, top: 10, @@ -90,13 +91,15 @@ export const fanVariants: Record = { bottom: 20, }), axisLabelOffsetX: ({ maxLeftPadding }) => Math.max(maxLeftPadding - 2, 8), - palette: ({ getThemeColor }) => ({ + palette: ({ getThemeColor, isEmbedded }) => ({ communityArea: getThemeColor(METAC_COLORS.olive["500"]), communityLine: getThemeColor(METAC_COLORS.olive["800"]), userArea: getThemeColor(METAC_COLORS.orange["500"]), userLine: getThemeColor(METAC_COLORS.orange["700"]), resolutionStroke: getThemeColor(METAC_COLORS.purple["800"]), - communityPoint: getThemeColor(METAC_COLORS.olive["800"]), + communityPoint: isEmbedded + ? getThemeColor(METAC_COLORS.olive["700"]) + : getThemeColor(METAC_COLORS.olive["800"]), }), resolutionPoint: { size: 8, diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 938b4b1a51..c7cc9c9899 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -2,7 +2,7 @@ import { isNil, merge } from "lodash"; import { useTranslations } from "next-intl"; -import React, { FC, memo, useEffect, useMemo, useState } from "react"; +import React, { FC, memo, useEffect, useMemo, useRef, useState } from "react"; import { CursorCoordinatesPropType, DomainTuple, @@ -42,7 +42,6 @@ import { generateScale, generateTimeSeriesYDomain, generateTimestampXScale, - getAxisLeftPadding, getAxisRightPadding, getTickLabelFontSize, } from "@/utils/charts/axis"; @@ -75,11 +74,13 @@ type Props = { isEmptyDomain?: boolean; openTime?: number | null; forceAutoZoom?: boolean; - isEmbedded?: boolean; cursorTimestamp?: number | null; forecastAvailability?: ForecastAvailability; forceShowLinePoints?: boolean; forFeedPage?: boolean; + isEmbedded?: boolean; + showCursorLabel?: boolean; + fadeLinesOnHover?: boolean; }; const LABEL_FONT_FAMILY = "Inter"; @@ -88,6 +89,7 @@ const TICK_FONT_SIZE = 10; const POINT_SIZE = 9; const USER_POINT_SIZE = 6; const USER_POINT_STROKE = 1.5; +const PLOT_TOP = 10; const GroupChart: FC = ({ timestamps, @@ -109,11 +111,13 @@ const GroupChart: FC = ({ isEmptyDomain, openTime, forceAutoZoom, - isEmbedded, cursorTimestamp, forecastAvailability, forceShowLinePoints = false, forFeedPage, + isEmbedded = false, + showCursorLabel = true, + fadeLinesOnHover = true, }) => { const t = useTranslations(); const { @@ -121,6 +125,7 @@ const GroupChart: FC = ({ width: chartWidth, height: chartHeight, } = useContainerSize(); + const inPlotRef = useRef(false); const { theme, getThemeColor } = useAppTheme(); const chartTheme = theme === "dark" ? darkTheme : lightTheme; @@ -178,33 +183,39 @@ const GroupChart: FC = ({ forFeedPage, ] ); + const [localCursorTimestamp, setLocalCursorTimestamp] = useState< + number | null + >(null); + const effectiveCursorTimestamp = !isNil(cursorTimestamp) + ? cursorTimestamp + : localCursorTimestamp; + const plotBottom = + height - (isEmbedded ? BOTTOM_PADDING - 6 : BOTTOM_PADDING); const filteredLines = useMemo(() => { return graphs.map(({ line, active }) => { const lastLineX = line.at(-1)?.x; if (!active || !lastLineX) return null; + + if (isNil(effectiveCursorTimestamp)) return line; + let filteredLine = - !isNil(cursorTimestamp) && lastLineX > cursorTimestamp - ? line.filter(({ x }) => x <= cursorTimestamp) + lastLineX > effectiveCursorTimestamp + ? line.filter(({ x }) => x <= effectiveCursorTimestamp) : line; - if (!isNil(cursorTimestamp) && lastLineX > cursorTimestamp) { + + if (lastLineX > effectiveCursorTimestamp) { filteredLine = [ ...filteredLine, { - x: cursorTimestamp, + x: effectiveCursorTimestamp, y: filteredLine.at(-1)?.y ?? null, }, ]; } + return filteredLine; }); - }, [graphs, cursorTimestamp]); - - const { leftPadding, MIN_LEFT_PADDING } = useMemo(() => { - return getAxisLeftPadding(yScale, tickLabelFontSize as number, yLabel); - }, [yScale, tickLabelFontSize, yLabel]); - const maxLeftPadding = useMemo(() => { - return Math.max(leftPadding, MIN_LEFT_PADDING); - }, [leftPadding, MIN_LEFT_PADDING]); + }, [graphs, effectiveCursorTimestamp]); const { rightPadding, MIN_RIGHT_PADDING } = useMemo(() => { return getAxisRightPadding(yScale, tickLabelFontSize as number, yLabel); @@ -219,6 +230,9 @@ const GroupChart: FC = ({ ); const prevWidth = usePrevious(chartWidth); + const baseLineOpacity = + fadeLinesOnHover && isCursorActive && !isHighlightActive ? 0.35 : 1; + useEffect(() => { if (!prevWidth && chartWidth && onChartReady) { onChartReady(); @@ -236,19 +250,19 @@ const GroupChart: FC = ({ style={{ touchAction: "pan-y", }} - cursorLabelOffset={{ - x: 0, - y: 0, - }} - cursorLabel={({ datum }: VictoryLabelProps) => { - if (datum) { - return datum.x === defaultCursor - ? isClosed - ? "" - : t("now") - : xScale.cursorFormat?.(datum.x) ?? xScale.tickFormat(datum.x); - } - }} + cursorLabelOffset={showCursorLabel ? { x: 0, y: 0 } : undefined} + cursorLabel={ + showCursorLabel + ? ({ datum }: VictoryLabelProps) => { + if (!datum) return ""; + return datum.x === defaultCursor + ? isClosed + ? "" + : t("now") + : xScale.cursorFormat?.(datum.x) ?? xScale.tickFormat(datum.x); + } + : undefined + } cursorComponent={ = ({ /> } cursorLabelComponent={ - - - + showCursorLabel ? ( + + + + ) : undefined } onCursorChange={(value: CursorCoordinatesPropType) => { - if (typeof value === "number" && onCursorChange) { + if (typeof value !== "number") return; + if (!inPlotRef.current) return; + + setLocalCursorTimestamp(value); + + if (onCursorChange) { const lastTimestamp = timestamps[timestamps.length - 1]; if (value === lastTimestamp) { onCursorChange(lastTimestamp, xScale.tickFormat); return; } - onCursorChange(value, xScale.tickFormat); } }} @@ -296,22 +319,41 @@ const GroupChart: FC = ({ height={height} theme={actualTheme} padding={{ - left: isEmbedded ? maxLeftPadding : 0, - right: isEmbedded ? 10 : maxRightPadding, + left: 0, top: 10, - bottom: BOTTOM_PADDING, + right: maxRightPadding, + bottom: isEmbedded ? BOTTOM_PADDING - 6 : BOTTOM_PADDING, }} events={[ { target: "parent", eventHandlers: { - onMouseOverCapture: () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMouseMoveCapture: (e: any) => { if (!onCursorChange) return; - setIsCursorActive(true); + const svg = + (e.currentTarget as SVGElement).ownerSVGElement ?? + e.currentTarget; + const rect = (svg as SVGElement).getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const inPlot = + x >= 0 && + x <= chartWidth - maxRightPadding && + y >= PLOT_TOP && + y <= plotBottom; + inPlotRef.current = inPlot; + setIsCursorActive(inPlot); + if (!inPlot) { + setLocalCursorTimestamp(null); + } }, - onMouseOutCapture: () => { + onMouseLeaveCapture: () => { if (!onCursorChange) return; + inPlotRef.current = false; setIsCursorActive(false); + setLocalCursorTimestamp(null); }, }, }, @@ -366,16 +408,11 @@ const GroupChart: FC = ({ }} label={yLabel} offsetX={ - isEmbedded - ? maxLeftPadding - : isNil(yLabel) - ? chartWidth + 5 - : chartWidth - TICK_FONT_SIZE + 5 + isNil(yLabel) ? chartWidth + 5 : chartWidth - TICK_FONT_SIZE + 5 } orientation={"left"} axisLabelComponent={} /> - {/* X axis */} = ({ chartWidth={chartWidth} withCursor={!!onCursorChange} fontSize={tickLabelFontSize as number} + dx={isEmbedded ? 16 : 0} /> } style={{ @@ -435,7 +473,7 @@ const GroupChart: FC = ({ data: { stroke: getThemeColor(color), strokeOpacity: !isHighlightActive - ? 1 + ? baseLineOpacity : highlighted ? 1 : 0.3, @@ -548,7 +586,6 @@ const GroupChart: FC = ({ /> ); })} - {/* User predictions */} {graphs.map(({ active, scatter, color, highlighted }, index) => active && (!isHighlightActive || highlighted) ? ( @@ -622,6 +659,7 @@ function buildChartData({ openTime, forceAutoZoom, forFeedPage, + isEmbedded, }: { timestamps: number[]; actualCloseTime?: number | null; @@ -638,6 +676,7 @@ function buildChartData({ openTime?: number | null; forceAutoZoom?: boolean; forFeedPage?: boolean; + isEmbedded?: boolean; }): ChartData { const closeTimes = choiceItems .map(({ closeTime }) => closeTime) @@ -898,7 +937,7 @@ function buildChartData({ scaling: scaling, domain: originalYDomain, zoomedDomain: zoomedYDomain, - forceTickCount: forFeedPage ? 3 : 5, + forceTickCount: isEmbedded ? 5 : forFeedPage ? 3 : 5, alwaysShowTicks: true, }); diff --git a/front_end/src/components/charts/minified_continuous_area_chart.tsx b/front_end/src/components/charts/minified_continuous_area_chart.tsx index ccdc7a3bfe..4520eb0087 100644 --- a/front_end/src/components/charts/minified_continuous_area_chart.tsx +++ b/front_end/src/components/charts/minified_continuous_area_chart.tsx @@ -66,6 +66,9 @@ type Props = { alignChartTabs?: boolean; forceTickCount?: number; variant?: "feed" | "question"; + colorOverride?: string; + showBaseline?: boolean; + minMaxLabelsOnly?: boolean; }; const MinifiedContinuousAreaChart: FC = ({ @@ -80,6 +83,9 @@ const MinifiedContinuousAreaChart: FC = ({ alignChartTabs, forceTickCount, variant = "feed", + colorOverride, + showBaseline = false, + minMaxLabelsOnly = false, }) => { const { ref: chartContainerRef, width: containerWidth } = useContainerSize(); @@ -228,9 +234,11 @@ const MinifiedContinuousAreaChart: FC = ({ // However, if there's a resolution point, we need extra padding to prevent clipping const hasResolution = !isNil(question.resolution) && question.resolution !== ""; + const wantsAnyXLabels = !hideCP && (!hideLabels || minMaxLabelsOnly); + if (wantsAnyXLabels) return BOTTOM_PADDING; const baseMinimalPadding = hasResolution ? 8 : 3; // Extra padding for resolution diamond - return hideCP || hideLabels ? baseMinimalPadding : BOTTOM_PADDING; - }, [hideCP, hideLabels, question.resolution]); + return baseMinimalPadding; + }, [hideCP, hideLabels, minMaxLabelsOnly, question.resolution]); return (
@@ -256,6 +264,22 @@ const MinifiedContinuousAreaChart: FC = ({ /> } > + {showBaseline && ( + + )} {charts .filter((chart) => chart.type !== "user_components") .map((chart, index) => { @@ -268,6 +292,7 @@ const MinifiedContinuousAreaChart: FC = ({ style={{ data: { fill: (() => { + if (colorOverride) return colorOverride; if (extraTheme?.area?.style?.data?.fill) { return extraTheme.area.style.data.fill; } @@ -336,6 +361,7 @@ const MinifiedContinuousAreaChart: FC = ({ style={{ data: { stroke: (() => { + if (colorOverride) return colorOverride; switch (chart.color) { case "orange": return getThemeColor(METAC_COLORS.orange["600"]); @@ -356,7 +382,20 @@ const MinifiedContinuousAreaChart: FC = ({ "" : xScale.tickFormat} + tickFormat={(tick: number, index?: number, ticks?: number[]) => { + if (hideCP) return ""; + + if (hideLabels && !minMaxLabelsOnly) return ""; + + if (minMaxLabelsOnly) { + const last = (ticks?.length ?? 0) - 1; + if (index === 0 || index === last) + return xScale.tickFormat(tick); + return ""; + } + + return xScale.tickFormat(tick); + }} style={{ ticks: { strokeWidth: 1, @@ -393,6 +432,7 @@ const MinifiedContinuousAreaChart: FC = ({ style={{ data: { stroke: (() => { + if (colorOverride) return colorOverride; switch (chart.color) { case "gray": return getThemeColor(METAC_COLORS.gray["500"]); @@ -417,6 +457,7 @@ const MinifiedContinuousAreaChart: FC = ({ style={{ data: { fill: (() => { + if (colorOverride) return colorOverride; switch (chart.color) { case "gray": return getThemeColor(METAC_COLORS.gray["600"]); @@ -425,6 +466,7 @@ const MinifiedContinuousAreaChart: FC = ({ } })(), stroke: (() => { + if (colorOverride) return colorOverride; switch (chart.color) { case "gray": return getThemeColor(METAC_COLORS.gray["600"]); diff --git a/front_end/src/components/charts/multiple_choice_chart.tsx b/front_end/src/components/charts/multiple_choice_chart.tsx index 350f707a74..36fe986559 100644 --- a/front_end/src/components/charts/multiple_choice_chart.tsx +++ b/front_end/src/components/charts/multiple_choice_chart.tsx @@ -42,7 +42,6 @@ import { generateScale, generateTimestampXScale, generateTimeSeriesYDomain, - getAxisLeftPadding, getTickLabelFontSize, getAxisRightPadding, } from "@/utils/charts/axis"; @@ -55,6 +54,7 @@ import ChartCursorLabel from "./primitives/chart_cursor_label"; import XTickLabel from "./primitives/x_tick_label"; import ForecastAvailabilityChartOverflow from "../post_card/chart_overflow"; import SvgWrapper from "./primitives/svg_wrapper"; +import YTickLabel from "./primitives/y_tick_label"; type ColoredLinePoint = { x: number; @@ -181,12 +181,6 @@ const MultipleChoiceChart: FC = ({ forFeedPage, ] ); - const { leftPadding, MIN_LEFT_PADDING } = useMemo(() => { - return getAxisLeftPadding(yScale, tickLabelFontSize as number, yLabel); - }, [yScale, tickLabelFontSize, yLabel]); - const maxLeftPadding = useMemo(() => { - return Math.max(leftPadding, MIN_LEFT_PADDING); - }, [leftPadding, MIN_LEFT_PADDING]); const { rightPadding, MIN_RIGHT_PADDING } = useMemo(() => { return getAxisRightPadding(yScale, tickLabelFontSize as number, yLabel); @@ -240,7 +234,10 @@ const MultipleChoiceChart: FC = ({ /> } cursorLabelComponent={ - + } onCursorChange={(value: CursorCoordinatesPropType) => { if (typeof value === "number" && onCursorChange) { @@ -258,6 +255,14 @@ const MultipleChoiceChart: FC = ({ /> ); + const topPadding = isEmbedded ? 0 : height < 150 ? 5 : 10; + const BASE_BOTTOM_PADDING = 20; + const EMBED_EXTRA_BOTTOM_PADDING = 6; + + const bottomPadding = isEmbedded + ? BASE_BOTTOM_PADDING - EMBED_EXTRA_BOTTOM_PADDING + : BASE_BOTTOM_PADDING; + return (
= ({ height={height} theme={actualTheme} padding={{ - left: isEmbedded ? maxLeftPadding : 0, - top: height < 150 ? 5 : 10, - right: isEmbedded ? 10 : maxRightPadding, - bottom: BOTTOM_PADDING, + left: 0, + top: topPadding, + right: maxRightPadding, + bottom: bottomPadding, }} events={[ { @@ -343,6 +348,12 @@ const MultipleChoiceChart: FC = ({ dependentAxis tickValues={yScale.ticks} tickFormat={yScale.tickFormat} + tickLabelComponent={ + + } style={{ ticks: { stroke: "transparent", @@ -373,11 +384,7 @@ const MultipleChoiceChart: FC = ({ }} label={yLabel} offsetX={ - isEmbedded - ? maxLeftPadding - : isNil(yLabel) - ? chartWidth + 5 - : chartWidth - TICK_FONT_SIZE + 5 + isNil(yLabel) ? chartWidth + 5 : chartWidth - TICK_FONT_SIZE + 5 } orientation={"left"} axisLabelComponent={} @@ -398,6 +405,7 @@ const MultipleChoiceChart: FC = ({ chartWidth={chartWidth} withCursor={!!onCursorChange} fontSize={tickLabelFontSize as number} + dx={isEmbedded ? 16 : 0} /> } diff --git a/front_end/src/components/charts/numeric_chart.tsx b/front_end/src/components/charts/numeric_chart.tsx index a894ae3989..baf46f839b 100644 --- a/front_end/src/components/charts/numeric_chart.tsx +++ b/front_end/src/components/charts/numeric_chart.tsx @@ -19,7 +19,6 @@ import { VictoryChart, VictoryContainer, VictoryCursorContainer, - VictoryLabel, VictoryLabelProps, VictoryLine, VictoryPortal, @@ -55,6 +54,7 @@ import { } from "@/utils/charts/axis"; import { findLastIndexBefore } from "@/utils/charts/helpers"; import cn from "@/utils/core/cn"; +import { resolveToCssColor } from "@/utils/resolve_color"; import ForecastAvailabilityChartOverflow from "../post_card/chart_overflow"; import ChartValueBox from "./primitives/chart_value_box"; @@ -85,7 +85,7 @@ type Props = { cursorTimestamp?: number | null; onCursorChange?: (value: number | null) => void; getCursorValue?: (value: number) => string; - colorOverride?: ThemeColor; + colorOverride?: ThemeColor | string; nonInteractive?: boolean; isEmbedded?: boolean; simplifiedCursor?: boolean; @@ -211,9 +211,6 @@ const NumericChart: FC = ({ return getAxisLeftPadding(yScale, tickLabelFontSize as number, yLabel); }, [yScale, tickLabelFontSize, yLabel]); - const maxLeftPadding = useMemo(() => { - return Math.max(leftPadding, MIN_LEFT_PADDING); - }, [leftPadding, MIN_LEFT_PADDING]); const maxRightPadding = useMemo(() => { return Math.max(rightPadding, MIN_RIGHT_PADDING); }, [rightPadding, MIN_RIGHT_PADDING]); @@ -405,6 +402,73 @@ const NumericChart: FC = ({ })); }, [points, yMin, yMax]); + const themeLineData = ( + actualTheme?.line as + | { style?: { data?: Record } } + | undefined + )?.style?.data as Record | undefined; + + const themeAreaData = ( + actualTheme?.area as + | { style?: { data?: Record } } + | undefined + )?.style?.data as Record | undefined; + + const asCssColor = (v: unknown): string | undefined => + typeof v === "string" && v.trim().length ? v : undefined; + + const cpLineStroke = useMemo(() => { + const overrideCss = resolveToCssColor(getThemeColor, colorOverride); + if (overrideCss) return overrideCss; + + if (hasExternalTheme) { + const fromTheme = asCssColor(themeLineData?.stroke); + if (fromTheme) return fromTheme; + } + return getThemeColor(colorPalette.lineStroke); + }, [ + colorOverride, + hasExternalTheme, + themeLineData?.stroke, + getThemeColor, + colorPalette.lineStroke, + ]); + + const cpRangeFill = useMemo(() => { + const overrideCss = resolveToCssColor(getThemeColor, colorOverride); + if (overrideCss) return overrideCss; + + if (hasExternalTheme) { + const fromTheme = asCssColor(themeAreaData?.fill); + if (fromTheme) return fromTheme; + } + return getThemeColor(colorPalette.cpRange); + }, [ + colorOverride, + hasExternalTheme, + themeAreaData?.fill, + getThemeColor, + colorPalette.cpRange, + ]); + + const cpRangeOpacity = useMemo(() => { + if (!hasExternalTheme) return 0.3; + const fromTheme = themeAreaData?.opacity; + return typeof fromTheme === "number" ? fromTheme : 0.3; + }, [hasExternalTheme, themeAreaData?.opacity]); + + const showRightYAxis = isEmbedded; + + const chartPadding = { + top: 10, + bottom: isEmbedded ? BOTTOM_PADDING + 15 : BOTTOM_PADDING, + + left: showRightYAxis ? 10 : Math.max(leftPadding, MIN_LEFT_PADDING), + right: showRightYAxis + ? Math.max(rightPadding, MIN_RIGHT_PADDING) + : Math.max(rightPadding, MIN_RIGHT_PADDING), + }; + return ( <>
= ({ className="text-xs text-gray-700 dark:text-gray-700-dark" textClassName="pl-0" style={{ - paddingRight: isEmbedded ? 10 : maxRightPadding, - paddingLeft: isEmbedded ? maxLeftPadding : 0, + paddingRight: isEmbedded ? maxRightPadding : maxRightPadding, + paddingLeft: isEmbedded ? 0 : 0, paddingTop: withZoomPicker ? 24 : 0, }} /> @@ -440,20 +504,36 @@ const NumericChart: FC = ({ width={chartWidth} height={height} theme={actualTheme} - padding={{ - right: isEmbedded ? 10 : maxRightPadding, - top: 10, - left: isEmbedded ? maxLeftPadding : 10, - bottom: BOTTOM_PADDING, - }} + padding={chartPadding} events={chartEvents} containerComponent={containerComponent} > - {/* Y axis */} + {/* Y axis used for GRIDLINES */} + + + {/* Y axis used for LABELS */} = ({ ? {} : { fill: getThemeColor(METAC_COLORS.gray["700"]) }), }, - axis: { stroke: "transparent" }, - grid: { - ...(hasExternalTheme - ? {} - : { stroke: getThemeColor(METAC_COLORS.gray["400"]) }), - strokeWidth: 1, - strokeDasharray: "3, 2", - }, }} tickValues={yScaleTicks} tickFormat={yScale.tickFormat} label={!isNil(yLabel) ? `(${yLabel})` : undefined} - orientation={"left"} - offsetX={ - isEmbedded - ? maxLeftPadding - : isNil(yLabel) - ? chartWidth + 5 - : chartWidth - tickLabelFontSize + 5 - } - axisLabelComponent={} /> {/* X axis */} @@ -498,7 +561,7 @@ const NumericChart: FC = ({ ticks: { stroke: "transparent" }, axis: { stroke: "transparent" }, }} - offsetY={isEmbedded ? 0 : BOTTOM_PADDING} + offsetY={isEmbedded ? BOTTOM_PADDING - 5 : BOTTOM_PADDING} tickValues={xScale.ticks} tickFormat={ hideCP @@ -530,10 +593,8 @@ const NumericChart: FC = ({ data={area} style={{ data: { - opacity: 0.3, - fill: isNil(colorOverride) - ? getThemeColor(colorPalette.cpRange) - : getThemeColor(colorOverride), + opacity: cpRangeOpacity, + fill: cpRangeFill, }, }} interpolation="stepAfter" @@ -547,9 +608,7 @@ const NumericChart: FC = ({ style={{ data: { strokeWidth: 2.5, - stroke: isNil(colorOverride) - ? getThemeColor(colorPalette.lineStroke) - : getThemeColor(colorOverride), + stroke: cpLineStroke, opacity: 0.2, }, }} @@ -564,9 +623,7 @@ const NumericChart: FC = ({ style={{ data: { strokeWidth: simplifiedCursor ? 2.5 : 1.5, - stroke: isNil(colorOverride) - ? getThemeColor(colorPalette.lineStroke) - : getThemeColor(colorOverride), + stroke: cpLineStroke, }, }} interpolation="stepAfter" @@ -577,7 +634,11 @@ const NumericChart: FC = ({ } + dataComponent={ + + } /> @@ -652,10 +713,10 @@ const NumericChart: FC = ({ } chartWidth={chartWidth} rightPadding={maxRightPadding} - colorOverride={colorOverride ?? colorPalette.chip} getCursorValue={getCursorValue} resolution={resolution} questionType={questionType} + colorOverride={colorOverride ?? colorPalette.chip} /> )} @@ -696,30 +757,18 @@ const NumericChart: FC = ({ const CursorChip: FC<{ x?: number; y?: number; - colorOverride?: ThemeColor; + colorOverride?: ThemeColor | string; shouldRender?: boolean; isEmbedded?: boolean; -}> = (props) => { +}> = ({ x, y, colorOverride, shouldRender, isEmbedded }) => { const { getThemeColor } = useAppTheme(); - const { x, y, colorOverride, shouldRender, isEmbedded } = props; - const innerCircleRadius = isEmbedded ? 5 : 4; - if (isNil(x) || isNil(y) || !shouldRender) return null; - return ( - - - - ); + const fill = + resolveToCssColor(getThemeColor, colorOverride) ?? + getThemeColor(METAC_COLORS.olive["700"]); + + return ; }; export default memo(NumericChart); diff --git a/front_end/src/components/charts/numeric_timeline.tsx b/front_end/src/components/charts/numeric_timeline.tsx index 53a9b84130..a66ead3118 100644 --- a/front_end/src/components/charts/numeric_timeline.tsx +++ b/front_end/src/components/charts/numeric_timeline.tsx @@ -14,6 +14,7 @@ import { Scaling, UserForecastHistory, } from "@/types/question"; +import { ThemeColor } from "@/types/theme"; import { getResolutionPoint } from "@/utils/charts/resolution"; import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { formatResolution } from "@/utils/formatters/resolution"; @@ -53,6 +54,7 @@ type Props = { cursorTooltip?: ReactNode; isConsumerView?: boolean; forFeedPage?: boolean; + colorOverride?: ThemeColor | string; }; const NumericTimeline: FC = ({ @@ -86,6 +88,7 @@ const NumericTimeline: FC = ({ cursorTooltip, isConsumerView, forFeedPage, + colorOverride, }) => { const locale = useLocale(); const resolutionPoint = useMemo(() => { @@ -211,6 +214,7 @@ const NumericTimeline: FC = ({ cursorTooltip={cursorTooltip} isConsumerView={isConsumerView} questionType={questionType} + colorOverride={colorOverride} /> ); }; diff --git a/front_end/src/components/charts/primitives/chart_container.tsx b/front_end/src/components/charts/primitives/chart_container.tsx index a7b7c17806..c244050045 100644 --- a/front_end/src/components/charts/primitives/chart_container.tsx +++ b/front_end/src/components/charts/primitives/chart_container.tsx @@ -3,6 +3,7 @@ import { Tab, TabGroup, TabList } from "@headlessui/react"; import { isNil } from "lodash"; import { forwardRef, Fragment, PropsWithChildren, useState } from "react"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import { TimelineChartZoomOption } from "@/types/charts"; import { getChartZoomOptions } from "@/utils/charts/helpers"; import cn from "@/utils/core/cn"; @@ -31,12 +32,22 @@ const ChartContainer = forwardRef>( } }; + const isEmbed = useIsEmbedMode(); + return (
- {(!!chartTitle || !!zoom) && ( -
+ {(!!chartTitle || !!zoom) && !isEmbed && ( +
{!!chartTitle && ( -
+
{chartTitle}
)} @@ -60,7 +71,9 @@ const ChartContainer = forwardRef>( { "bg-gray-300 dark:bg-gray-300-dark": hover || selected, - } + }, + isEmbed && + "uppercase text-gray-600 dark:text-gray-600-dark md:text-xs" )} > {option.label} diff --git a/front_end/src/components/charts/primitives/chart_value_box.tsx b/front_end/src/components/charts/primitives/chart_value_box.tsx index 29cd6847a0..ed9b1e8768 100644 --- a/front_end/src/components/charts/primitives/chart_value_box.tsx +++ b/front_end/src/components/charts/primitives/chart_value_box.tsx @@ -1,12 +1,12 @@ -"use client"; import { isNil } from "lodash"; -import { FC, useEffect, useRef, useState } from "react"; +import { FC, useEffect, useMemo, useRef, useState } from "react"; import { METAC_COLORS } from "@/constants/colors"; import useAppTheme from "@/hooks/use_app_theme"; import { Resolution } from "@/types/post"; import { QuestionType } from "@/types/question"; import { ThemeColor } from "@/types/theme"; +import { resolveToCssColor } from "@/utils/resolve_color"; const ChartValueBox: FC<{ x?: number | null; @@ -15,7 +15,7 @@ const ChartValueBox: FC<{ isCursorActive: boolean; chartWidth: number; rightPadding: number; - colorOverride?: ThemeColor; + colorOverride?: ThemeColor | string; getCursorValue?: (value: number) => string; resolution?: Resolution | null; isDistributionChip?: boolean; @@ -38,17 +38,22 @@ const ChartValueBox: FC<{ const TEXT_PADDING = 4; const CHIP_OFFSET = !isNil(resolution) ? 8 : 0; + const displayText = useMemo(() => { + if (!!resolution && !isCursorActive) return String(resolution); + const v = datum?.y; + if (typeof v !== "number") return ""; + return getCursorValue ? getCursorValue(v) : v.toFixed(1); + }, [resolution, isCursorActive, datum?.y, getCursorValue]); + const [textWidth, setTextWidth] = useState(0); const textRef = useRef(null); useEffect(() => { if (textRef.current) { setTextWidth(textRef.current.getBBox().width + TEXT_PADDING); } - }, [datum?.y]); + }, [displayText]); - if (isNil(x) || isNil(y)) { - return null; - } + if (isNil(x) || isNil(y)) return null; const adjustedX = isCursorActive || isDistributionChip @@ -58,9 +63,12 @@ const ChartValueBox: FC<{ const chipFontSize = 12; const hasResolution = !!resolution && !isCursorActive; + const chipFill = + resolveToCssColor(getThemeColor, colorOverride) ?? + getThemeColor(METAC_COLORS.olive["600"]); + return ( - {/* "RESOLVED" label above the chip for resolution values */} {hasResolution && questionType !== QuestionType.Binary && ( - {!!resolution && !isCursorActive - ? resolution - : getCursorValue - ? getCursorValue(datum?.y as number) - : datum?.y.toFixed(1)} + {displayText} ); diff --git a/front_end/src/components/charts/primitives/embed_fan_legend.tsx b/front_end/src/components/charts/primitives/embed_fan_legend.tsx new file mode 100644 index 0000000000..189cfc88ee --- /dev/null +++ b/front_end/src/components/charts/primitives/embed_fan_legend.tsx @@ -0,0 +1,25 @@ +type Props = { + items: Array<{ name: string; valueText: string }>; +}; + +const EmbedFanLegend: React.FC = ({ items }) => { + if (!items.length) return null; + + return ( +
+ {items.map((i) => ( +
+ {i.name} + + {i.valueText} + +
+ ))} +
+ ); +}; + +export default EmbedFanLegend; diff --git a/front_end/src/components/charts/primitives/prediction_with_range.tsx b/front_end/src/components/charts/primitives/prediction_with_range.tsx index 6f837beeb1..60cdfa6c25 100644 --- a/front_end/src/components/charts/primitives/prediction_with_range.tsx +++ b/front_end/src/components/charts/primitives/prediction_with_range.tsx @@ -5,6 +5,7 @@ import { D3Scale } from "victory"; import { METAC_COLORS } from "@/constants/colors"; import useAppTheme from "@/hooks/use_app_theme"; +import { ThemeColor } from "@/types/theme"; type PredictionWithRangeProps = { x?: number; @@ -15,6 +16,7 @@ type PredictionWithRangeProps = { y?: D3Scale; }; datum?: { y1: number; y2: number }; + colorOverride?: ThemeColor | string; }; const PredictionWithRange: FC = (props) => { diff --git a/front_end/src/components/charts/primitives/x_tick_label.tsx b/front_end/src/components/charts/primitives/x_tick_label.tsx index ea984d9d5c..e86605402c 100644 --- a/front_end/src/components/charts/primitives/x_tick_label.tsx +++ b/front_end/src/components/charts/primitives/x_tick_label.tsx @@ -5,19 +5,24 @@ type Props = ComponentProps & { chartWidth: number; withCursor?: boolean; fontSize?: number; + dx?: number; }; const XTickLabel: FC = ({ chartWidth, withCursor, fontSize = 10, + dx = 0, ...props }) => { - const estimatedTextWidth = - ((props.text?.toString().length ?? 0) * fontSize) / 2; + const text = props.text?.toString() ?? ""; + const estimatedTextWidth = (text.length * fontSize) / 2; + + const x = (props.x ?? 0) + dx; + const overlapsRightEdge = withCursor - ? (props.x ?? 0) > chartWidth - estimatedTextWidth - : (props.x ?? 0) > chartWidth - 12; + ? x > chartWidth - estimatedTextWidth + : x > chartWidth - 12; if (overlapsRightEdge) { return null; @@ -26,9 +31,10 @@ const XTickLabel: FC = ({ return ( ); diff --git a/front_end/src/components/charts/primitives/y_tick_label.tsx b/front_end/src/components/charts/primitives/y_tick_label.tsx new file mode 100644 index 0000000000..e8a7f32b79 --- /dev/null +++ b/front_end/src/components/charts/primitives/y_tick_label.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, FC } from "react"; +import { VictoryLabel } from "victory"; + +type Props = ComponentProps & { + nudgeTop?: number; + nudgeBottom?: number; +}; + +const YTickLabel: FC = ({ nudgeTop = 0, nudgeBottom = 0, ...props }) => { + const text = props.text?.toString(); + + let extraDy = 0; + + if (text === "100%") extraDy = nudgeTop; + if (text === "0%") extraDy = -nudgeBottom; + + const baseDy = typeof props.dy === "number" ? props.dy : 0; + return ; +}; + +export default YTickLabel; 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 023e958d60..4a84ea67d7 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 @@ -1,7 +1,10 @@ +"use client"; + import { isNil } from "lodash"; import { useTranslations } from "next-intl"; import { FC, useId } from "react"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import { useHideCP } from "@/contexts/cp_context"; import { QuestionStatus } from "@/types/post"; import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; @@ -12,12 +15,19 @@ type Props = { question: QuestionWithNumericForecasts; size?: "xs" | "sm" | "md" | "lg"; className?: string; + colorOverride?: string; }; -const BinaryCPBar: FC = ({ question, size = "md", className }) => { +const BinaryCPBar: FC = ({ + question, + size = "md", + className, + colorOverride, +}) => { const t = useTranslations(); const { hideCP } = useHideCP(); const gradientId = useId(); + const isEmbed = useIsEmbedMode(); const questionCP = question.aggregations[question.default_aggregation_method]?.latest @@ -34,10 +44,10 @@ const BinaryCPBar: FC = ({ question, size = "md", className }) => { ? Math.round((questionCP as number) * 1000) / 10 : null; - const width = 112; - const height = 66; - const strokeWidth = 12; - const strokeCursorWidth = 17; + const width = isEmbed ? 85 : 112; + const height = isEmbed ? 50 : 66; + const strokeWidth = isEmbed ? 8 : 12; + const strokeCursorWidth = isEmbed ? 12 : 17; const radius = (width - strokeWidth) / 2; const arcAngle = Math.PI * 1.1; const center = { x: width / 2, y: height - strokeWidth }; @@ -61,11 +71,17 @@ const BinaryCPBar: FC = ({ question, size = "md", className }) => { }) : null; - const { textClass, strokeClass, hex } = getBinaryGaugeColors( + const { + textClass, + strokeClass, + hex: defaultHex, + } = getBinaryGaugeColors( cpPercentage ?? 0, isClosed || isNil(questionCP) || hideCP ); + const hex = colorOverride ?? defaultHex; + const startAngle = Math.PI - (arcAngle - Math.PI) / 2; const endAngle = startAngle + ((cpPercentage ?? 0) / 100) * arcAngle; const gradientStartX = center.x + radius * Math.cos(startAngle); @@ -83,6 +99,7 @@ const BinaryCPBar: FC = ({ question, size = "md", className }) => { "scale-100": size === "md", "mb-4 scale-[1.25]": size === "lg", }, + isEmbed && "scale-100", className )} > @@ -112,7 +129,7 @@ const BinaryCPBar: FC = ({ question, size = "md", className }) => { stroke={hex} strokeOpacity={0.15} strokeWidth={strokeWidth} - className={strokeClass} + className={!colorOverride ? strokeClass : undefined} /> {/* Progress arc */} @@ -145,30 +162,39 @@ const BinaryCPBar: FC = ({ question, size = "md", className }) => { 2 * Math.sin(progressArc.angle + Math.PI / 2) } stroke={hex} - className={strokeClass} + className={!colorOverride ? strokeClass : undefined} strokeWidth={strokeCursorWidth} /> )}
{cpPercentage != null ? `${cpPercentage}%` : "%"} {t("chance")} diff --git a/front_end/src/components/consumer_post_card/question_resolution_chip.tsx b/front_end/src/components/consumer_post_card/question_resolution_chip.tsx index 7efbab857b..509b774ce3 100644 --- a/front_end/src/components/consumer_post_card/question_resolution_chip.tsx +++ b/front_end/src/components/consumer_post_card/question_resolution_chip.tsx @@ -1,6 +1,7 @@ import { useLocale, useTranslations } from "next-intl"; import { FC } from "react"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import { QuestionWithNumericForecasts } from "@/types/question"; import cn from "@/utils/core/cn"; import { formatResolution } from "@/utils/formatters/resolution"; @@ -24,6 +25,7 @@ const QuestionResolutionChip: FC = ({ size = "md", }) => { const t = useTranslations(); + const isEmbed = useIsEmbedMode(); return (
= ({ // Mobile-first responsive sizing "text-[10px] md:text-xs": size === "sm", "text-xs md:text-sm": size === "md", + "md:text-xs": size === "md" && isEmbed, "text-sm md:text-base": size === "lg", } )} @@ -59,6 +62,7 @@ const QuestionResolutionChip: FC = ({ // Mobile-first responsive sizing "text-sm md:text-base": size === "sm", "text-lg md:text-xl": size === "md", + "md:text-lg": size === "md" && isEmbed, "text-xl md:text-2xl": size === "lg", } )} 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 f90dc0574b..7bb35750cc 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 @@ -1,20 +1,33 @@ "use client"; -import React, { FC, useEffect } from "react"; +import { FC, useEffect, useMemo } from "react"; +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 { ContinuousQuestionTypes } from "@/constants/questions"; import { useHideCP } from "@/contexts/cp_context"; +import useTimestampCursor from "@/hooks/use_timestamp_cursor"; import { GroupOfQuestionsGraphType, GroupOfQuestionsPost, PostStatus, } from "@/types/post"; -import { QuestionWithNumericForecasts } from "@/types/question"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; import { sendAnalyticsEvent } from "@/utils/analytics"; +import { getGroupQuestionsTimestamps } from "@/utils/charts/timestamps"; +import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices"; import { getGroupForecastAvailability } from "@/utils/questions/forecastAvailability"; -import { getPostDrivenTime } from "@/utils/questions/helpers"; +import { + getContinuousGroupScaling, + getPostDrivenTime, +} from "@/utils/questions/helpers"; +import { getCommonUnit } from "@/utils/questions/units"; + +import { getMaxVisibleCheckboxes } from "../embeds"; type Props = { post: GroupOfQuestionsPost; @@ -26,6 +39,9 @@ type Props = { groupPresentationOverride?: GroupOfQuestionsGraphType; className?: string; prioritizeOpenSubquestions?: boolean; + embedChartHeight?: number; + onLegendHeightChange?: (height: number) => void; + chartTheme?: VictoryThemeDefinition; }; const DetailedGroupCard: FC = ({ @@ -34,6 +50,9 @@ const DetailedGroupCard: FC = ({ groupPresentationOverride, className, prioritizeOpenSubquestions = false, + embedChartHeight, + onLegendHeightChange, + chartTheme, }) => { const { open_time, @@ -65,7 +84,41 @@ const DetailedGroupCard: FC = ({ } }, [groupPresentationOverride, hasUserForecast]); + const isEmbed = useIsEmbedMode(); + + const maxVisibleCheckboxes = useMemo( + () => getMaxVisibleCheckboxes(isEmbed), + [isEmbed] + ); + const forecastAvailability = getGroupForecastAvailability(questions); + + const groupType = questions[0]?.type; + const isContinuousGroup = + !!groupType && ContinuousQuestionTypes.some((t) => t === groupType); + + const commonUnit = useMemo(() => { + if (!isContinuousGroup) return null; + return getCommonUnit( + questions.map((q) => ({ + unit: q.unit, + scaling: q.scaling ? { unit: q.unit } : null, + })) + ); + }, [isContinuousGroup, questions]); + + const groupScaling = useMemo( + () => + isContinuousGroup ? getContinuousGroupScaling(questions) : undefined, + [isContinuousGroup, questions] + ); + const timestamps = getGroupQuestionsTimestamps(questions, { + withUserTimestamps: !!forecastAvailability.cpRevealsOn, + }); + + const [_cursorTimestamp, _tooltipDate, handleCursorChange] = + useTimestampCursor(timestamps); + if ( forecastAvailability.isEmpty && forecastAvailability.cpRevealsOn && @@ -76,6 +129,48 @@ const DetailedGroupCard: FC = ({ switch (presentationType) { case GroupOfQuestionsGraphType.MultipleChoiceGraph: { + if (isEmbed && (groupType === QuestionType.Binary || isContinuousGroup)) { + const timestamps = getGroupQuestionsTimestamps(questions, { + withUserTimestamps: !!forecastAvailability.cpRevealsOn, + }); + + const choiceItems = generateChoiceItemsFromGroupQuestions( + post.group_of_questions, + { + activeCount: maxVisibleCheckboxes, + preselectedQuestionId, + } + ); + + return ( + <> + + {hideCP && } + + ); + } + return ( <> = ({ hideCP={hideCP} className={className} prioritizeOpen={prioritizeOpenSubquestions} + embedMode={isEmbed} + chartHeight={embedChartHeight} + chartTheme={chartTheme} /> {hideCP && } @@ -99,6 +197,9 @@ const DetailedGroupCard: FC = ({ group={post.group_of_questions} hideCP={hideCP} withTooltip + height={embedChartHeight} + isEmbedded={isEmbed} + onLegendHeightChange={onLegendHeightChange} /> {hideCP && } 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 c783436e93..40bcf29b86 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,8 +1,10 @@ "use client"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import React, { FC, ReactNode, useCallback, useMemo, useState } from "react"; +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 NumericTimeline from "@/components/charts/numeric_timeline"; import QuestionPredictionTooltip from "@/components/charts/primitives/question_prediction_tooltip"; @@ -13,6 +15,7 @@ import { QuestionType, QuestionWithNumericForecasts, } from "@/types/question"; +import { ThemeColor } from "@/types/theme"; import { getCursorForecast } from "@/utils/charts/cursor"; import cn from "@/utils/core/cn"; import { isForecastActive } from "@/utils/forecasts/helpers"; @@ -33,6 +36,11 @@ type Props = { forecastAvailability?: ForecastAvailability; hideTitle?: boolean; isConsumerView?: boolean; + embedChartHeight?: number; + extraTheme?: VictoryThemeDefinition; + colorOverride?: ThemeColor | string; + defaultZoom?: TimelineChartZoomOption; + withZoomPicker?: boolean; }; const DetailedContinuousChartCard: FC = ({ @@ -42,9 +50,19 @@ const DetailedContinuousChartCard: FC = ({ forecastAvailability, hideTitle, isConsumerView: isConsumerViewProp, + embedChartHeight, + extraTheme, + colorOverride, + defaultZoom, + withZoomPicker, }) => { const t = useTranslations(); const { user } = useAuth(); + const effectiveDefaultZoom = + defaultZoom ?? + (user ? TimelineChartZoomOption.All : TimelineChartZoomOption.TwoMonths); + + const effectiveWithZoomPicker = withZoomPicker ?? true; const isConsumerView = isConsumerViewProp ?? !user; const [isChartReady, setIsChartReady] = useState(false); @@ -180,6 +198,70 @@ const DetailedContinuousChartCard: FC = ({ question.status, ]); + const isEmbed = useIsEmbedMode(); + const shouldOverlayCp = + isEmbed && + !hideCP && + !forecastAvailability?.isEmpty && + !forecastAvailability?.cpRevealsOn && + (question.type === QuestionType.Binary || isContinuousQuestion(question)); + + const timelineTitle = + !isEmbed && !hideTitle ? t("forecastTimelineHeading") : undefined; + + const chartHeight = embedChartHeight ?? 150; + + const renderTimeline = () => ( + + ); + + const cpColorOverride: string | undefined = + typeof colorOverride === "string" ? colorOverride : undefined; + + const overlayNode = ( + + ); + return (
= ({ > {!isConsumerView ? ( <> - {/* Large screens: side-by-side layout */} + {/* Desktop */}
- {isContinuousQuestion(question) && ( + {isContinuousQuestion(question) && !isEmbed && ( )} +
-
- {/* Small screens: timeline only (CP status shown in header) */} + {/* Mobile */}
-
) : (
-
)} @@ -330,6 +312,32 @@ const DetailedContinuousChartCard: FC = ({ ); }; +type OverlayableTimelineProps = { + enabled: boolean; + timeline: ReactNode; + overlay: ReactNode; +}; + +const OverlayableTimeline: FC = ({ + enabled, + timeline, + overlay, +}) => { + if (!enabled) return <>{timeline}; + + return ( +
+
+ {timeline} +
+ +
+ {overlay} +
+
+ ); +}; + function renderDisplayValue(displayValue: string): ReactNode { const displayValueChunks = displayValue.split("\n"); if (displayValueChunks.length > 1) { 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 9a28d5dd78..506c620eab 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 @@ -1,10 +1,13 @@ "use client"; -import React, { FC, useEffect } from "react"; +import { FC, useEffect } from "react"; +import { VictoryThemeDefinition } from "victory"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import RevealCPButton from "@/app/(main)/questions/[id]/components/reveal_cp_button"; import { useHideCP } from "@/contexts/cp_context"; import { PostStatus, QuestionPost } from "@/types/post"; import { QuestionType, QuestionWithForecasts } from "@/types/question"; +import { ThemeColor } from "@/types/theme"; import { sendAnalyticsEvent } from "@/utils/analytics"; import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; @@ -16,18 +19,28 @@ type Props = { post: QuestionPost; hideTitle?: boolean; isConsumerView?: boolean; + embedChartHeight?: number; + onLegendHeightChange?: (height: number) => void; + chartTheme?: VictoryThemeDefinition; + colorOverride?: ThemeColor | string; }; const DetailedQuestionCard: FC = ({ post, hideTitle, isConsumerView, + embedChartHeight, + onLegendHeightChange, + chartTheme, + colorOverride, }) => { const { question, status, nr_forecasters } = post; const forecastAvailability = getQuestionForecastAvailability(question); const { hideCP } = useHideCP(); + const isEmbed = useIsEmbedMode(); + useEffect(() => { if (!!question.my_forecasts?.history.length) { sendAnalyticsEvent("visitPredictedQuestion", { @@ -54,6 +67,9 @@ const DetailedQuestionCard: FC = ({ nrForecasters={nr_forecasters} hideTitle={hideTitle} isConsumerView={isConsumerView} + embedChartHeight={embedChartHeight} + extraTheme={chartTheme} + colorOverride={colorOverride} /> {hideCP && } @@ -65,6 +81,10 @@ const DetailedQuestionCard: FC = ({ question={question} hideCP={hideCP} forecastAvailability={forecastAvailability} + embedMode={isEmbed} + chartHeight={embedChartHeight} + onLegendHeightChange={onLegendHeightChange} + chartTheme={chartTheme} /> {hideCP && } 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 3c535253b2..34afabb366 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 @@ -1,11 +1,12 @@ "use client"; import { uniq } from "lodash"; import { useTranslations } from "next-intl"; -import React, { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { VictoryThemeDefinition } from "victory"; import MultiChoicesChartView from "@/app/(main)/questions/[id]/components/multiple_choices_chart_view"; import CPRevealTime from "@/components/cp_reveal_time"; +import { MultipleChoiceTile } from "@/components/post_card/multiple_choice_tile"; import useTimestampCursor from "@/hooks/use_timestamp_cursor"; import { TimelineChartZoomOption } from "@/types/charts"; import { ChoiceItem, ChoiceTooltipItem } from "@/types/choices"; @@ -18,12 +19,10 @@ import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { generateChoiceItemsFromMultipleChoiceForecast } from "@/utils/questions/choices"; import { getPostDrivenTime } from "@/utils/questions/helpers"; -const MAX_VISIBLE_CHECKBOXES = 3; - -const generateList = (question: QuestionWithMultipleChoiceForecasts) => - generateChoiceItemsFromMultipleChoiceForecast(question, { - activeCount: MAX_VISIBLE_CHECKBOXES, - }); +import { + buildEmbedChoicesWithOthers, + getMaxVisibleCheckboxes, +} from "../embeds"; type Props = { question: QuestionWithMultipleChoiceForecasts; @@ -33,6 +32,7 @@ type Props = { chartTheme?: VictoryThemeDefinition; hideCP?: boolean; forecastAvailability?: ForecastAvailability; + onLegendHeightChange?: (height: number) => void; }; const DetailedMultipleChoiceChartCard: FC = ({ @@ -43,6 +43,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ chartTheme, hideCP, forecastAvailability, + onLegendHeightChange, }) => { const t = useTranslations(); @@ -50,13 +51,26 @@ const DetailedMultipleChoiceChartCard: FC = ({ const openTime = getPostDrivenTime(question.open_time); const isClosed = actualCloseTime ? actualCloseTime < Date.now() : false; + const maxVisibleCheckboxes = useMemo( + () => getMaxVisibleCheckboxes(embedMode), + [embedMode] + ); + + const generateList = useCallback( + (q: QuestionWithMultipleChoiceForecasts) => + generateChoiceItemsFromMultipleChoiceForecast(q, { + activeCount: maxVisibleCheckboxes, + }), + [maxVisibleCheckboxes] + ); + const [choiceItems, setChoiceItems] = useState( generateList(question) ); useEffect(() => { setChoiceItems(generateList(question)); - }, [question]); + }, [question, generateList]); const timestamps = useMemo(() => { if (!forecastAvailability?.cpRevealsOn) { @@ -68,6 +82,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ return choiceItems[0]?.userTimestamps ?? []; }, [choiceItems, forecastAvailability]); + const userTimestamps = useMemo( () => choiceItems[0]?.userTimestamps ?? [], [choiceItems] @@ -81,6 +96,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ findPreviousTimestamp(timestamps, cursorTimestamp) ); }, [timestamps, cursorTimestamp]); + const userCursorIndex = useMemo(() => { return userTimestamps.indexOf( findPreviousTimestamp(userTimestamps, cursorTimestamp) @@ -146,6 +162,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ }), [choiceItems, aggregationCursorIndex, getOptionTooltipValue] ); + const tooltipUserChoices = useMemo(() => { return choiceItems .filter(({ active }) => active) @@ -166,6 +183,39 @@ const DetailedMultipleChoiceChartCard: FC = ({ userCursorIndex, ]); + const embedChoiceItems = useMemo(() => { + if (!embedMode) return choiceItems; + const othersLabel = "Others"; + + return buildEmbedChoicesWithOthers( + choiceItems, + maxVisibleCheckboxes, + othersLabel + ); + }, [choiceItems, embedMode, maxVisibleCheckboxes]); + + if (embedMode) { + return ( + + ); + } + return ( { + if (!embedMode) return 3; + return 4; +}; + +const OTHERS_COLOR: ThemeColor = METAC_COLORS.gray[400]; + +export function buildEmbedChoicesWithOthers( + choices: ChoiceItem[], + baseCount: number, + othersLabel: string +): ChoiceItem[] { + if (choices.length <= baseCount) return choices; + + const head = choices.slice(0, baseCount); + const tail = choices.slice(baseCount); + const aggLen = Math.max( + ...tail.map((c) => c.aggregationValues?.length ?? 0), + 0 + ); + const aggregationValues = Array.from({ length: aggLen }, (_, i) => + tail.reduce((sum, c) => sum + (c.aggregationValues?.[i] ?? 0), 0) + ); + + const userLen = Math.max(...tail.map((c) => c.userValues?.length ?? 0), 0); + const userValues: (number | null)[] = + userLen > 0 + ? Array.from({ length: userLen }, (_, i) => + tail.reduce((sum, c) => sum + (c.userValues?.[i] ?? 0), 0) + ) + : []; + + const template = tail[0]; + + const others: ChoiceItem = { + ...template, + choice: othersLabel, + color: OTHERS_COLOR, + aggregationValues, + userValues, + resolution: null, + displayedResolution: null, + active: true, + highlighted: template?.highlighted ?? false, + aggregationTimestamps: template?.aggregationTimestamps ?? [], + aggregationMinValues: + template?.aggregationMinValues?.map((v) => v ?? 0) ?? [], + aggregationMaxValues: + template?.aggregationMaxValues?.map((v) => v ?? 0) ?? [], + userTimestamps: template?.userTimestamps ?? [], + aggregationForecasterCounts: + template?.aggregationForecasterCounts?.map((v) => v ?? 0) ?? [], + }; + + return [...head, others]; +} diff --git a/front_end/src/components/post_card/multiple_choice_tile/choice_option.tsx b/front_end/src/components/post_card/multiple_choice_tile/choice_option.tsx index 109a0f34eb..e21b2fc7a3 100644 --- a/front_end/src/components/post_card/multiple_choice_tile/choice_option.tsx +++ b/front_end/src/components/post_card/multiple_choice_tile/choice_option.tsx @@ -1,6 +1,7 @@ import { isNil } from "lodash"; import React, { FC } from "react"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import ChoiceIcon from "@/components/choice_icon"; import ChoiceResolutionIcon from "@/components/choice_resolution_icon"; import { Resolution } from "@/types/post"; @@ -56,6 +57,7 @@ const ChoiceOption: FC = ({ : resolution; const hasValue = !isNil(values.at(-1)); + const isEmbed = useIsEmbedMode(); return (
= ({ "flex h-auto flex-row items-center self-stretch text-gray-900 dark:text-gray-900-dark", { "text-gray-800 dark:text-gray-800-dark": !hasValue, - } + }, + isEmbed && "pl-0.5" )} > {withIcon && ( @@ -79,6 +82,7 @@ const ChoiceOption: FC = ({
diff --git a/front_end/src/components/post_card/multiple_choice_tile/index.tsx b/front_end/src/components/post_card/multiple_choice_tile/index.tsx index f37043525d..5ff0152399 100644 --- a/front_end/src/components/post_card/multiple_choice_tile/index.tsx +++ b/front_end/src/components/post_card/multiple_choice_tile/index.tsx @@ -1,9 +1,10 @@ "use client"; import { isNil } from "lodash"; -import React, { FC, useCallback, useMemo } from "react"; +import { FC, useCallback, useEffect, useMemo } from "react"; import { VictoryThemeDefinition } from "victory"; +import { useIsEmbedMode } from "@/app/(embed)/questions/components/question_view_mode_context"; import FanChart from "@/components/charts/fan_chart"; import GroupChart from "@/components/charts/group_chart"; import MultipleChoiceChart from "@/components/charts/multiple_choice_chart"; @@ -15,9 +16,10 @@ import useCardReaffirmContext from "@/components/post_card/reaffirm_context"; import PredictionChip from "@/components/prediction_chip"; import { ContinuousQuestionTypes } from "@/constants/questions"; import { useAuth } from "@/contexts/auth_context"; +import useChartTooltip from "@/hooks/use_chart_tooltip"; import useContainerSize from "@/hooks/use_container_size"; import { ForecastPayload } from "@/services/api/questions/questions.server"; -import { TimelineChartZoomOption } from "@/types/charts"; +import { TickFormat, TimelineChartZoomOption } from "@/types/charts"; import { ChoiceItem } from "@/types/choices"; import { PostGroupOfQuestions, PostStatus, QuestionStatus } from "@/types/post"; import { @@ -42,6 +44,10 @@ type BaseProps = { showChart?: boolean; minimalistic?: boolean; optionsLimit?: number; + yLabel?: string; + onCursorChange?: (value: number, format: TickFormat) => void; + withHoverTooltip?: boolean; + showCursorLabel?: boolean; }; type QuestionProps = { @@ -69,6 +75,7 @@ type ContinuousMultipleChoiceTileProps = BaseProps & question?: QuestionWithMultipleChoiceForecasts; scaling?: Scaling | undefined; forecastAvailability?: ForecastAvailability; + onLegendHeightChange?: (height: number) => void; }; const CHART_HEIGHT = 100; @@ -87,15 +94,34 @@ export const MultipleChoiceTile: FC = ({ groupType, group, scaling, + yLabel, + onCursorChange, hideCP, forecastAvailability, canPredict, showChart = true, minimalistic = false, + onLegendHeightChange, + withHoverTooltip = true, + showCursorLabel = true, }) => { + const enableTooltip = withHoverTooltip; + const { getReferenceProps, refs } = useChartTooltip(); + const attachRef = useCallback( + (node: HTMLElement | null) => { + if (!enableTooltip) return; + if (node) refs.setReference(node); + }, + [enableTooltip, refs] + ); const { user } = useAuth(); const { onReaffirm } = useCardReaffirmContext(); const { ref, height } = useContainerSize(); + + const { ref: tileRef, width: tileWidth } = useContainerSize(); + const isEmbed = useIsEmbedMode(); + const isCompactEmbed = isEmbed && !!tileWidth && tileWidth < 400; + // when resolution chip is shown we want to hide the chart and display the chip // (e.g. multiple-choice question on questions feed) // otherwise, resolution status will be populated near the every choice @@ -115,6 +141,11 @@ export const MultipleChoiceTile: FC = ({ [group?.questions, groupType, question, user] ); + useEffect(() => { + if (!onLegendHeightChange) return; + onLegendHeightChange(height); + }, [height, groupType, onLegendHeightChange]); + const handleReaffirmClick = useCallback(() => { if (!onReaffirm || !canReaffirm) return; @@ -123,17 +154,26 @@ export const MultipleChoiceTile: FC = ({ return (
{isResolvedView ? ( @@ -148,49 +188,68 @@ export const MultipleChoiceTile: FC = ({ hideCP={hideCP} canPredict={canPredict && canReaffirm} onReaffirm={onReaffirm ? handleReaffirmClick : undefined} + layout={isEmbed && isCompactEmbed ? "wrap" : "column"} /> ) )}
- {showChart && !isResolvedView && ( + {showChart && !isCompactEmbed && !isResolvedView && (
- {isNil(group) ? ( - - ) : ( - - )} +
+ {isNil(group) ? ( + + ) : ( + + )} +
)}
diff --git a/front_end/src/components/post_card/multiple_choice_tile/multiple_choice_tile_legend.tsx b/front_end/src/components/post_card/multiple_choice_tile/multiple_choice_tile_legend.tsx index 6822d1bf0e..1320b1aa28 100644 --- a/front_end/src/components/post_card/multiple_choice_tile/multiple_choice_tile_legend.tsx +++ b/front_end/src/components/post_card/multiple_choice_tile/multiple_choice_tile_legend.tsx @@ -6,6 +6,7 @@ import React, { FC, RefObject } from "react"; import ReaffirmButton from "@/components/post_card/reaffirm_button"; import { ChoiceItem } from "@/types/choices"; import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; import ChoiceOption from "./choice_option"; @@ -19,6 +20,7 @@ type Props = { canPredict?: boolean; ref?: RefObject; withChoiceIcon?: boolean; + layout?: "column" | "wrap"; }; const MultipleChoiceTileLegend: FC = ({ @@ -31,14 +33,23 @@ const MultipleChoiceTileLegend: FC = ({ canPredict = false, ref, withChoiceIcon = true, + layout = "column", }) => { const t = useTranslations(); const visibleChoices = choices.slice(0, visibleChoicesCount); const otherItemsCount = choices.length - visibleChoices.length; + const isWrap = layout === "wrap"; return ( -
+
{visibleChoices.map( ({ choice, @@ -49,29 +60,47 @@ const MultipleChoiceTileLegend: FC = ({ scaling, actual_resolve_time, }) => ( - + className={cn(isWrap && "min-w-0 flex-none overflow-hidden")} + > + +
) )} + {otherItemsCount > 0 && ( -
+
-
+
@@ -87,8 +116,9 @@ const MultipleChoiceTileLegend: FC = ({ )}
)} + {!otherItemsCount && canPredict && !!onReaffirm && ( -
+
= ({ question, size = "md", variant = "feed", + colorOverride, }) => { const latest = question.aggregations[question.default_aggregation_method]?.latest; + const isDate = question.type === QuestionType.Date; + const isEmbed = useIsEmbedMode(); + const w = useEmbedContainerWidth(); + const isEmbedBelow376 = isEmbed && (w ?? 0) > 0 && (w ?? 0) < 376; + if (!latest) { return null; } @@ -41,7 +52,7 @@ const ContinuousCPBar: FC = ({ latest?.interval_upper_bounds?.[0] as number, ] : [], - unit: question.unit, + unit: isEmbedBelow376 ? question.unit : isEmbed ? "" : question.unit, actual_resolve_time: question.actual_resolve_time ?? null, discreteValueOptions, }, @@ -49,32 +60,47 @@ const ContinuousCPBar: FC = ({ ); const displayValueChunks = displayValue.split("\n"); const [centerLabel, intervalLabel] = displayValueChunks; + const isClosed = question.status === QuestionStatus.CLOSED; + const accentStyle = + !isClosed && colorOverride + ? ({ color: colorOverride } as const) + : undefined; return (
{centerLabel}
- {!isNil(intervalLabel) && ( + {!isNil(intervalLabel) && !isEmbedBelow376 && (
{intervalLabel} diff --git a/front_end/src/hooks/use_app_theme.ts b/front_end/src/hooks/use_app_theme.ts index 05b29d103b..791bdb9b1e 100644 --- a/front_end/src/hooks/use_app_theme.ts +++ b/front_end/src/hooks/use_app_theme.ts @@ -1,5 +1,11 @@ import { useTheme } from "next-themes"; -import { Dispatch, SetStateAction, useCallback, useState } from "react"; +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { useAuth } from "@/contexts/auth_context"; @@ -16,6 +22,10 @@ const useAppTheme = () => { const [isSyncing, setIsSyncing] = useState(); const { user, setUser } = useAuth(); + const theme = useMemo(() => { + return (forcedTheme ?? resolvedTheme ?? "light") as AppTheme; + }, [forcedTheme, resolvedTheme]); + const setTheme = useCallback( async (newTheme: AppTheme) => { // Immediately update NextTheme for instant visual feedback @@ -35,20 +45,12 @@ const useAppTheme = () => { ); const getThemeColor = useCallback( - (color: ThemeColor) => { - if (resolvedTheme === "dark") { - return color.dark; - } - - return color.DEFAULT; - }, - [resolvedTheme] + (color: ThemeColor) => (theme === "dark" ? color.dark : color.DEFAULT), + [theme] ); return { - // Currently active theme respecting Forced selection - // Could be dark or light - theme: (forcedTheme ?? resolvedTheme) as AppTheme, + theme, // Currently selected theme. Could be dark, light or system themeChoice: themeChoice ?? AppTheme.System, isSyncing, diff --git a/front_end/src/utils/questions/units.ts b/front_end/src/utils/questions/units.ts index 04028b52b8..68a0239cc9 100644 --- a/front_end/src/utils/questions/units.ts +++ b/front_end/src/utils/questions/units.ts @@ -8,3 +8,21 @@ export const formatValueUnit = (value: string, unit?: string) => { const QUESTION_UNIT_COMPACT_LENGTH = 3; export const isUnitCompact = (unit?: string) => unit && unit.length <= QUESTION_UNIT_COMPACT_LENGTH; + +export function getCommonUnit( + questions: Array<{ + unit?: string | null; + scaling?: { unit?: string | null } | null; + }> +): string | null { + const units = questions + .map((q) => (q.unit ?? q.scaling?.unit ?? "").trim()) + .filter(Boolean); + + if (units.length === 0) return null; + + const first = units[0]; + if (first === undefined) return null; + + return units.every((u) => u === first) ? first : null; +} diff --git a/front_end/src/utils/resolve_color.ts b/front_end/src/utils/resolve_color.ts new file mode 100644 index 0000000000..8164828536 --- /dev/null +++ b/front_end/src/utils/resolve_color.ts @@ -0,0 +1,21 @@ +import { ThemeColor } from "@/types/theme"; + +export type ThemeOrCssColor = ThemeColor | string; + +export const isCssColorString = (v: unknown): v is string => + typeof v === "string" && v.trim().length > 0; + +export const isThemeColor = (v: unknown): v is ThemeColor => { + if (!v || typeof v !== "object") return false; + const obj = v as { DEFAULT?: unknown; dark?: unknown }; + return typeof obj.DEFAULT === "string" && typeof obj.dark === "string"; +}; + +export const resolveToCssColor = ( + getThemeColor: (c: ThemeColor) => string, + v?: ThemeOrCssColor +): string | undefined => { + if (isCssColorString(v)) return v; + if (isThemeColor(v)) return getThemeColor(v); + return undefined; +};