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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions front_end/messages/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,12 @@
"tournamentUnfollowModalSubmit": "Zrušit sledování",
"switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení",
"view": "Zobrazit",
"distributionCopied": "Distribuce zkopírována do schránky",
"distributionCopyFailed": "Nepodařilo se zkopírovat distribuci",
"distributionPasted": "Distribuce vložena ze schránky",
"distributionPasteInvalid": "Ve schránce nebyla nalezena platná distribuce",
"copyDistribution": "Kopírovat distribuci do schránky",
"pasteDistribution": "Vložit distribuci ze schránky",
"maxAttainableCoverageExplanation": "Otázky, které se vyřeší dříve, mají své skóre a pokrytí upravené, aby se zachovala správnost skóre. <link>Více se dozvíte zde.</link>",
"viewComment": "Zobrazit komentář",
"viewArticle": "Zobrazit článek",
Expand Down
6 changes: 6 additions & 0 deletions front_end/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,12 @@
"forecastCopyToAllToastMessage": "Distribution copied to all other questions",
"forecastCopyFromToastMessage": "Distribution copied from ''{name}''",
"forecastCopyToToastMessage": "Distribution copied from ''{from_name}'' to ''{to_name}''",
"distributionCopied": "Distribution copied to clipboard",
"distributionCopyFailed": "Failed to copy distribution",
"distributionPasted": "Distribution pasted from clipboard",
"distributionPasteInvalid": "No valid distribution found in clipboard",
"copyDistribution": "Copy distribution to clipboard",
"pasteDistribution": "Paste distribution from clipboard",
"result": "result",
"communityDisclaimer": "Contributed by the <community></community> community. <learnMore>Learn more</learnMore>",
"conditionalBranchResolutionAnnulled": "This branch was annulled because the <link>parent question</link> resolved <resolution></resolution>.",
Expand Down
6 changes: 6 additions & 0 deletions front_end/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,12 @@
"tournamentUnfollowModalSubmit": "Dejar de Seguir",
"switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave",
"view": "Ver",
"distributionCopied": "Distribución copiada al portapapeles",
"distributionCopyFailed": "Error al copiar la distribución",
"distributionPasted": "Distribución pegada desde el portapapeles",
"distributionPasteInvalid": "No se encontró una distribución válida en el portapapeles",
"copyDistribution": "Copiar distribución al portapapeles",
"pasteDistribution": "Pegar distribución desde el portapapeles",
"maxAttainableCoverageExplanation": "Las preguntas que se resuelven pronto tienen sus puntuaciones y cobertura truncadas para preservar la propiedad adecuada de la puntuación. <link>Aprende más aquí.</link>",
"viewComment": "Ver comentario",
"viewArticle": "Ver artículo",
Expand Down
6 changes: 6 additions & 0 deletions front_end/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,12 @@
"tournamentUnfollowModalSubmit": "Deixar de Seguir",
"switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave",
"view": "Visualizar",
"distributionCopied": "Distribuição copiada para a área de transferência",
"distributionCopyFailed": "Falha ao copiar distribuição",
"distributionPasted": "Distribuição colada da área de transferência",
"distributionPasteInvalid": "Nenhuma distribuição válida encontrada na área de transferência",
"copyDistribution": "Copiar distribuição para a área de transferência",
"pasteDistribution": "Colar distribuição da área de transferência",
"maxAttainableCoverageExplanation": "Questões que são resolvidas cedo têm suas pontuações e coberturas truncadas para preservar a correção da pontuação. <link>Saiba mais aqui.</link>",
"viewComment": "Ver comentário",
"viewArticle": "Ver artigo",
Expand Down
6 changes: 6 additions & 0 deletions front_end/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,12 @@
"tournamentUnfollowModalSubmit": "取消關注",
"switchBackToSlidersHint": "切換回滑桿以平滑調整",
"view": "檢視",
"distributionCopied": "分配已複製到剪貼簿",
"distributionCopyFailed": "複製分配失敗",
"distributionPasted": "從剪貼簿貼上了分配",
"distributionPasteInvalid": "剪貼簿中未找到有效的分配",
"copyDistribution": "將分配複製到剪貼簿",
"pasteDistribution": "從剪貼簿貼上分配",
Comment on lines +2193 to +2198

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use 「分佈」 instead of 「分配」 for consistency and correctness.

In this locale file, distribution in forecasting context is already translated as 「分佈」. Using 「分配」 here introduces inconsistent terminology and reads as “allocation.”

