import { useMemo } from 'react' import { useWidgetId, useWidgetShallow, type WidgetState } from '../stores' import { CategoryUI } from './category-ui' import type { CategoryKey, CategoryLabels, CategorySeriesConfig, CategorySize, CategoryWidgetData, } from './types' interface CategorySlice { data: CategoryWidgetData rawData: CategoryWidgetData | undefined formatter?: (value: number) => string labelFormatter?: (value: string | number) => string | number } const categorySelector = (s: WidgetState): CategorySlice => ({ data: (s.data ?? []) as CategoryWidgetData, rawData: (s.rawData ?? undefined) as CategoryWidgetData | undefined, formatter: s.formatter, labelFormatter: s.labelFormatter, }) function maxFromCategoryData(d: CategoryWidgetData | undefined): number { if (!d) return 0 let m = 0 for (const series of d) { for (const item of series) if (item.value > m) m = item.value } return m } export interface CategoryProps { /** Currently-selected category names. Destination-owned. */ selection?: readonly CategoryKey[] /** Fires when a row is clicked. Consumer updates the destination's store. */ onSelectionChange?: (next: readonly CategoryKey[]) => void /** Per-series metadata. Enables the legend + overrides palette per index. */ series?: readonly CategorySeriesConfig[] /** * Cap visible rows; overflow folds into an "Others " row. Default 20 * (when omitted). Pass `0` to swap the cap for a scrollable viewport * (composers use this when the user opens the SearcherToggle — * `maxItems = searcherOpen ? 0 : userMaxItems`). Pass `null` to disable * the cap entirely without adding a scroll viewport. */ maxItems?: number | null /** Labels for the "Other" overflow row. */ labels?: CategoryLabels /** * When provided, the "Other" overflow row becomes a button that fires this * callback. Composers wire it to expand the widget (e.g. flip the * `show-all` flag) so every category is shown. Forwarded to * {@link CategoryUI}. */ onShowAll?: () => void /** * Manual override for the bar-width denominator. When omitted, the * bridge auto-fills from the widget store's `rawData` so bar widths * stay coherent across data transforms (e.g., the Searcher filtering * rows in and out — bars don't rescale just because the larger rows * got hidden). Pass an explicit number to fix the denominator * regardless of the data. */ maxOverride?: number /** * Visual density of the bar primitive. `'small'` (default) keeps the * historical 4px-tall pill; `'medium'` switches to a 12px-tall track * with a 2px corner radius. Forwarded as-is to {@link CategoryUI}. */ size?: CategorySize /** * Multi-series stacked mode. Forwarded as-is to {@link CategoryUI}. * Composers typically wire this from * `useTransformEnabled(id, 'stack-toggle')`. No-op for single-series. */ stacked?: boolean } /** * Stateful Category bridge — reads `data`, `formatter`, and * `labelFormatter` (post-pipeline) from the per-widget store and * forwards them to the pure {@link CategoryUI}. `selection` and * `onSelectionChange` follow the destination-owned principle: the * consumer keeps the list in their own store and passes it through. */ export function Category({ selection, onSelectionChange, series, maxItems, labels, onShowAll, maxOverride, size, stacked, }: CategoryProps) { const id = useWidgetId() const slice = useWidgetShallow(id, categorySelector) // Auto-fill `maxOverride` from `rawData` so bar widths stay coherent // across data transforms (e.g., the Searcher filtering rows in/out). // Consumer's explicit value always wins; we only fill when omitted // AND `rawData` has at least one positive value (CategoryUI's // `maxOverride > 0` guard would reject 0 anyway, but skipping is // cleaner — it lets the live fallback path handle the empty case). const naturalMax = useMemo( () => maxFromCategoryData(slice.rawData), [slice.rawData], ) const effectiveMaxOverride = maxOverride ?? (naturalMax > 0 ? naturalMax : undefined) return ( ) }