From e54c253cd5b710a2577d5033d8f7db4234d34234 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 03:28:03 +0000 Subject: [PATCH] Phase 1: multi-node select + summary deselect controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimelineFrame: - Shift+drag brush now calls addToSelection() instead of boxSelect(), so brushing a date range while holding shift adds to the existing set SummaryFrame: - Add × button per selection item to remove it via toggle() - Add "clear all" button next to the selection count heading - Large-selection (>8) chip summary also gets a "clear all ×" button - Hint text "shift+click to add" shown in the heading when nothing is selected Shift+click single-node additive select was already wired in all canvas frames (Semantic, Map, Timeline) and box-select shift-add was already wired in SemanticFrame — no changes needed there. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/SummaryFrame.tsx | 67 ++++++++++++++------ kb-viz/frontend/src/frames/TimelineFrame.tsx | 12 ++-- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/kb-viz/frontend/src/frames/SummaryFrame.tsx b/kb-viz/frontend/src/frames/SummaryFrame.tsx index 78c0a95..2b30339 100644 --- a/kb-viz/frontend/src/frames/SummaryFrame.tsx +++ b/kb-viz/frontend/src/frames/SummaryFrame.tsx @@ -91,24 +91,45 @@ export function SummaryFrame(_props: FrameProps) { {/* ── Current selection ── */}
-

selection · {selected.size}

+
+

+ selection · {selected.size} + {selected.size === 0 && shift+click to add} +

+ {selected.size > 0 && ( + + )} +
{selected.size === 0 ? Nothing selected : selected.size > 8 - ? + ? selectionStore.getState().clear()} /> : selectedNodes.map((n) => ( - +
+ + +
))}
@@ -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>((acc, n) => { acc[n.type] = (acc[n.type] ?? 0) + 1; return acc; }, {}); return ( -
- {Object.entries(byType).map(([type, count]) => ( - {count} {type}{count !== 1 ? 's' : ''} - ))} +
+
+ {Object.entries(byType).map(([type, count]) => ( + {count} {type}{count !== 1 ? 's' : ''} + ))} +
+
); } diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 94ca6f7..9485983 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -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'; @@ -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), @@ -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) => { @@ -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);