"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { FaMedal } from "react-icons/fa"; import { TRIWORLD_COLORS, TRIWORLD_RADAR_DIMS, TRIWORLD_VIS_PANELS, type TriWorldBoardEntry, } from "./metrics"; import { hasLocalizedText, type LocalizedText } from "@/lib/i18n"; import { LocalizedMarkdown } from "./LocalizedMarkdown"; const CLEAN_CASES = [ "blocks_ranking_rgb.mp4", "click_bell.mp4", "grab_roller.mp4", "move_can_pot.mp4", "move_pillbottle_pad.mp4", "pick_dual_bottles.mp4", "place_cans_plasticbox.mp4", "place_dual_shoes.mp4", "place_object_stand.mp4", "scan_object.mp4", "stack_bowls_three.mp4", "turn_switch.mp4", ] as const; const RANDOM_CASES = [ "adjust-bottle.mp4", "blocks_ranking_size.mp4", "dump_bin_bigbin.mp4", "grab_roller.mp4", "handover_mic.mp4", "hanging_mug.mp4", "move_can_pot.mp4", "pick_diverse_bottles (2).mp4", "pick_diverse_bottles.mp4", "place_a2b_right.mp4", "place_bread_skillet.mp4", "place_fan.mp4", ] as const; const CASE_TITLES_ZH: Record = { "adjust-bottle.mp4": "调整瓶子", "blocks_ranking_rgb.mp4": "RGB 方块排序", "blocks_ranking_size.mp4": "按尺寸排序方块", "click_bell.mp4": "点击铃铛", "dump_bin_bigbin.mp4": "将小箱倒入大箱", "grab_roller.mp4": "抓取滚轮", "handover_mic.mp4": "递交麦克风", "hanging_mug.mp4": "悬挂杯子", "move_can_pot.mp4": "移动罐子到锅中", "move_pillbottle_pad.mp4": "移动药瓶到垫子", "pick_diverse_bottles (2).mp4": "抓取多种瓶子(二)", "pick_diverse_bottles.mp4": "抓取多种瓶子", "pick_dual_bottles.mp4": "抓取双瓶", "place_a2b_right.mp4": "从 A 到 B 放置到右侧", "place_bread_skillet.mp4": "将面包放入煎锅", "place_cans_plasticbox.mp4": "将罐子放入塑料盒", "place_dual_shoes.mp4": "放置双鞋", "place_fan.mp4": "放置风扇", "place_object_stand.mp4": "将物体放到支架", "scan_object.mp4": "扫描物体", "stack_bowls_three.mp4": "堆叠三个碗", "turn_switch.mp4": "拨动开关", }; const RADAR_LINE_PATTERNS = [ "none", "12 5", "4 4", "14 4 3 4", "2 4", "9 3 2 3", "18 6", "6 3", "16 3 2 3", "3 3 9 3", "20 4 4 4", "7 2", "11 2 2 2", "5 3 1 3", "22 5", "8 4 2 4", "13 3", "3 2", "15 5 3 2", "6 2 2 2", "10 5", "4 2 10 2", "17 3 4 3", "2 3 6 3", ] as const; function DualText({ en, zh }: { en: React.ReactNode; zh: React.ReactNode }) { return ( <> {en} {zh} ); } function fmt(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(Number(value))) { return "TBA"; } return Number(value).toFixed(2); } function formatCaseTitle(fileName: string): string { return fileName .replace(/\.mp4$/i, "") .replace(/\s*\(2\)$/, " 2") .replace(/[_-]+/g, " ") .replace(/\b\w/g, (letter) => letter.toUpperCase()); } function radarPoint( cx: number, cy: number, radius: number, axisIndex: number, axisCount: number, value: number ): [number, number] { const angle = -Math.PI / 2 + (axisIndex / axisCount) * Math.PI * 2; const scaled = Math.max(0, Math.min(100, value || 0)) / 100; return [ cx + Math.cos(angle) * radius * scaled, cy + Math.sin(angle) * radius * scaled, ]; } function radarPercentage(value: number | null | undefined, maxValue: number): number { if (value === null || value === undefined || Number.isNaN(Number(value))) return 0; return (Number(value) / maxValue) * 100; } function CaseGrid({ category, cases, active, }: { category: "clean" | "random"; cases: ReadonlyArray; active: boolean; }) { const panelRef = useRef(null); const gridRef = useRef(null); useEffect(() => { if (!active) return; const panel = panelRef.current; const grid = gridRef.current; if (!panel || !grid) return; const updatePanelHeight = () => { const items = Array.from(grid.querySelectorAll(".case-item")); if (!items.length) return; const rowHeights = new Map(); for (const item of items) { const rowTop = Math.round(item.offsetTop); rowHeights.set(rowTop, Math.max(rowHeights.get(rowTop) || 0, item.offsetHeight)); } const firstTwoRows = [...rowHeights.entries()] .sort(([a], [b]) => a - b) .slice(0, 2) .map(([, height]) => height); if (!firstTwoRows.length) return; const rowGap = Number.parseFloat(window.getComputedStyle(grid).rowGap || "0") || 0; const measuredHeight = firstTwoRows.reduce((sum, height) => sum + height, 0) + rowGap * Math.max(0, firstTwoRows.length - 1); panel.style.setProperty("--case-panel-max-height", `${Math.ceil(measuredHeight)}px`); }; updatePanelHeight(); window.addEventListener("resize", updatePanelHeight); const videos = Array.from(grid.querySelectorAll("video")); videos.forEach((video) => { video.addEventListener("loadedmetadata", updatePanelHeight); video.addEventListener("loadeddata", updatePanelHeight); }); const resizeObserver = "ResizeObserver" in window ? new ResizeObserver(updatePanelHeight) : null; if (resizeObserver) { Array.from(grid.children).forEach((child) => resizeObserver.observe(child)); } return () => { window.removeEventListener("resize", updatePanelHeight); videos.forEach((video) => { video.removeEventListener("loadedmetadata", updatePanelHeight); video.removeEventListener("loadeddata", updatePanelHeight); }); resizeObserver?.disconnect(); }; }, [active, cases]); return (
{cases.map((fileName) => (
))}
); } export interface TriWorldMetricText { code: string; label: LocalizedText; shortLabel: LocalizedText; title: LocalizedText; description?: LocalizedText; category?: string; } function labelFor(metricTexts: Record, code: string, fallback: string): LocalizedText { return metricTexts[code]?.shortLabel || { en: fallback, zh: fallback }; } function titleFor(metricTexts: Record, code: string | null, fallback: string): LocalizedText { if (!code) return metricTexts.__overall__?.title || { en: fallback, zh: fallback }; return metricTexts[code]?.title || { en: fallback, zh: fallback }; } function panelLabelFor(metricTexts: Record, code: string | null, fallback: string): LocalizedText { if (!code) return metricTexts.__overall__?.label || { en: fallback, zh: fallback }; return metricTexts[code]?.label || { en: fallback, zh: fallback }; } function radarLabelLines(value: string, maxLineLength = 16): string[] { const normalized = value.trim(); if (!normalized || normalized.length <= maxLineLength) return [normalized]; const words = normalized.split(/\s+/).filter(Boolean); if (words.length === 1) { const characters = Array.from(normalized); const splitAt = Math.ceil(characters.length / 2); return [characters.slice(0, splitAt).join(""), characters.slice(splitAt).join("")]; } let bestSplit = 1; let bestScore = Number.POSITIVE_INFINITY; for (let split = 1; split < words.length; split += 1) { const firstLength = words.slice(0, split).join(" ").length; const secondLength = words.slice(split).join(" ").length; const score = Math.max(firstLength, secondLength) * 2 + Math.abs(firstLength - secondLength); if (score < bestScore) { bestScore = score; bestSplit = split; } } return [words.slice(0, bestSplit).join(" "), words.slice(bestSplit).join(" ")]; } function radarLabelSpans(value: string, className: string, x: string): React.ReactNode[] { const lines = radarLabelLines(value); return lines.map((line, index) => ( {line} )); } function OverallRadar({ rows, allRows, highlightName, metricTexts, categoryTexts, }: { rows: TriWorldBoardEntry[]; allRows: TriWorldBoardEntry[]; highlightName: string | null; metricTexts: Record; categoryTexts: Record; }) { const cx = 250; const cy = 250; const radius = 194; const ringFractions = [0.2, 0.4, 0.6, 0.8, 1]; const highlighted = highlightName ? rows.find((row) => row.modelName === highlightName) : null; return ( {ringFractions.map((fraction) => { const points = TRIWORLD_RADAR_DIMS.map((_, index) => radarPoint( cx, cy, radius * fraction, index, TRIWORLD_RADAR_DIMS.length, 100 ) .map((coordinate) => coordinate.toFixed(1)) .join(",") ).join(" "); return ( ); })} {TRIWORLD_RADAR_DIMS.map((dimension, index) => { const [x, y] = radarPoint(cx, cy, radius, index, TRIWORLD_RADAR_DIMS.length, 100); const [labelX, labelY] = radarPoint( cx, cy, radius + 32, index, TRIWORLD_RADAR_DIMS.length, 100 ); const label = categoryTexts[dimension.code] || labelFor(metricTexts, dimension.code, dimension.code); const angle = -Math.PI / 2 + (index / TRIWORLD_RADAR_DIMS.length) * Math.PI * 2; const horizontalDirection = Math.cos(angle); const labelAnchor = horizontalDirection > 0.25 ? "start" : horizontalDirection < -0.25 ? "end" : "middle"; const tickOffsetX = -Math.sin(angle) * 6; const tickOffsetY = Math.cos(angle) * 6; const labelXValue = labelX.toFixed(1); return ( {radarLabelSpans(label.en, "i18n-en", labelXValue)} {radarLabelSpans(label.zh, "i18n-zh", labelXValue)} {ringFractions.map((fraction) => { const [tickX, tickY] = radarPoint( cx, cy, radius, index, TRIWORLD_RADAR_DIMS.length, fraction * 100 ); return ( {Math.round(dimension.maxValue * fraction)} ); })} ); })} {rows.map((row) => { const globalIndex = Math.max(0, allRows.findIndex((item) => item.modelName === row.modelName)); const color = TRIWORLD_COLORS[globalIndex % TRIWORLD_COLORS.length]; const dashPattern = RADAR_LINE_PATTERNS[globalIndex % RADAR_LINE_PATTERNS.length]; const points = TRIWORLD_RADAR_DIMS.map((dimension, dimensionIndex) => radarPoint( cx, cy, radius, dimensionIndex, TRIWORLD_RADAR_DIMS.length, radarPercentage(row.metrics[dimension.code], dimension.maxValue) ) .map((coordinate) => coordinate.toFixed(1)) .join(",") ).join(" "); const isHighlighted = highlightName === row.modelName; const isDimmed = Boolean(highlightName && !isHighlighted); return ( ); })} {highlighted && TRIWORLD_RADAR_DIMS.map((dimension, index) => { const [x, y] = radarPoint( cx, cy, radius, index, TRIWORLD_RADAR_DIMS.length, radarPercentage(highlighted.metrics[dimension.code], dimension.maxValue) ); const color = TRIWORLD_COLORS[ Math.max(0, allRows.findIndex((row) => row.modelName === highlighted.modelName)) % TRIWORLD_COLORS.length ]; return ( {fmt(highlighted.metrics[dimension.code])} ); })} ); } export function TriWorldCases() { const [activeCategory, setActiveCategory] = useState<"clean" | "random">("clean"); return ( <>
); } export function TriWorldVisualization({ rows, metricTexts, categoryTexts, sectionNumber, titleText, descriptionText, }: { rows: TriWorldBoardEntry[]; metricTexts: Record; categoryTexts: Record; sectionNumber: string; titleText: LocalizedText; descriptionText: LocalizedText; }) { const [panelKey, setPanelKey] = useState<(typeof TRIWORLD_VIS_PANELS)[number]["key"]>("overall"); const [highlightName, setHighlightName] = useState(null); const [modelQuery, setModelQuery] = useState(""); const rankingListRef = useRef(null); const visibleRows = useMemo(() => { const query = modelQuery.trim().toLocaleLowerCase(); if (!query) return rows; return rows.filter((row) => row.modelName.toLocaleLowerCase().includes(query)); }, [modelQuery, rows]); useEffect(() => { if (highlightName && !visibleRows.some((row) => row.modelName === highlightName)) { setHighlightName(null); } }, [highlightName, visibleRows]); const visiblePanels = useMemo( () => TRIWORLD_VIS_PANELS.filter((panel) => hasLocalizedText(panelLabelFor(metricTexts, panel.code, panel.key)) ), [metricTexts] ); const activePanel = visiblePanels.find((panel) => panel.key === panelKey) ?? visiblePanels[0] ?? TRIWORLD_VIS_PANELS[0]; const rankedRows = useMemo(() => { const sorted = [...visibleRows]; if (activePanel.code) { sorted.sort( (a, b) => (b.metrics[activePanel.code as string] ?? -Infinity) - (a.metrics[activePanel.code as string] ?? -Infinity) ); return sorted.map((row, index) => ({ ...row, value: row.metrics[activePanel.code as string] ?? null, visualRank: index + 1, })); } sorted.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity)); return sorted.map((row, index) => ({ ...row, value: row.score, visualRank: index + 1 })); }, [activePanel.code, visibleRows]); useEffect(() => { if (!highlightName) return; const list = rankingListRef.current; const selectedRow = Array.from(list?.querySelectorAll(".twb-bar-row") || []) .find((row) => row.dataset.model === highlightName); if (!list || !selectedRow) return; const listRect = list.getBoundingClientRect(); const rowRect = selectedRow.getBoundingClientRect(); list.scrollTop = Math.max( 0, list.scrollTop + rowRect.top - listRect.top - (list.clientHeight - rowRect.height) / 2 ); }, [activePanel.key, highlightName, rankedRows]); const maxValue = Math.max( 1, ...rankedRows.map((row) => (row.value === null ? 0 : Number(row.value))) ); const activePanelTitle = titleFor(metricTexts, activePanel.code, activePanel.key); return (
{sectionNumber}

{visibleRows.map((row) => { const globalIndex = Math.max(0, rows.findIndex((item) => item.modelName === row.modelName)); const color = TRIWORLD_COLORS[globalIndex % TRIWORLD_COLORS.length]; const isActive = highlightName === row.modelName; return ( ); })}
{!visibleRows.length ?

: null}
{visiblePanels.map((panel) => ( ))}
{hasLocalizedText(activePanelTitle) ? (

) : null}
{rankedRows.map((row) => { const globalIndex = Math.max(0, rows.findIndex((item) => item.modelName === row.modelName)); const color = TRIWORLD_COLORS[globalIndex % TRIWORLD_COLORS.length]; const isHighlighted = highlightName === row.modelName; const isDimmed = Boolean(highlightName && !isHighlighted); const width = row.value === null ? 0 : (Number(row.value) / maxValue) * 100; const medalColor = row.visualRank === 1 ? "#c58b13" : row.visualRank === 2 ? "#6b7280" : "#b56736"; return ( ); })} {!rankedRows.length ?

: null}
); }