Skip to content
Merged
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
67 changes: 48 additions & 19 deletions kb-viz/frontend/src/frames/SummaryFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,45 @@ export function SummaryFrame(_props: FrameProps) {

{/* ── Current selection ── */}
<div style={S.section}>
<p style={S.heading}>selection · {selected.size}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<p style={{ ...S.heading, margin: 0 }}>
selection · {selected.size}
{selected.size === 0 && <span style={{ ...S.empty, fontStyle: 'normal', marginLeft: 6, fontSize: 9 }}>shift+click to add</span>}
</p>
{selected.size > 0 && (
<button
style={{ ...S.navBtn, width: 'auto', fontSize: 10, color: 'var(--text-muted)', padding: '0 2px' }}
onClick={() => selectionStore.getState().clear()}
>
clear all
</button>
)}
</div>
{selected.size === 0
? <span style={S.empty}>Nothing selected</span>
: selected.size > 8
? <SelectionSummary nodes={selectedNodes} />
? <SelectionSummary nodes={selectedNodes} onClearAll={() => selectionStore.getState().clear()} />
: selectedNodes.map((n) => (
<button
key={n.id}
style={{ ...S.navBtn, opacity: n.id === focused ? 1 : 0.8 }}
onClick={() => selectionStore.getState().selectOnly(n.id)}
>
<div style={S.row}>
<span style={{ ...S.label, color: n.id === focused ? 'var(--selected)' : 'var(--text)' }}>
{deriveLabel(n, 50)}
</span>
<span style={S.badge}>{n.type}</span>
</div>
</button>
<div key={n.id} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
style={{ ...S.navBtn, opacity: n.id === focused ? 1 : 0.8, flex: 1 }}
onClick={() => selectionStore.getState().selectOnly(n.id)}
>
<div style={S.row}>
<span style={{ ...S.label, color: n.id === focused ? 'var(--selected)' : 'var(--text)' }}>
{deriveLabel(n, 45)}
</span>
<span style={S.badge}>{n.type}</span>
</div>
</button>
<button
title="Remove from selection"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 12, padding: '0 3px', lineHeight: 1, flexShrink: 0 }}
onClick={() => selectionStore.getState().toggle(n.id)}
>
×
</button>
</div>
))}
</div>

Expand All @@ -127,16 +148,24 @@ export function SummaryFrame(_props: FrameProps) {
);
}

function SelectionSummary({ nodes }: { nodes: Node[] }) {
function SelectionSummary({ nodes, onClearAll }: { nodes: Node[]; onClearAll: () => void }) {
const byType = nodes.reduce<Record<string, number>>((acc, n) => {
acc[n.type] = (acc[n.type] ?? 0) + 1;
return acc;
}, {});
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{Object.entries(byType).map(([type, count]) => (
<span key={type} style={S.chip}>{count} {type}{count !== 1 ? 's' : ''}</span>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{Object.entries(byType).map(([type, count]) => (
<span key={type} style={S.chip}>{count} {type}{count !== 1 ? 's' : ''}</span>
))}
</div>
<button
style={{ ...S.navBtn, width: 'auto', alignSelf: 'flex-start', fontSize: 10, color: 'var(--text-muted)' }}
onClick={onClearAll}
>
clear all ×
</button>
</div>
);
}
Expand Down
12 changes: 7 additions & 5 deletions kb-viz/frontend/src/frames/TimelineFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState, useCallback } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import DeckGL from '@deck.gl/react';
import { ScatterplotLayer, LineLayer, TextLayer } from '@deck.gl/layers';
import { OrthographicView } from '@deck.gl/core';
Expand Down Expand Up @@ -42,8 +42,9 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) {
const scopedIds = useScopedIds(level);
const dateRange = useStore(filterStore, (s) => s.dateRange);

// Brush state: [startX, endX] in data space (ms)
// Brush state: [startX, endX] in data space (ms) + whether shift was held at drag start
const [brush, setBrush] = useState<[number, number] | null>(null);
const brushShiftRef = useRef(false);

const encode = useMemo(
() => makeColorEncoder(nodesById, nodeTypes, colorBy),
Expand Down Expand Up @@ -166,9 +167,10 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) {
transitions: { getFillColor: 120 },
}),
]}
onDragStart={(info) => {
onDragStart={(info, event) => {
if (!info.coordinate) return;
const x = info.coordinate[0];
brushShiftRef.current = !!(event?.srcEvent as MouseEvent | undefined)?.shiftKey;
setBrush([x, x]);
}}
onDrag={(info) => {
Expand All @@ -179,9 +181,9 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) {
if (!brush) return;
const [a, b] = [Math.min(brush[0], info.coordinate?.[0] ?? brush[1]), Math.max(brush[0], info.coordinate?.[0] ?? brush[1])];
if (b - a > 1000 * 60 * 60 * 24 * 30) {
// Meaningful range (> 30 days) → apply filter
const ids = points.filter((p) => p.x >= a && p.x <= b).map((p) => p.id);
selectionStore.getState().boxSelect(ids);
if (brushShiftRef.current) selectionStore.getState().addToSelection(ids);
else selectionStore.getState().boxSelect(ids);
filterStore.getState().setDateRange({ startMs: a, endMs: b });
}
setBrush(null);
Expand Down
Loading