Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions kb-viz/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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() {
Expand Down Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions kb-viz/frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const FRAME_LABELS: Record<FrameType, string> = {
entity: 'Entity',
summary: 'Summary',
llm: 'LLM Assistant',
filter: 'Filters',
};

export function AppShell() {
Expand Down
141 changes: 141 additions & 0 deletions kb-viz/frontend/src/frames/FilterFrame.tsx
Original file line number Diff line number Diff line change
@@ -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<string, React.CSSProperties> = {
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 (
<div style={S.frame}>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<p style={S.heading}>filters</p>
<span style={{ fontSize: 11, color: isFiltered ? 'var(--accent)' : 'var(--text-dim)' }}>
{activeIds.size}/{totalNodes}
{isFiltered && (
<button
onClick={() => filterStore.getState().reset()}
style={{ marginLeft: 6, fontSize: 10, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
clear ×
</button>
)}
</span>
</div>

{/* Text search */}
<div style={S.section}>
<span style={S.subHead}>text search</span>
<input
type="text"
value={textQuery}
onChange={(e) => 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',
}}
/>
</div>

{/* Node type filter */}
{nodeTypes.length > 0 && (
<div style={S.section}>
<span style={S.subHead}>node type</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{nodeTypes.map((nt) => {
const active = typeFilter.has(nt.id);
return (
<label key={nt.id} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer' }}>
<input
type="checkbox"
checked={active}
onChange={() => {
const next = new Set(typeFilter);
if (active) next.delete(nt.id);
else next.add(nt.id);
filterStore.getState().setTypeFilter(next);
}}
style={{ accentColor: 'var(--accent)', cursor: 'pointer' }}
/>
<span style={{ fontSize: 12, color: active ? 'var(--text)' : 'var(--text-dim)' }}>{nt.id}</span>
</label>
);
})}
</div>
</div>
)}

{/* Annotation type filter */}
<div style={S.section}>
<span style={S.subHead}>has annotation</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{ANNOTATION_TYPES.map(({ key, label, color, bg }) => {
const active = annotTypes.has(key);
return (
<button
key={key}
onClick={() => filterStore.getState().toggleAnnotationType(key)}
style={{
fontSize: 10, padding: '2px 9px', borderRadius: 9999, cursor: 'pointer',
border: 'var(--border-width) solid',
borderColor: active ? color : 'var(--border)',
background: active ? bg : 'none',
color: active ? color : 'var(--text-muted)',
transition: 'background 120ms, color 120ms, border-color 120ms',
}}
>
{label}
</button>
);
})}
</div>
</div>

{/* Active date range */}
{dateRange && (
<div style={S.section}>
<span style={S.subHead}>date range</span>
<div style={{ fontSize: 11, color: 'var(--time)', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>
{new Date(dateRange.startMs).getUTCFullYear()}
{' → '}
{new Date(dateRange.endMs).getUTCFullYear()}
</span>
<button
onClick={() => filterStore.getState().setDateRange(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 13, padding: 0, lineHeight: 1 }}
>
×
</button>
</div>
</div>
)}
</div>
);
}
33 changes: 31 additions & 2 deletions kb-viz/frontend/src/frames/TextFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -37,8 +45,29 @@ export function TextFrame(_props: FrameProps) {
);
}

if (compareNode) {
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<div className="text-frame" style={{ flex: 1, borderLeft: 'none', borderRight: 'var(--border-width) solid var(--border)' }}>
<NodeContent node={node} nodesById={nodesById} />
</div>
<div className="text-frame" style={{ flex: 1 }}>
<NodeContent node={compareNode} nodesById={nodesById} />
</div>
</div>
);
}

return (
<div className="text-frame">
<NodeContent node={node} nodesById={nodesById} />
</div>
);
}

function NodeContent({ node, nodesById }: { node: Node; nodesById: Map<string, Node> }) {
return (
<>
<h3 title={node.id}>{deriveLabel(node)}</h3>
<div className="meta">
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, color: 'var(--text-muted)', userSelect: 'all' }}>{node.id}</span>
Expand All @@ -53,7 +82,7 @@ export function TextFrame(_props: FrameProps) {
)}
<PropertiesList node={node} />
<AnnotationsList node={node} />
</div>
</>
);
}

Expand Down
2 changes: 2 additions & 0 deletions kb-viz/frontend/src/frames/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,3 +20,4 @@ registerFrame('text', TextFrame);
registerFrame('graph', GraphFrame);
registerFrame('summary', SummaryFrame);
registerFrame('llm', LLMFrame);
registerFrame('filter', FilterFrame);
13 changes: 12 additions & 1 deletion kb-viz/frontend/src/state/layout-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export type FrameType =
| 'search'
| 'entity'
| 'summary'
| 'llm';
| 'llm'
| 'filter';

// ---------------------------------------------------------------------------
// Mosaic-compatible pane tree (binary split tree)
Expand Down Expand Up @@ -75,6 +76,16 @@ const PRESETS: Record<string, PaneNode> = {
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',
};

Expand Down
Loading