From 7900a3d8cd23d552e09630c734f990d84ec9dca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 04:43:34 +0000 Subject: [PATCH] Add filter sidebar, side-by-side text comparison, and hover tooltip - FilterFrame: exposes filterStore controls (text search, node type checkboxes, annotation type chips, active date range display) with live node count and reset. Registered as 'filter' frame type. - TextFrame: when 2+ nodes are selected, splits into a two-pane side-by-side comparison view showing focused node left and the first other selected node right. - Hover tooltip: NodeTooltip was already globally rendered in AppShell via the existing component; wired correctly to selectionStore.hovered which is already set by MapFrame and SemanticFrame onHover handlers. - New 'filter+map' layout preset combining filter panel, summary, map, timeline, text, and semantic frames. Co-Authored-By: Claude --- kb-viz/frontend/src/App.tsx | 7 +- kb-viz/frontend/src/components/AppShell.tsx | 1 + kb-viz/frontend/src/frames/FilterFrame.tsx | 141 ++++++++++++++++++++ kb-viz/frontend/src/frames/TextFrame.tsx | 33 ++++- kb-viz/frontend/src/frames/index.ts | 2 + kb-viz/frontend/src/state/layout-store.ts | 13 +- 6 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 kb-viz/frontend/src/frames/FilterFrame.tsx diff --git a/kb-viz/frontend/src/App.tsx b/kb-viz/frontend/src/App.tsx index 92910a2..50c17c0 100644 --- a/kb-viz/frontend/src/App.tsx +++ b/kb-viz/frontend/src/App.tsx @@ -13,10 +13,11 @@ import type { FrameType } from './state/layout-store'; // Register all frames with the registry import './frames/index'; -const BUILTIN_PRESETS = ['4-panel', 'map-focus', 'text-focus', 'llm-focus', 'single']; +const BUILTIN_PRESETS = ['4-panel', 'map-focus', 'text-focus', 'llm-focus', 'filter+map', 'single']; const BUILTIN_LABELS: Record = { '4-panel': '4-panel', 'map-focus': 'map focus', - 'text-focus': 'text focus', 'llm-focus': 'llm focus', 'single': 'single', + 'text-focus': 'text focus', 'llm-focus': 'llm focus', + 'filter+map': 'filter + map', 'single': 'single', }; export function App() { @@ -155,7 +156,7 @@ function LayoutMenu() { ); } -const ADDABLE_FRAMES: FrameType[] = ['semantic', 'map', 'timeline', 'chart', 'text', 'graph', 'summary', 'llm']; +const ADDABLE_FRAMES: FrameType[] = ['semantic', 'map', 'timeline', 'chart', 'text', 'graph', 'summary', 'llm', 'filter']; function AddFrameButton() { return ( diff --git a/kb-viz/frontend/src/components/AppShell.tsx b/kb-viz/frontend/src/components/AppShell.tsx index 6954539..6ae9c9b 100644 --- a/kb-viz/frontend/src/components/AppShell.tsx +++ b/kb-viz/frontend/src/components/AppShell.tsx @@ -18,6 +18,7 @@ const FRAME_LABELS: Record = { entity: 'Entity', summary: 'Summary', llm: 'LLM Assistant', + filter: 'Filters', }; export function AppShell() { diff --git a/kb-viz/frontend/src/frames/FilterFrame.tsx b/kb-viz/frontend/src/frames/FilterFrame.tsx new file mode 100644 index 0000000..f1b8377 --- /dev/null +++ b/kb-viz/frontend/src/frames/FilterFrame.tsx @@ -0,0 +1,141 @@ +import { useStore } from '../lib/use-store'; +import { dataStore } from '../state/data-store'; +import { filterStore } from '../state/filter-store'; +import type { FrameProps } from './registry'; + +const ANNOTATION_TYPES = [ + { key: 'geographic', label: 'geo', color: 'var(--loc)', bg: 'var(--loc-bg)' }, + { key: 'temporal', label: 'time', color: 'var(--time)', bg: 'var(--time-bg)' }, + { key: 'entity_ref', label: 'entity', color: 'var(--person)', bg: 'var(--person-bg)' }, + { key: 'numeric', label: 'numeric', color: 'var(--keyword)', bg: 'var(--keyword-bg)' }, +]; + +const S: Record = { + frame: { padding: '10px 12px', overflowY: 'auto', height: '100%', background: 'var(--surface)', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 14 }, + heading: { fontSize: 10, fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.6px', color: 'var(--title-color)', margin: 0 }, + section: { display: 'flex', flexDirection: 'column', gap: 6 }, + subHead: { fontSize: 10, color: 'var(--title-color)', textTransform: 'uppercase' as const, letterSpacing: '0.5px', fontWeight: 600 }, +}; + +export function FilterFrame(_props: FrameProps) { + const manifest = useStore(dataStore, (s) => s.manifest); + const totalNodes = useStore(dataStore, (s) => s.nodes.size); + const typeFilter = useStore(filterStore, (s) => s.typeFilter); + const annotTypes = useStore(filterStore, (s) => s.annotationTypes); + const textQuery = useStore(filterStore, (s) => s.textQuery); + const dateRange = useStore(filterStore, (s) => s.dateRange); + const activeIds = useStore(filterStore, (s) => s.activeIds); + + const nodeTypes = manifest?.node_types ?? []; + const isFiltered = typeFilter.size > 0 || annotTypes.size > 0 || !!textQuery || !!dateRange; + + return ( +
+ {/* Header row */} +
+

