import { useCallback, useMemo, useRef, useState } from "react"; import { DataTableView, PaneStatusBody, QueryBar, type DataTableCell, type DataTableColumn, type DataTableKeyEvent, type DataTableSelectionChangeReason, type PaneFooterSegment } from "../../../components"; import { useAsyncResource } from "../../../react/async-resource"; import { useShortcut } from "../../../react/input"; import { usePaneSettingValue } from "../../../state/app/context"; import { colors } from "../../../theme/colors"; import type { PaneProps } from "../../../types/plugin"; import { Box, ScrollBox, useUiCapabilities, type InputRenderable } from "../../../ui"; import { formatNumber } from "../../../utils/format"; import { isPlainKey } from "../../../utils/keyboard"; import { stopSearchFocusNavigation } from "../../../utils/search-focus-navigation"; import { useAutoRefresh } from "../shared/auto-refresh"; import { usePaneStatusFooter } from "../shared/pane-footer"; import { getCachedValuationBundle, loadValuationBundle } from "./client"; import { indicatorSeries, indicatorUnavailableReason, shortZoneLabel, type IndicatorDef, type ValuationRangeId } from "./defs"; import { IndicatorDetail } from "./detail"; import { INDICATORS } from "./indicators"; import { RANGE_OPTIONS, VALUATION_DEFAULTS } from "./settings"; import { formatSigma, selectValuationViews, type IndicatorViewModel } from "./view"; const loadBundle = (force: boolean) => loadValuationBundle({ force }); /** Below this the detail sits under the table instead of beside it. */ const SPLIT_MIN_WIDTH = 108; const LIST_WIDTH = 46; /** The range strip names the key that picks each range. */ const RANGE_VIEW_OPTIONS = RANGE_OPTIONS.map((option, index) => ({ ...option, hint: String(index + 1) })); type ColumnId = "name" | "value" | "zone" | "percentile" | "sigma"; interface Column extends DataTableColumn { id: ColumnId } interface IndicatorRow { indicator: IndicatorDef; view: IndicatorViewModel | null; error: string | null; } function matchesQuery(row: IndicatorRow, query: string): boolean { if (!query) return true; const haystack = [ row.indicator.label, row.indicator.shortLabel, row.indicator.description, row.view?.zone?.label, ].join(" ").toLowerCase(); return query.split(/\s+/).every((token) => haystack.includes(token)); } /** Stacked mode keeps only what fits; the split has a whole column to work with. */ function buildColumns(width: number, stacked: boolean): Column[] { const withTrend = stacked && width >= 100; return [ { id: "name", label: "INDICATOR", width: 11, flexGrow: 1, align: "left" }, { id: "value", label: "VALUE", width: 8, align: "right" }, { id: "zone", label: "ZONE", width: 11, align: "right" }, { id: "percentile", label: "RICH", width: 6, align: "right" }, ...(withTrend ? [{ id: "sigma" as const, label: "TREND", width: 8, align: "right" as const }] : []), ]; } // Stable table adapters so memoized rows survive pane re-renders. const indicatorKey = (row: IndicatorRow) => row.indicator.id; const renderIndicatorCell = (row: IndicatorRow, column: Column) => cellsFor(row)[column.id]; function cellsFor(row: IndicatorRow): Record { const view = row.view; if (!view || view.current.ratio == null || !view.zone) { return { name: { text: row.indicator.shortLabel, color: colors.textBright }, value: { text: "--", color: colors.textDim }, zone: { text: "Unavailable", color: colors.warning }, percentile: { text: "--", color: colors.textDim }, sigma: { text: "--", color: colors.textDim }, }; } return { name: { text: view.indicator.shortLabel, color: colors.textBright }, value: { text: view.indicator.formatValue(view.current.ratio), color: view.zone.color }, zone: { text: shortZoneLabel(view.zone.id), color: view.zone.color }, // Restated so a high number always means expensive, whichever way the // underlying measure runs, otherwise the column cannot be read down. percentile: { text: view.richPercentile == null ? "--" : formatNumber(view.richPercentile, 0), color: colors.text }, sigma: { text: formatSigma(view.richSigma), color: colors.textMuted }, }; } /** * The table commits keyboard moves by index after a short delay. When a filter * changes the rows in between, that index lands on a different indicator, and * honouring it would silently rewrite the persisted setting as the user types. * Pointer and activation commits are explicit, so they are always honoured. */ export function shouldPersistSelection({ id, reason, selectionOnScreen, knownIds, }: { id: string; reason: DataTableSelectionChangeReason; selectionOnScreen: boolean; knownIds: readonly string[]; }): boolean { if (!knownIds.includes(id)) return false; return reason !== "keyboard" || selectionOnScreen; } export function MarketValuationPane({ focused, width, height }: PaneProps) { const [indicatorId, setIndicatorId] = usePaneSettingValue( "indicator", VALUATION_DEFAULTS.indicator, ); const [range, setRange] = usePaneSettingValue("range", VALUATION_DEFAULTS.range); const resource = useAsyncResource(loadBundle, { initialData: () => getCachedValuationBundle() }); const { data: bundle, load, reload: refresh, updatedAt: lastUpdated } = resource; const { nativePaneChrome } = useUiCapabilities(); const [query, setQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); const [searchFocusToken, setSearchFocusToken] = useState(0); const searchInputRef = useRef(null); useAutoRefresh(lastUpdated, load); const focusSearch = useCallback(() => { setSearchFocused(true); setSearchFocusToken((current) => current + 1); }, []); const blurSearch = useCallback(() => setSearchFocused(false), []); const handlePaneKey = useCallback((event: DataTableKeyEvent): boolean => { if (isPlainKey(event, "/")) { stopSearchFocusNavigation(event); focusSearch(); return true; } if (isPlainKey(event, "r")) { stopSearchFocusNavigation(event); refresh(); return true; } return false; }, [focusSearch, refresh]); useShortcut((event) => { if (!focused || searchFocused || event.targetEditable || event.defaultPrevented) return; if (isPlainKey(event, "r")) { event.preventDefault?.(); event.stopPropagation?.(); refresh(); return; } // 1, 2 and 3 pick the history range, as the chart's range keys do. const picked = RANGE_VIEW_OPTIONS.find((option) => isPlainKey(event, option.hint)); if (!picked) return; event.preventDefault?.(); event.stopPropagation?.(); setRange(picked.value); }); const views = useMemo( () => (bundle ? selectValuationViews(bundle, range) : []), [bundle, range], ); const rows = useMemo(() => bundle ? INDICATORS.flatMap((indicator) => { const view = views.find((entry) => entry.indicator.id === indicator.id) ?? null; const error = indicatorUnavailableReason(indicator) ?? bundle.errors.find((entry) => [indicator.label, ...indicatorSeries(indicator).map((def) => def.key)] .some((prefix) => entry.startsWith(`${prefix}:`))) ?? null; return view || error ? [{ indicator, view, error }] : []; }) : [], [bundle, views]); const normalizedQuery = query.trim().toLowerCase(); const visible = useMemo( () => rows.filter((row) => matchesQuery(row, normalizedQuery)), [normalizedQuery, rows], ); // Filtering narrows the list, but the detail keeps showing the chosen indicator // until the user picks another, so typing never blanks the chart. const selected = rows.find((row) => row.indicator.id === indicatorId) ?? rows[0] ?? null; const selectedView = selected?.view; const selectionOnScreen = visible.some((view) => view.indicator.id === selected?.indicator.id); const chooseIndicator = useCallback(( id: string, reason: DataTableSelectionChangeReason, ) => { if (!shouldPersistSelection({ id, reason, selectionOnScreen, knownIds: rows.map((row) => row.indicator.id), })) return; setIndicatorId(id); }, [selectionOnScreen, setIndicatorId, rows]); const basisErrors = new Set(INDICATORS.flatMap((indicator) => { const reason = indicatorUnavailableReason(indicator); return reason ? [`${indicator.label}: ${reason}`] : []; })); const sourceError = bundle?.errors.find((entry) => !basisErrors.has(entry)); const unavailableCount = bundle?.errors.filter((entry) => basisErrors.has(entry)).length ?? 0; const bodyError = resource.error ?? sourceError ?? null; const error = bodyError ?? selected?.error ?? (unavailableCount ? `${unavailableCount} unavailable` : null); const footerInfo = useMemo(() => { if (!selectedView) return []; const info: PaneFooterSegment[] = [ { id: "as-of", parts: [{ text: `as of ${selectedView.asOf}`, tone: "muted" }] }, ...(width >= 80 ? [{ id: "delayed", parts: [{ text: "delayed", tone: "muted" as const }] }] : []), ]; if (selectedView.observationStale) { info.push({ id: "stale", parts: [{ text: "STALE", tone: "warning", bold: true }] }); } return info; }, [selectedView, width]); usePaneStatusFooter({ registrationId: "market-valuation", loading: resource.loading, error: error && !selectedView ? "Unavailable" : error, info: footerInfo, }); if (!bundle && resource.error === null) { return ( ); } if (!selected) { return ( ); } const split = width >= SPLIT_MIN_WIDTH; const listWidth = split ? Math.min(LIST_WIDTH, Math.floor(width * 0.4)) : width; const detailWidth = split ? width - listWidth : width; // The terminal scroller draws its bar in the last column; the detail stays clear of it. const detailContentWidth = Math.max(1, detailWidth - (nativePaneChrome ? 0 : 1)); const columns = buildColumns(listWidth, !split); // Everything under the query bar. The split table fills it to the footer. const bodyHeight = Math.max(1, height - 1); // Stacked: header plus every row, and one more line for the horizontal scrollbar. const tableHeight = split ? Math.max(3, bodyHeight) : Math.min(visible.length + 2, Math.max(3, height - 12)); // The stacked table consumes rows outside the detail scroll viewport. const detailHeight = split ? bodyHeight : Math.max(1, bodyHeight - tableHeight); const list = ( focused={focused && !searchFocused} rootWidth={listWidth} rootHeight={tableHeight} columns={columns} items={visible} sortColumnId={null} sortDirection="asc" selection={{ kind: "id", selectedId: selected.indicator.id, getId: (view) => view.indicator.id, onChange: (id, _item, _index, reason) => chooseIndicator(String(id), reason), }} onRootKeyDown={handlePaneKey} getItemKey={indicatorKey} renderCell={renderIndicatorCell} emptyStateTitle={normalizedQuery ? "No indicator matches." : error ?? "No indicators."} /> ); const detail = ( {selectedView ? : ( )} ); return ( active ? focusSearch() : blurSearch(), focusToken: searchFocusToken, inputRef: searchInputRef, debounceMs: 80, }} view={{ value: range, options: RANGE_VIEW_OPTIONS, onChange: (value: string) => setRange(value as ValuationRangeId) }} /> {list} {detail} ); }