diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index c671eb2094..24f6db1b21 100644 --- a/front_end/src/components/charts/continuous_area_chart.tsx +++ b/front_end/src/components/charts/continuous_area_chart.tsx @@ -94,6 +94,7 @@ type Props = { withTodayLine?: boolean; globalScaling?: Scaling; outlineUser?: boolean; + centerOOBResolution?: boolean; }; const ContinuousAreaChart: FC = ({ @@ -113,6 +114,7 @@ const ContinuousAreaChart: FC = ({ withTodayLine = true, globalScaling, outlineUser = false, + centerOOBResolution = false, }) => { const locale = useLocale(); const { ref: chartContainerRef, width: containerWidth } = @@ -825,6 +827,47 @@ const ContinuousAreaChart: FC = ({ /> )) )} + + {/* Today's date dot for date questions */} + {question.type === QuestionType.Date && withTodayLine && ( + + )} + + {question.type === QuestionType.Date && + todayLabelPosition && + withTodayLine && ( + + + + )} + {/* Resolution point */} {resX != null && resPlacement === "in" && ( = ({ {resX != null && resPlacement === "in" && withResolutionChip && - (question.type === QuestionType.Discrete || - question.type === QuestionType.Numeric) && ( + [ + QuestionType.Numeric, + QuestionType.Discrete, + QuestionType.Date, + ].includes(question.type) && ( = ({ /> )} + {/* Resolution chip for out of bounds resolution */} + {resX != null && + resPlacement !== "in" && + withResolutionChip && + [ + QuestionType.Numeric, + QuestionType.Discrete, + QuestionType.Date, + ].includes(question.type) && ( + + + + } + /> + )} + {resX != null && resPlacement && resPlacement !== "in" && ( = ({ resPlacement === "left" ? Math.min(...xDomain) : Math.max(...xDomain), - y: yDomain[1] - (yDomain[1] - yDomain[0]) * 0.04, - placement: resPlacement === "left" ? "above" : "below", + y: centerOOBResolution ? Math.max(...yDomain) / 2 : 0, + placement: resPlacement, primary: METAC_COLORS.purple["800"], secondary: METAC_COLORS.purple["500"], }, ]} - dataComponent={ - - } + dataComponent={} /> )} - {/* Today's date dot for date questions */} - {question.type === QuestionType.Date && withTodayLine && ( - - )} - - {question.type === QuestionType.Date && - todayLabelPosition && - withTodayLine && ( - - - - )} {/* Manually render cursor component when cursor is on edge */} {!isNil(cursorEdge) && ( diff --git a/front_end/src/components/charts/fan_chart.tsx b/front_end/src/components/charts/fan_chart.tsx index 4fa1c9b17d..9638551796 100644 --- a/front_end/src/components/charts/fan_chart.tsx +++ b/front_end/src/components/charts/fan_chart.tsx @@ -19,6 +19,7 @@ import { import ChartFanTooltip from "@/components/charts/primitives/chart_fan_tooltip"; import FanPoint from "@/components/charts/primitives/fan_point"; import PredictionWithRange from "@/components/charts/primitives/prediction_with_range"; +import ResolutionDiamond from "@/components/charts/primitives/resolution_diamond"; import ForecastAvailabilityChartOverflow from "@/components/post_card/chart_overflow"; import { darkTheme, lightTheme } from "@/constants/chart_theme"; import { METAC_COLORS } from "@/constants/colors"; @@ -457,27 +458,48 @@ const FanChart: FC = ({ /> )} - {resolutionPoints.map((point) => ( - palette.resolutionStroke, - strokeWidth: 2, - strokeOpacity: 1, - }, - }} - dataComponent={ - - } - /> - ))} + {resolutionPoints.map((point) => { + if ( + point.placement && + ["below", "above"].includes(point.placement) + ) { + return ( + + } + /> + + ); + } + return ( + palette.resolutionStroke, + strokeWidth: 2, + strokeOpacity: 1, + }, + }} + dataComponent={ + + } + /> + ); + })} {emptyPoints.map((point) => ( 1; + const isBelowLowerBound = + option.question?.resolution === "below_lower_bound" || yVal < 0; + resolutionPoints.push({ x: option.name, y: yVal, unsuccessfullyResolved: false, resolved: true, + placement: isAboveUpperBound + ? "above" + : isBelowLowerBound + ? "below" + : "in", }); } diff --git a/front_end/src/components/charts/fan_chart_variants.ts b/front_end/src/components/charts/fan_chart_variants.ts index 3ee239520e..1b453e9b0a 100644 --- a/front_end/src/components/charts/fan_chart_variants.ts +++ b/front_end/src/components/charts/fan_chart_variants.ts @@ -99,8 +99,8 @@ export const fanVariants: Record = { communityPoint: getThemeColor(METAC_COLORS.olive["800"]), }), resolutionPoint: { - size: 8, - strokeWidth: 2, + size: 10, + strokeWidth: 2.5, fill: () => "none", }, }, diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 938b4b1a51..2973ccd96d 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -53,6 +53,7 @@ import ForecastAvailabilityChartOverflow from "../post_card/chart_overflow"; import ChartContainer from "./primitives/chart_container"; import ChartCursorLabel from "./primitives/chart_cursor_label"; import GroupResolutionPoint from "./primitives/group_resolution_point"; +import ResolutionDiamond from "./primitives/resolution_diamond"; import XTickLabel from "./primitives/x_tick_label"; type Props = { @@ -515,6 +516,31 @@ const GroupChart: FC = ({ ? METAC_COLORS["mc-option-text"][1] : color; + if ( + resolutionPoint.placement && + ["below", "above"].includes(resolutionPoint.placement) + ) { + return ( + + } + /> + + ); + } + return ( 1 ? "above" : "in", }; } else if ( typeof resolution === "string" && @@ -851,10 +886,13 @@ function buildChartData({ resolveTime, scaling, }); + if (dateResolution) { + const yPos = dateResolution.y ?? 0; item.resolutionPoint = { x: dateResolution.x, - y: dateResolution.y ?? 0, + y: yPos, + placement: yPos < 0 ? "below" : yPos > 1 ? "above" : "in", x1: lastLineItem?.x, y1: lastLineItem?.y ?? undefined, }; 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..5637018054 100644 --- a/front_end/src/components/charts/primitives/chart_value_box.tsx +++ b/front_end/src/components/charts/primitives/chart_value_box.tsx @@ -8,10 +8,163 @@ import { Resolution } from "@/types/post"; import { QuestionType } from "@/types/question"; import { ThemeColor } from "@/types/theme"; +const TEXT_PADDING = 6; +const PLACEMENT_OFFSET_VERTICAL = 4; +const PLACEMENT_OFFSET_HORIZONTAL = -12; +const CHIP_HEIGHT = 16; +const CHIP_FONT_SIZE = 12; + +type Placement = "in" | "below" | "above" | "left" | "right"; + +function getTextAnchor(placement: Placement) { + switch (placement) { + case "left": + return "start"; + case "right": + return "end"; + default: + return "middle"; + } +} + +function getRectX( + placement: Placement, + adjustedX: number, + textWidth: number, + textAlignToSide?: boolean +) { + switch (placement) { + case "left": + return ( + adjustedX - + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) + + textWidth - + TEXT_PADDING / 2 + ); + case "right": + return ( + adjustedX + + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) - + textWidth + + TEXT_PADDING / 2 + ); + default: + return adjustedX - textWidth / 2; + } +} + +function getTextX( + placement: Placement, + adjustedX: number, + textAlignToSide?: boolean +) { + switch (placement) { + case "left": + return ( + adjustedX - + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) + ); + case "right": + return ( + adjustedX + + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) + ); + default: + return adjustedX; + } +} + +function getResolvedX( + placement: Placement, + adjustedX: number, + textAlignToSide?: boolean +) { + switch (placement) { + case "left": + return ( + adjustedX - + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) - + TEXT_PADDING / 2 + ); + case "right": + return ( + adjustedX + + (textAlignToSide + ? PLACEMENT_OFFSET_HORIZONTAL + : PLACEMENT_OFFSET_VERTICAL) + + TEXT_PADDING / 2 + ); + default: + return adjustedX; + } +} + +function getTextY( + placement: Placement, + y: number, + isDistributionChip?: boolean, + textAlignToSide?: boolean +) { + const baseY = + y + CHIP_FONT_SIZE / 10 - (isDistributionChip ? CHIP_HEIGHT : 0); + switch (placement) { + case "left": + case "right": + return baseY + (textAlignToSide ? CHIP_HEIGHT : -2); + default: + return baseY; + } +} + +function getResolvedY( + placement: Placement, + y: number, + isDistributionChip?: boolean, + textAlignToSide?: boolean +) { + const baseY = y - CHIP_HEIGHT - 1 - (isDistributionChip ? CHIP_HEIGHT : 0); + switch (placement) { + case "left": + case "right": + return baseY + (textAlignToSide ? CHIP_HEIGHT + 2 : 0); + default: + return baseY; + } +} + +function getRectY( + placement: Placement, + y: number, + isDistributionChip?: boolean, + textAlignToSide?: boolean +) { + const baseY = y - CHIP_HEIGHT / 2 - (isDistributionChip ? CHIP_HEIGHT : 0); + switch (placement) { + case "left": + case "right": + return baseY + (textAlignToSide ? CHIP_HEIGHT : -2); + default: + return baseY; + } +} + const ChartValueBox: FC<{ x?: number | null; y?: number | null; - datum?: { y: number }; + datum?: { + y: number; + placement?: Placement; + }; isCursorActive: boolean; chartWidth: number; rightPadding: number; @@ -20,6 +173,7 @@ const ChartValueBox: FC<{ resolution?: Resolution | null; isDistributionChip?: boolean; questionType?: QuestionType; + textAlignToSide?: boolean; }> = (props) => { const { getThemeColor } = useAppTheme(); const { @@ -34,12 +188,13 @@ const ChartValueBox: FC<{ resolution, isDistributionChip, questionType, + textAlignToSide, } = props; - const TEXT_PADDING = 4; const CHIP_OFFSET = !isNil(resolution) ? 8 : 0; const [textWidth, setTextWidth] = useState(0); const textRef = useRef(null); + const placement = datum?.placement ?? "in"; useEffect(() => { if (textRef.current) { setTextWidth(textRef.current.getBBox().width + TEXT_PADDING); @@ -54,8 +209,6 @@ const ChartValueBox: FC<{ isCursorActive || isDistributionChip ? x : chartWidth - rightPadding + textWidth / 2 + CHIP_OFFSET; - const chipHeight = 16; - const chipFontSize = 12; const hasResolution = !!resolution && !isCursorActive; return ( @@ -63,14 +216,15 @@ const ChartValueBox: FC<{ {/* "RESOLVED" label above the chip for resolution values */} {hasResolution && questionType !== QuestionType.Binary && ( RESOLVED @@ -78,10 +232,10 @@ const ChartValueBox: FC<{ {/* Original chip background - unchanged */} {!!resolution && !isCursorActive ? resolution diff --git a/front_end/src/components/charts/primitives/resolution_diamond.tsx b/front_end/src/components/charts/primitives/resolution_diamond.tsx index f4e5791b80..02c866033d 100644 --- a/front_end/src/components/charts/primitives/resolution_diamond.tsx +++ b/front_end/src/components/charts/primitives/resolution_diamond.tsx @@ -1,13 +1,20 @@ "use client"; +import { isNil } from "lodash"; import React, { forwardRef, memo } from "react"; +import { METAC_COLORS } from "@/constants/colors"; +import useAppTheme from "@/hooks/use_app_theme"; import { ThemeColor } from "@/types/theme"; import cn from "@/utils/core/cn"; export type DiamondDatum = { - placement: "in" | "below" | "above"; + placement: "in" | "below" | "above" | "left" | "right"; primary?: ThemeColor; secondary?: ThemeColor; + x?: number; + y?: number; + x1?: number; + y1?: number; }; type Props = { @@ -17,49 +24,97 @@ type Props = { axisPadPx?: number; hoverable?: boolean; isHovered?: boolean; + scale?: { + x: (x: number) => number; + y: (y: number) => number; + }; refProps?: React.SVGProps; - rotateDeg?: number; }; +function getRotationDeg(placement: DiamondDatum["placement"]) { + switch (placement) { + case "left": + return 90; + case "right": + return -90; + case "below": + return 0; + case "above": + return 180; + default: + return 0; + } +} + +function getAnchorX( + placement: DiamondDatum["placement"], + x: number, + axisPadPx: number +) { + switch (placement) { + case "left": + return x - axisPadPx; + case "right": + return x + axisPadPx; + default: + return x; + } +} + +function getAnchorY( + placement: DiamondDatum["placement"], + y: number, + axisPadPx: number +) { + switch (placement) { + case "above": + return y - axisPadPx; + case "below": + return y + axisPadPx; + default: + return y; + } +} + const ResolutionDiamond = forwardRef(function RD( { x, y, datum, - axisPadPx = 2, + axisPadPx = 5, hoverable = true, isHovered = false, refProps, - rotateDeg = 0, + scale, }, ref ) { + const { getThemeColor } = useAppTheme(); const d = (datum as DiamondDatum | undefined) ?? { placement: "in" }; const { placement } = d; - if (x == null || y == null) return null; - const anchorY = - placement === "above" - ? y - axisPadPx - : placement === "below" - ? y + axisPadPx - : y; - - const baseTransform = `translate(${x}, ${anchorY}) rotate(${rotateDeg})`; - - const bob = placement === "in" ? 5.5 : 4.0; - const values = - placement === "below" - ? `0,0;0,${bob};0,0` - : placement === "above" - ? `0,0;0,${-bob};0,0` - : `0,${-bob};0,${bob};0,${-bob}`; - const keyTimes = placement === "in" ? "0;0.5;1" : "0;0.825;1"; - const keySplines = - placement === "in" - ? "0.25 0.1 0.25 1; 0.25 0.1 0.25 1" - : "0.25 0.1 0.25 1; 0.5 0 1 1"; + const rotateDeg = getRotationDeg(placement); + const anchorY = getAnchorY(placement, y, axisPadPx); + const anchorX = getAnchorX(placement, x, axisPadPx); + + const baseTransform = `translate(${anchorX}, ${anchorY}) rotate(${rotateDeg})`; + + // Arrow color animation values + const lightColor = getThemeColor( + d.secondary ?? d.primary ? METAC_COLORS.gray[400] : METAC_COLORS.purple[500] + ); + const darkColor = getThemeColor(d.primary ?? METAC_COLORS.purple[800]); + + // Animation timing for 750ms duration (250ms each section): + // Arrow 1: dark from 0% to 33.3% (250ms), then light + // Arrow 2: light until 33.3%, dark from 33.3% to 66.7% (250ms), then light + // Both light: 66.7% to 100% (250ms pause) + const arrow1Values = `${darkColor};${darkColor};${lightColor};${lightColor}`; + const arrow1KeyTimes = "0;0.333;0.4;1"; + + const arrow2Values = `${lightColor};${lightColor};${darkColor};${darkColor};${lightColor};${lightColor}`; + const arrow2KeyTimes = "0;0.333;0.4;0.667;0.733;1"; const HIT_W = 36; const HIT_H = 44; @@ -67,57 +122,83 @@ const ResolutionDiamond = forwardRef(function RD( const HIT_Y = -HIT_H / 2; return ( - - - {!isHovered && ( - + {!isNil(scale) && + !isNil(d.x) && + !isNil(d.y) && + !isNil(d.x1) && + !isNil(d.y1) && + Math.abs(d.y1 - d.y) > 0.1 && ( + )} - - - - - - - + + + + + + + {!isHovered && ( + + )} + + + {!isHovered && ( + + )} + + - + ); }); diff --git a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx index 04f8ced4ed..d12ad3c97c 100644 --- a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx +++ b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx @@ -207,6 +207,7 @@ const QuestionContinuousTile: FC = ({ question={question} hideCP={hideCP} forceTickCount={3} + centerOOBResolution /> = ({ question={question} hideCP={hideCP} forceTickCount={3} + centerOOBResolution /> = ({ question={question} hideCP={hideCP} forceTickCount={3} + centerOOBResolution />