filters

+ + {activeIds.size}/{totalNodes} + {isFiltered && ( + + )} + +
+ + {/* Text search */} +
+ text search + filterStore.getState().setTextQuery(e.target.value)} + placeholder="filter by content…" + style={{ + width: '100%', background: 'var(--bg)', color: 'var(--text)', + border: 'var(--border-width) solid var(--border)', borderRadius: 4, + padding: '5px 8px', fontSize: 12, outline: 'none', boxSizing: 'border-box', + }} + /> +
+ + {/* Node type filter */} + {nodeTypes.length > 0 && ( +
+ node type +
+ {nodeTypes.map((nt) => { + const active = typeFilter.has(nt.id); + return ( + + ); + })} +
+
+ )} + + {/* Annotation type filter */} +
+ has annotation +
+ {ANNOTATION_TYPES.map(({ key, label, color, bg }) => { + const active = annotTypes.has(key); + return ( + + ); + })} +
+
+ + {/* Active date range */} + {dateRange && ( +
+ date range +
+ + {new Date(dateRange.startMs).getUTCFullYear()} + {' → '} + {new Date(dateRange.endMs).getUTCFullYear()} + + +
+
+ )} +
+ ); +} diff --git a/kb-viz/frontend/src/frames/TextFrame.tsx b/kb-viz/frontend/src/frames/TextFrame.tsx index a59263e..3382148 100644 --- a/kb-viz/frontend/src/frames/TextFrame.tsx +++ b/kb-viz/frontend/src/frames/TextFrame.tsx @@ -15,9 +15,17 @@ import { import { deriveLabel } from '../lib/derive-label'; import type { FrameProps } from './registry'; + export function TextFrame(_props: FrameProps) { const nodesById = useStore(dataStore, (s) => s.nodes); - const focused = useStore(selectionStore, (s) => s.focused); + const focused = useStore(selectionStore, (s) => s.focused); + const selected = useStore(selectionStore, (s) => s.selected); + + // Pick a comparison node: the first selected id that isn't focused + const compareId = selected.size >= 2 + ? [...selected].find((id) => id !== focused) ?? null + : null; + const compareNode = compareId ? nodesById.get(compareId) ?? null : null; if (!focused) { return ( @@ -37,8 +45,29 @@ export function TextFrame(_props: FrameProps) { ); } + if (compareNode) { + return ( +
+
+ +
+
+ +
+
+ ); + } + return (
+ +
+ ); +} + +function NodeContent({ node, nodesById }: { node: Node; nodesById: Map }) { + return ( + <>

{deriveLabel(node)}

{node.id} @@ -53,7 +82,7 @@ export function TextFrame(_props: FrameProps) { )} -
+ ); } diff --git a/kb-viz/frontend/src/frames/index.ts b/kb-viz/frontend/src/frames/index.ts index e6d04b4..9e2e006 100644 --- a/kb-viz/frontend/src/frames/index.ts +++ b/kb-viz/frontend/src/frames/index.ts @@ -7,6 +7,7 @@ import { TextFrame } from './TextFrame'; import { GraphFrame } from './GraphFrame'; import { SummaryFrame } from './SummaryFrame'; import { LLMFrame } from './LLMFrame'; +import { FilterFrame } from './FilterFrame'; // Activate history tracking (subscribes to selectionStore) import '../state/history-store'; @@ -19,3 +20,4 @@ registerFrame('text', TextFrame); registerFrame('graph', GraphFrame); registerFrame('summary', SummaryFrame); registerFrame('llm', LLMFrame); +registerFrame('filter', FilterFrame); diff --git a/kb-viz/frontend/src/state/layout-store.ts b/kb-viz/frontend/src/state/layout-store.ts index 8ab66ff..046c246 100644 --- a/kb-viz/frontend/src/state/layout-store.ts +++ b/kb-viz/frontend/src/state/layout-store.ts @@ -18,7 +18,8 @@ export type FrameType = | 'search' | 'entity' | 'summary' - | 'llm'; + | 'llm' + | 'filter'; // --------------------------------------------------------------------------- // Mosaic-compatible pane tree (binary split tree) @@ -75,6 +76,16 @@ const PRESETS: Record = { second: 'llm', splitPercentage: 55, }, + 'filter+map': { + direction: 'row', + first: { direction: 'column', first: 'filter', second: 'summary', splitPercentage: 45 }, + second: { + direction: 'column', + first: { direction: 'row', first: 'map', second: 'timeline', splitPercentage: 55 }, + second: { direction: 'row', first: 'text', second: 'semantic' }, + }, + splitPercentage: 20, + }, single: 'semantic', };