Suggested wording update
-  "distributionCopied": "分配已複製到剪貼簿",
-  "distributionCopyFailed": "複製分配失敗",
-  "distributionPasted": "從剪貼簿貼上了分配",
-  "distributionPasteInvalid": "剪貼簿中未找到有效的分配",
-  "copyDistribution": "將分配複製到剪貼簿",
-  "pasteDistribution": "從剪貼簿貼上分配",
+  "distributionCopied": "分佈已複製到剪貼簿",
+  "distributionCopyFailed": "複製分佈失敗",
+  "distributionPasted": "已從剪貼簿貼上分佈",
+  "distributionPasteInvalid": "剪貼簿中未找到有效的分佈",
+  "copyDistribution": "將分佈複製到剪貼簿",
+  "pasteDistribution": "從剪貼簿貼上分佈",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"distributionCopied": "分配已複製到剪貼簿",
"distributionCopyFailed": "複製分配失敗",
"distributionPasted": "從剪貼簿貼上了分配",
"distributionPasteInvalid": "剪貼簿中未找到有效的分配",
"copyDistribution": "將分配複製到剪貼簿",
"pasteDistribution": "從剪貼簿貼上分配",
"distributionCopied": "分佈已複製到剪貼簿",
"distributionCopyFailed": "複製分佈失敗",
"distributionPasted": "已從剪貼簿貼上分佈",
"distributionPasteInvalid": "剪貼簿中未找到有效的分佈",
"copyDistribution": "將分佈複製到剪貼簿",
"pasteDistribution": "從剪貼簿貼上分佈",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front_end/messages/zh-TW.json` around lines 2081 - 2086, Update the
translation values for the keys "distributionCopied", "distributionCopyFailed",
"distributionPasted", "distributionPasteInvalid", "copyDistribution", and
"pasteDistribution" to use 「分佈」 instead of 「分配」 so the terminology matches the
forecasting context; locate these string values in front_end/messages/zh-TW.json
(the keys listed above) and replace each occurrence of 「分配」 with 「分佈」 while
preserving the rest of the messages.

"maxAttainableCoverageExplanation": "為了保持分數的正確性,早期解決的問題其分數和覆蓋率會被截斷。<link>在這裡了解更多。</link>",
"viewComment": "查看評論",
"viewArticle": "查看文章",
Expand Down
8 changes: 7 additions & 1 deletion front_end/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,12 @@
"tournamentUnfollowModalSubmit": "取消关注",
"switchBackToSlidersHint": "切回滑块以进行更精细的调整",
"view": "查看",
"distributionCopied": "分布已复制到剪贴板",
"distributionCopyFailed": "复制分布失败",
"distributionPasted": "分布已从剪贴板粘贴",
"distributionPasteInvalid": "剪贴板中没有找到有效的分布",
"copyDistribution": "复制分布到剪贴板",
"pasteDistribution": "从剪贴板粘贴分布",
"maxAttainableCoverageExplanation": "为确保分数的准确性,提前解决的问题,其分数和覆盖率会被截断。<link>在此了解更多信息。</link>",
"viewArticle": "查看文章",
"createdTimeAgoBy": "由 @{author} 创建于 {timeAgo}",
Expand Down Expand Up @@ -2375,4 +2381,4 @@
"laborHubJobsChartBaseline": "基准",
"predictorsMentionWarning": "只有策展人和管理员可以通知 @predictors。您的提及不会发送通知。",
"feedTileQuestionsRecentlyResolved": "{count, plural, one {# 个问题最近已解决} other {# 个问题最近已解决}}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { faCopy, faPaste } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useTranslations } from "next-intl";
import { FC, useCallback, useEffect, useRef } from "react";
import toast from "react-hot-toast";

import Button from "@/components/ui/button";
import { ContinuousForecastInputType } from "@/types/charts";
import {
DistributionQuantileComponent,
DistributionSliderComponent,
Quantile,
Scaling,
} from "@/types/question";
import { scaleInternalLocation, unscaleNominalLocation } from "@/utils/math";

// Matches the tolerance validateQuantileInput (forecast_maker/helpers.ts)
// enforces for open-bound probabilities: [0.1, 99.9].
const OPEN_BOUND_PROBABILITY_MIN = 0.1;
const OPEN_BOUND_PROBABILITY_MAX = 99.9;
// Small nudge off 0/1 so a rescaled quantile lands strictly inside a closed
// bound, matching validateQuantileInput's strict `<=`/`>=` range checks.
const CLOSED_BOUND_LOCATION_EPSILON = 0.001;

function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

const CLIPBOARD_MARKER = "_metaculus_distribution";

type ClipboardDistribution = {
[CLIPBOARD_MARKER]: true;
type: ContinuousForecastInputType;
components: DistributionSliderComponent[] | DistributionQuantileComponent;
};

const VALID_INPUT_TYPES: string[] = Object.values(ContinuousForecastInputType);

function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
}

// Quantile values are in the question's real-world units, which aren't
// meaningful on a different question. `quantile` is a number (25/50/75) for
// the actual value entries; `below_lower_bound`/`above_upper_bound` are
// already scale-independent percentages, so they're left as-is.
function toPortableQuantileComponents(
components: DistributionQuantileComponent,
scaling: Scaling
): DistributionQuantileComponent {
return components.map((c) =>
typeof c.quantile === "number" && c.value !== undefined
? { ...c, value: unscaleNominalLocation(c.value, scaling) }
: c
);
}

// Rescales a pasted (portable) quantile distribution into the target
// question's units, and reconciles it with the target's own open/closed
// bounds — which may differ from the question it was copied from:
// - below_lower_bound/above_upper_bound (probability mass outside the
// range) must be exactly 0 on a closed bound (there's no UI to edit that
// cell once it's not 0 — see continuous_table/index.tsx) and within
// [0.1, 99.9] on an open bound.
// - q1/q2/q3 must land strictly inside a closed bound; an open bound has no
// such constraint.
function fromPortableQuantileComponents(
components: DistributionQuantileComponent,
scaling: Scaling,
openLowerBound: boolean,
openUpperBound: boolean
): DistributionQuantileComponent {
return components.map((c) => {
if (c.value === undefined) return c;
if (c.quantile === Quantile.lower) {
return {
...c,
value: openLowerBound
? clamp(
c.value,
OPEN_BOUND_PROBABILITY_MIN,
OPEN_BOUND_PROBABILITY_MAX
)
: 0,
};
}
if (c.quantile === Quantile.upper) {
return {
...c,
value: openUpperBound
? clamp(
c.value,
OPEN_BOUND_PROBABILITY_MIN,
OPEN_BOUND_PROBABILITY_MAX
)
: 0,
};
}
// c.value is still the portable internal [0, 1] location at this point
let location = c.value;
if (!openLowerBound && location <= 0) {
location = CLOSED_BOUND_LOCATION_EPSILON;
}
if (!openUpperBound && location >= 1) {
location = 1 - CLOSED_BOUND_LOCATION_EPSILON;
}
return { ...c, value: scaleInternalLocation(location, scaling) };
});
}

function isClipboardDistribution(data: unknown): data is ClipboardDistribution {
return (
typeof data === "object" &&
data !== null &&
CLIPBOARD_MARKER in data &&
(data as ClipboardDistribution)[CLIPBOARD_MARKER] === true &&
"type" in data &&
"components" in data &&
VALID_INPUT_TYPES.includes((data as ClipboardDistribution).type)
);
}

type Props = {
forecastInputMode: ContinuousForecastInputType;
sliderComponents: DistributionSliderComponent[];
quantileComponents: DistributionQuantileComponent;
scaling: Scaling;
openLowerBound: boolean;
openUpperBound: boolean;
onPaste: (
type: ContinuousForecastInputType,
components: DistributionSliderComponent[] | DistributionQuantileComponent
) => void;
disabled?: boolean;
containerRef?: React.RefObject<HTMLDivElement | null>;
};

const ContinuousClipboardMenu: FC<Props> = ({
forecastInputMode,
sliderComponents,
quantileComponents,
scaling,
openLowerBound,
openUpperBound,
onPaste,
disabled,
containerRef,
}) => {
const t = useTranslations();

const onPasteRef = useRef(onPaste);
onPasteRef.current = onPaste;

const handleCopy = useCallback(async () => {
const data: ClipboardDistribution = {
[CLIPBOARD_MARKER]: true,
type: forecastInputMode,
components:
forecastInputMode === ContinuousForecastInputType.Slider
? sliderComponents
: toPortableQuantileComponents(quantileComponents, scaling),
};
try {
await navigator.clipboard.writeText(JSON.stringify(data));
toast(t("distributionCopied"));
} catch {
toast.error(t("distributionCopyFailed"));
}
}, [forecastInputMode, sliderComponents, quantileComponents, scaling, t]);

const handlePaste = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
const parsed: unknown = JSON.parse(text);
if (!isClipboardDistribution(parsed)) {
toast.error(t("distributionPasteInvalid"));
return;
}
const components =
parsed.type === ContinuousForecastInputType.Quantile
? fromPortableQuantileComponents(
parsed.components as DistributionQuantileComponent,
scaling,
openLowerBound,
openUpperBound
)
: parsed.components;
onPasteRef.current(parsed.type, components);
toast(t("distributionPasted"));
} catch {
toast.error(t("distributionPasteInvalid"));
}
}, [scaling, openLowerBound, openUpperBound, t]);

// Ctrl+C / Ctrl+V keyboard shortcuts
useEffect(() => {
const container = containerRef?.current;
if (!container) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
// Let native copy/paste happen when the user is focused on a form
// field (e.g. a percentile input) instead of hijacking the shortcut
if (isEditableTarget(e.target)) return;

if (e.key === "c") {
// Only intercept if no text is selected
const selection = window.getSelection()?.toString();
if (selection) return;
e.preventDefault();
handleCopy();
} else if (e.key === "v" && !disabled) {
e.preventDefault();
handlePaste();
}
};

container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleCopy, handlePaste, disabled]);

return (
<div className="flex gap-1">
<Button
className="size-[26px] border border-blue-400 dark:border-blue-400-dark"
variant="link"
onClick={handleCopy}
aria-label={t("copyDistribution")}
title={t("copyDistribution")}
>
<FontAwesomeIcon
className="text-blue-700 dark:text-blue-700-dark"
icon={faCopy}
/>
</Button>
{!disabled && (
<Button
className="size-[26px] border border-blue-400 dark:border-blue-400-dark"
variant="link"
onClick={handlePaste}
aria-label={t("pasteDistribution")}
title={t("pasteDistribution")}
>
<FontAwesomeIcon
className="text-blue-700 dark:text-blue-700-dark"
icon={faPaste}
/>
</Button>
)}
</div>
);
};

export default ContinuousClipboardMenu;
Loading
Loading