({
placed,
selection,
selected,
onSelect,
onShowAllCategories,
}: {
placed: PlacedPoint[];
selection: ReadonlySet<number> | null;
selected: MemoryProjectionPoint | null;
onSelect: (point: MemoryProjectionPoint | null) => void;
/** Present when legend filters hide every point; restores all categories. */
onShowAllCategories?: () => void;
})
| 826 | const ROW_HEIGHT = 46; |
| 827 | |
| 828 | function SidePanel({ |
| 829 | placed, |
| 830 | selection, |
| 831 | selected, |
| 832 | onSelect, |
| 833 | onShowAllCategories, |
| 834 | }: { |
| 835 | placed: PlacedPoint[]; |
| 836 | selection: ReadonlySet<number> | null; |
| 837 | selected: MemoryProjectionPoint | null; |
| 838 | onSelect: (point: MemoryProjectionPoint | null) => void; |
| 839 | /** Present when legend filters hide every point; restores all categories. */ |
| 840 | onShowAllCategories?: () => void; |
| 841 | }) { |
| 842 | const { containerRef, onScroll, start, end, totalHeight, offsetTop } = |
| 843 | useVirtualList({ count: placed.length, rowHeight: ROW_HEIGHT }); |
| 844 | |
| 845 | // Projection payloads truncate content at 200 chars; when the pinned fact |
| 846 | // looks truncated, fetch the full row from the fact-detail endpoint. |
| 847 | const selectedId = selected?.fact_id; |
| 848 | const maybeTruncated = (selected?.content?.length ?? 0) >= 200; |
| 849 | const [detail, setDetail] = useState<{ id: number; content: string } | null>( |
| 850 | null, |
| 851 | ); |
| 852 | const [detailFailedId, setDetailFailedId] = useState<number | null>(null); |
| 853 | useEffect(() => { |
| 854 | if (selectedId == null || !maybeTruncated) return; |
| 855 | let cancelled = false; |
| 856 | setDetailFailedId((prev) => (prev === selectedId ? null : prev)); |
| 857 | api |
| 858 | .getMemoryFact(selectedId) |
| 859 | .then((resp) => { |
| 860 | if (!cancelled) setDetail({ id: selectedId, content: resp.fact.content }); |
| 861 | }) |
| 862 | .catch(() => { |
| 863 | if (!cancelled) setDetailFailedId(selectedId); |
| 864 | }); |
| 865 | return () => { |
| 866 | cancelled = true; |
| 867 | }; |
| 868 | }, [selectedId, maybeTruncated]); |
| 869 | |
| 870 | const fullContent = |
| 871 | detail && detail.id === selectedId ? detail.content : null; |
| 872 | const detailFailed = detailFailedId === selectedId; |
| 873 | const detailLoading = maybeTruncated && !fullContent && !detailFailed; |
| 874 | |
| 875 | // Single tab stop + arrow-key roving selection (the rows themselves stay |
| 876 | // buttons for pointer users, but are skipped in the tab order). |
| 877 | const activeIndex = selected |
| 878 | ? placed.findIndex((p) => p.point.fact_id === selected.fact_id) |
| 879 | : -1; |
| 880 | |
| 881 | const moveTo = (index: number) => { |
| 882 | if (placed.length === 0) return; |
| 883 | const next = Math.max(0, Math.min(placed.length - 1, index)); |
| 884 | onSelect(placed[next].point); |
| 885 | const node = containerRef.current; |
nothing calls this directly
no test coverage detected