From b0c8cd9cc6bfefb1598044a199deecf09149c15a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 04:16:16 +0000 Subject: [PATCH 1/9] Add zoom-dependent node labels to all canvas frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each frame tracks zoom via onViewStateChange and conditionally renders a TextLayer above the scatter layer: - SemanticFrame: labels appear for all nodes at zoom ≥ 3.5 (UMAP space); always visible for selected (red) and hovered (yellow) nodes - MapFrame: labels appear for all nodes at geo zoom ≥ 6 (city level); always visible for selected/hovered; TextLayer rendered below ScatterplotLayer so dots stay on top - TimelineFrame: labels appear when zoomed ≥ 3 stops in from the initial fit zoom (≈8× zoom-in); always visible for selected/hovered; uses monospace font to match the tick axis style All frames use a dark translucent background rect (background: true) on each label for legibility, and truncate text to 22-28 chars depending on the frame. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/MapFrame.tsx | 39 +++++++++++++++- kb-viz/frontend/src/frames/SemanticFrame.tsx | 42 ++++++++++++++++- kb-viz/frontend/src/frames/TimelineFrame.tsx | 47 +++++++++++++++++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 07c27cb..95ed151 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import DeckGL from '@deck.gl/react'; -import { ScatterplotLayer, LineLayer, ArcLayer } from '@deck.gl/layers'; +import { ScatterplotLayer, LineLayer, ArcLayer, TextLayer } from '@deck.gl/layers'; import { HeatmapLayer } from '@deck.gl/aggregation-layers'; import { Map as MapGL } from 'react-map-gl/maplibre'; import { useStore } from '../lib/use-store'; @@ -10,6 +10,7 @@ import { selectionStore } from '../state/selection-store'; import { viewStore } from '../state/view-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { projectMap } from '../projection/projectors/map'; +import { deriveLabel } from '../lib/derive-label'; import type { FrameProps } from './registry'; interface Point { id: string; position: [number, number]; } @@ -47,6 +48,8 @@ export function MapFrame(_props: FrameProps) { const [showHeatmap, setShowHeatmap] = useState(false); const [showArcs, setShowArcs] = useState(true); const [showHull, setShowHull] = useState(true); + const [zoom, setZoom] = useState(3.5); + const LABEL_ZOOM = 6; const encode = useMemo( () => makeColorEncoder(nodesById, nodeTypes, colorBy), @@ -122,6 +125,7 @@ export function MapFrame(_props: FrameProps) { setZoom((vs as { zoom?: number }).zoom ?? 3)} controller layers={[ ...(showHeatmap ? [ @@ -163,6 +167,39 @@ export function MapFrame(_props: FrameProps) { widthUnits: 'pixels', }), ] : []), + new TextLayer({ + id: 'map-labels', + data: zoom >= LABEL_ZOOM + ? points + : points.filter((p) => selected.has(p.id) || p.id === hovered), + getText: (d) => { + const n = nodesById.get(d.id); + return n ? deriveLabel(n, 28) : d.id; + }, + getPosition: (d) => d.position, + getPixelOffset: [0, -14], + getSize: 12, + getColor: (d) => { + if (selected.has(d.id)) return [240, 80, 40, 240]; + if (d.id === hovered) return [251, 191, 36, 240]; + return [220, 225, 220, 200]; + }, + getTextAnchor: 'middle', + getAlignmentBaseline: 'bottom', + fontFamily: 'system-ui, sans-serif', + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [4, 1, 4, 1], + getBackgroundColor: (d) => selected.has(d.id) + ? [40, 10, 5, 200] + : [10, 10, 10, 180], + updateTriggers: { + data: [selected, hovered, zoom], + getColor: [selected, hovered], + getBackgroundColor: [selected], + getText: [nodesById], + }, + }), new ScatterplotLayer({ id: 'map-points', data: points, diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index 9c80922..7ca913a 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -1,6 +1,6 @@ import { useMemo, useEffect, useRef, useState } from 'react'; import DeckGL from '@deck.gl/react'; -import { ScatterplotLayer } from '@deck.gl/layers'; +import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { OrthographicView, OrbitView } from '@deck.gl/core'; import { useStore } from '../lib/use-store'; import { useScopedIds } from '../lib/use-scoped-ids'; @@ -9,6 +9,7 @@ import { selectionStore } from '../state/selection-store'; import { viewStore } from '../state/view-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { useBoxSelect, BoxSelectOverlay } from '../lib/use-box-select'; +import { deriveLabel } from '../lib/derive-label'; import type { SemanticFrameConfig } from '../state/view-store'; import type { UmapRequest, UmapResponse, UmapError } from '../workers/umap.worker'; import type { FrameProps } from './registry'; @@ -35,6 +36,9 @@ export function SemanticFrame(_props: FrameProps) { const [points, setPoints] = useState([]); const [computing, setComputing] = useState(false); const workerRef = useRef(null); + const [zoom, setZoom] = useState(0); + // Show all labels above this zoom; always show selected/hovered labels + const LABEL_ZOOM = 3.5; const { deckRef, dragRect, onMouseDown } = useBoxSelect({ extractId: (obj) => obj?.id, @@ -167,6 +171,7 @@ export function SemanticFrame(_props: FrameProps) { key={mode} views={view} initialViewState={initialViewState} + onViewStateChange={({ viewState: vs }) => setZoom((vs as { zoom?: number }).zoom ?? 0)} controller layers={[ new ScatterplotLayer({ @@ -203,6 +208,41 @@ export function SemanticFrame(_props: FrameProps) { }, transitions: { getFillColor: 120 }, }), + new TextLayer({ + id: 'semantic-labels', + data: zoom >= LABEL_ZOOM + ? points + : points.filter((p) => selected.has(p.id) || p.id === hovered), + getText: (d) => { + const n = nodesById.get(d.id); + return n ? deriveLabel(n, 25) : d.id; + }, + getPosition: (d) => d.position, + getPixelOffset: [0, -12], + getSize: 11, + getColor: (d) => { + if (selected.has(d.id)) return [240, 80, 40, 230]; + if (d.id === hovered) return [251, 191, 36, 230]; + return [200, 205, 200, 160]; + }, + getTextAnchor: 'middle', + getAlignmentBaseline: 'bottom', + fontFamily: 'system-ui, sans-serif', + fontWeight: selected.size > 0 ? 600 : 400, + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [3, 1, 3, 1], + getBackgroundColor: (d) => { + if (selected.has(d.id)) return [30, 10, 8, 180]; + return [14, 22, 12, 160]; + }, + updateTriggers: { + data: [selected, hovered, zoom], + getColor: [selected, hovered], + getBackgroundColor: [selected], + getText: [nodesById], + }, + }), ]} /> diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 94ca6f7..1069a19 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'; @@ -10,6 +10,7 @@ import { viewStore } from '../state/view-store'; import { filterStore } from '../state/filter-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { projectTimeline } from '../projection/projectors/timeline'; +import { deriveLabel } from '../lib/derive-label'; import type { FrameProps } from './registry'; interface Point { id: string; x: number; y: number; } @@ -44,6 +45,9 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { // Brush state: [startX, endX] in data space (ms) const [brush, setBrush] = useState<[number, number] | null>(null); + const [zoom, setZoom] = useState(null); + // Store the initial zoom so we can use it as a relative threshold + const initialZoomRef = useRef(null); const encode = useMemo( () => makeColorEncoder(nodesById, nodeTypes, colorBy), @@ -114,6 +118,11 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { { + const z = (vs as { zoom?: number }).zoom ?? 0; + if (initialZoomRef.current === null) initialZoomRef.current = z; + setZoom(z); + }} controller layers={[ ...rangeBand, @@ -165,6 +174,42 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { updateTriggers: { getFillColor: [selected, hovered, colorBy, nodesById], getRadius: [selected, hovered] }, transitions: { getFillColor: 120 }, }), + new TextLayer({ + id: 'timeline-labels', + data: (() => { + const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; + return zoomed + ? points + : points.filter((p) => selected.has(p.id) || p.id === hovered); + })(), + getText: (d) => { + const n = nodesById.get(d.id); + return n ? deriveLabel(n, 22) : d.id; + }, + getPosition: (d) => [d.x, d.y, 0], + getPixelOffset: [0, -11], + getSize: 10, + getColor: (d) => { + if (selected.has(d.id)) return [240, 80, 40, 230]; + if (d.id === hovered) return [251, 191, 36, 230]; + return [190, 195, 190, 150]; + }, + getTextAnchor: 'middle', + getAlignmentBaseline: 'bottom', + fontFamily: 'ui-monospace, monospace', + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [3, 1, 3, 1], + getBackgroundColor: (d) => selected.has(d.id) + ? [30, 8, 5, 180] + : [14, 22, 12, 160], + updateTriggers: { + data: [selected, hovered, zoom], + getColor: [selected, hovered], + getBackgroundColor: [selected], + getText: [nodesById], + }, + }), ]} onDragStart={(info) => { if (!info.coordinate) return; From c15aacd96a703cda3b8a7a913f9bd57fddc035fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 04:02:37 +0000 Subject: [PATCH 2/9] Shorten ambient node labels to reduce clutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ambient (non-selected) labels truncated more aggressively: - Semantic: 25 → 12 chars - Map: 28 → 14 chars - Timeline: 22 → 11 chars Selected/hovered nodes keep longer labels (20/22/20 chars) so focused nodes remain readable. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/MapFrame.tsx | 2 +- kb-viz/frontend/src/frames/SemanticFrame.tsx | 2 +- kb-viz/frontend/src/frames/TimelineFrame.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 95ed151..5047d9a 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -174,7 +174,7 @@ export function MapFrame(_props: FrameProps) { : points.filter((p) => selected.has(p.id) || p.id === hovered), getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, 28) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 22 : 14) : d.id; }, getPosition: (d) => d.position, getPixelOffset: [0, -14], diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index 7ca913a..42bc19d 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -215,7 +215,7 @@ export function SemanticFrame(_props: FrameProps) { : points.filter((p) => selected.has(p.id) || p.id === hovered), getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, 25) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 20 : 12) : d.id; }, getPosition: (d) => d.position, getPixelOffset: [0, -12], diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 0cc6499..df441b5 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -185,7 +185,7 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { })(), getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, 22) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 20 : 11) : d.id; }, getPosition: (d) => [d.x, d.y, 0], getPixelOffset: [0, -11], From 57007500b28663d215a0cb592bff3fe3cc8475a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 04:15:29 +0000 Subject: [PATCH 3/9] Shorter ambient labels + fade-in/out transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ambient labels truncated to 8 chars; selected/hovered get 14 chars - Labels no longer filter data by zoom — all points stay in the layer; visibility is controlled by alpha in getColor/getBackgroundColor (0 when hidden, full value when visible) - transitions: { getColor: 250, getBackgroundColor: 250 } gives a smooth 250ms fade when labels appear/disappear on zoom or selection change https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/MapFrame.tsx | 31 +++++++++--------- kb-viz/frontend/src/frames/SemanticFrame.tsx | 27 ++++++++-------- kb-viz/frontend/src/frames/TimelineFrame.tsx | 34 ++++++++++---------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 5047d9a..3c5ccb5 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -169,20 +169,19 @@ export function MapFrame(_props: FrameProps) { ] : []), new TextLayer({ id: 'map-labels', - data: zoom >= LABEL_ZOOM - ? points - : points.filter((p) => selected.has(p.id) || p.id === hovered), + data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 22 : 14) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -14], - getSize: 12, + getSize: 11, getColor: (d) => { - if (selected.has(d.id)) return [240, 80, 40, 240]; - if (d.id === hovered) return [251, 191, 36, 240]; - return [220, 225, 220, 200]; + const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; + if (selected.has(d.id)) return [240, 80, 40, show ? 230 : 0]; + if (d.id === hovered) return [251, 191, 36, show ? 230 : 0]; + return [220, 225, 220, show ? 180 : 0]; }, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', @@ -190,14 +189,16 @@ export function MapFrame(_props: FrameProps) { background: true, getBorderColor: [0, 0, 0, 0], backgroundPadding: [4, 1, 4, 1], - getBackgroundColor: (d) => selected.has(d.id) - ? [40, 10, 5, 200] - : [10, 10, 10, 180], + getBackgroundColor: (d) => { + const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; + if (selected.has(d.id)) return [40, 10, 5, show ? 190 : 0]; + return [10, 10, 10, show ? 170 : 0]; + }, + transitions: { getColor: 250, getBackgroundColor: 250 }, updateTriggers: { - data: [selected, hovered, zoom], - getColor: [selected, hovered], - getBackgroundColor: [selected], - getText: [nodesById], + getColor: [selected, hovered, zoom], + getBackgroundColor: [selected, hovered, zoom], + getText: [nodesById, selected, hovered], }, }), new ScatterplotLayer({ diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index 42bc19d..b3e085e 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -210,37 +210,36 @@ export function SemanticFrame(_props: FrameProps) { }), new TextLayer({ id: 'semantic-labels', - data: zoom >= LABEL_ZOOM - ? points - : points.filter((p) => selected.has(p.id) || p.id === hovered), + data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 20 : 12) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -12], getSize: 11, getColor: (d) => { - if (selected.has(d.id)) return [240, 80, 40, 230]; - if (d.id === hovered) return [251, 191, 36, 230]; - return [200, 205, 200, 160]; + const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; + if (selected.has(d.id)) return [240, 80, 40, show ? 220 : 0]; + if (d.id === hovered) return [251, 191, 36, show ? 220 : 0]; + return [200, 205, 200, show ? 140 : 0]; }, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', fontFamily: 'system-ui, sans-serif', - fontWeight: selected.size > 0 ? 600 : 400, background: true, getBorderColor: [0, 0, 0, 0], backgroundPadding: [3, 1, 3, 1], getBackgroundColor: (d) => { - if (selected.has(d.id)) return [30, 10, 8, 180]; - return [14, 22, 12, 160]; + const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; + if (selected.has(d.id)) return [30, 10, 8, show ? 170 : 0]; + return [14, 22, 12, show ? 140 : 0]; }, + transitions: { getColor: 250, getBackgroundColor: 250 }, updateTriggers: { - data: [selected, hovered, zoom], - getColor: [selected, hovered], - getBackgroundColor: [selected], - getText: [nodesById], + getColor: [selected, hovered, zoom], + getBackgroundColor: [selected, hovered, zoom], + getText: [nodesById, selected, hovered], }, }), ]} diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index df441b5..99f6b70 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -177,23 +177,20 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { }), new TextLayer({ id: 'timeline-labels', - data: (() => { - const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; - return zoomed - ? points - : points.filter((p) => selected.has(p.id) || p.id === hovered); - })(), + data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 20 : 11) : d.id; + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => [d.x, d.y, 0], getPixelOffset: [0, -11], getSize: 10, getColor: (d) => { - if (selected.has(d.id)) return [240, 80, 40, 230]; - if (d.id === hovered) return [251, 191, 36, 230]; - return [190, 195, 190, 150]; + const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; + const show = selected.has(d.id) || d.id === hovered || zoomed; + if (selected.has(d.id)) return [240, 80, 40, show ? 220 : 0]; + if (d.id === hovered) return [251, 191, 36, show ? 220 : 0]; + return [190, 195, 190, show ? 130 : 0]; }, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', @@ -201,14 +198,17 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { background: true, getBorderColor: [0, 0, 0, 0], backgroundPadding: [3, 1, 3, 1], - getBackgroundColor: (d) => selected.has(d.id) - ? [30, 8, 5, 180] - : [14, 22, 12, 160], + getBackgroundColor: (d) => { + const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; + const show = selected.has(d.id) || d.id === hovered || zoomed; + if (selected.has(d.id)) return [30, 8, 5, show ? 170 : 0]; + return [14, 22, 12, show ? 140 : 0]; + }, + transitions: { getColor: 250, getBackgroundColor: 250 }, updateTriggers: { - data: [selected, hovered, zoom], - getColor: [selected, hovered], - getBackgroundColor: [selected], - getText: [nodesById], + getColor: [selected, hovered, zoom], + getBackgroundColor: [selected, hovered, zoom], + getText: [nodesById, selected, hovered], }, }), ]} From 6a31094694e09644b324e3965d95101bb3c1b6c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 04:15:59 +0000 Subject: [PATCH 4/9] Fix deriveLabel to truncate title/label/source properties Previously maxLen was only applied to the node.text fallback path. Title and label properties (used by document-level nodes) returned the full raw string, so long book/document titles were never truncated. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/lib/derive-label.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/kb-viz/frontend/src/lib/derive-label.ts b/kb-viz/frontend/src/lib/derive-label.ts index 1881641..ba287cb 100644 --- a/kb-viz/frontend/src/lib/derive-label.ts +++ b/kb-viz/frontend/src/lib/derive-label.ts @@ -1,25 +1,29 @@ import type { Node } from '../types/manifest'; +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + const cut = str.lastIndexOf(' ', maxLen); + return str.slice(0, cut > 0 ? cut : maxLen) + '…'; +} + export function deriveLabel(node: Node, maxLen = 80): string { const title = node.properties['title']; - if (title?.kind === 'categorical') return title.value; + if (title?.kind === 'categorical') return truncate(title.value, maxLen); // 'label' is used by document nodes (and other top-level types without a title) const label = node.properties['label']; - if (label?.kind === 'categorical') return label.value; + if (label?.kind === 'categorical') return truncate(label.value, maxLen); const source = node.properties['source']; if (source?.kind === 'categorical') { // Show just the filename, not a full path/URL const parts = source.value.replace(/\\/g, '/').split('/'); - return parts[parts.length - 1] ?? source.value; + return truncate(parts[parts.length - 1] ?? source.value, maxLen); } if (node.text) { const trimmed = node.text.trim().replace(/\s+/g, ' '); - if (trimmed.length <= maxLen) return trimmed; - const cut = trimmed.lastIndexOf(' ', maxLen); - return trimmed.slice(0, cut > 0 ? cut : maxLen) + '…'; + return truncate(trimmed, maxLen); } return node.id; From 7334b8ddc07f702aec91c4c67159a3fe40e51b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 04:20:21 +0000 Subject: [PATCH 5/9] Prefix chunk/expression labels with parent document title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveLabel now accepts an optional nodesById map. When provided and the node has a parent_id, the label is formatted as: 'Parent titl… · chunk text…' (parent truncated to 10 chars, chunk to the requested maxLen) Canvas frames (Semantic, Map, Timeline) pass nodesById so chunk-level nodes carry document context in their canvas labels. Callers that don't pass nodesById (SummaryFrame, TextFrame, etc.) are unaffected. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/MapFrame.tsx | 2 +- kb-viz/frontend/src/frames/SemanticFrame.tsx | 2 +- kb-viz/frontend/src/frames/TimelineFrame.tsx | 2 +- kb-viz/frontend/src/lib/derive-label.ts | 31 +++++++++++++++----- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 3c5ccb5..fb44871 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -172,7 +172,7 @@ export function MapFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -14], diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index b3e085e..002379b 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -213,7 +213,7 @@ export function SemanticFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -12], diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 99f6b70..2f8fc99 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -180,7 +180,7 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); }, getPosition: (d) => [d.x, d.y, 0], getPixelOffset: [0, -11], diff --git a/kb-viz/frontend/src/lib/derive-label.ts b/kb-viz/frontend/src/lib/derive-label.ts index ba287cb..31719e0 100644 --- a/kb-viz/frontend/src/lib/derive-label.ts +++ b/kb-viz/frontend/src/lib/derive-label.ts @@ -6,25 +6,42 @@ function truncate(str: string, maxLen: number): string { return str.slice(0, cut > 0 ? cut : maxLen) + '…'; } -export function deriveLabel(node: Node, maxLen = 80): string { +function rawLabel(node: Node, maxLen: number): string { const title = node.properties['title']; if (title?.kind === 'categorical') return truncate(title.value, maxLen); - // 'label' is used by document nodes (and other top-level types without a title) const label = node.properties['label']; if (label?.kind === 'categorical') return truncate(label.value, maxLen); const source = node.properties['source']; if (source?.kind === 'categorical') { - // Show just the filename, not a full path/URL const parts = source.value.replace(/\\/g, '/').split('/'); return truncate(parts[parts.length - 1] ?? source.value, maxLen); } - if (node.text) { - const trimmed = node.text.trim().replace(/\s+/g, ' '); - return truncate(trimmed, maxLen); - } + if (node.text) return truncate(node.text.trim().replace(/\s+/g, ' '), maxLen); return node.id; } + +/** + * Derive a display label for a node. + * + * When `nodesById` is provided and the node has a parent (i.e. it is a chunk + * or expression), the label is prefixed with a short parent title so the + * canvas label carries document context: "Parent title · chunk text…" + */ +export function deriveLabel( + node: Node, + maxLen = 80, + nodesById?: Map, +): string { + if (nodesById && node.parent_id) { + const parent = nodesById.get(node.parent_id); + if (parent) { + const parentPrefix = rawLabel(parent, 10); + return `${parentPrefix} · ${rawLabel(node, maxLen)}`; + } + } + return rawLabel(node, maxLen); +} From 35dcc082a63f94b4c0a3e52d8c44e8b2724a5aa2 Mon Sep 17 00:00:00 2001 From: Korede Aderele Date: Tue, 23 Jun 2026 21:52:22 -0700 Subject: [PATCH 6/9] Revert "Prefix chunk/expression labels with parent document title" This reverts commit 7334b8ddc07f702aec91c4c67159a3fe40e51b5e. --- kb-viz/frontend/src/frames/MapFrame.tsx | 2 +- kb-viz/frontend/src/frames/SemanticFrame.tsx | 2 +- kb-viz/frontend/src/frames/TimelineFrame.tsx | 2 +- kb-viz/frontend/src/lib/derive-label.ts | 31 +++++--------------- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index fb44871..3c5ccb5 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -172,7 +172,7 @@ export function MapFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -14], diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index 002379b..b3e085e 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -213,7 +213,7 @@ export function SemanticFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => d.position, getPixelOffset: [0, -12], diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 2f8fc99..99f6b70 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -180,7 +180,7 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8, nodesById) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); }, getPosition: (d) => [d.x, d.y, 0], getPixelOffset: [0, -11], diff --git a/kb-viz/frontend/src/lib/derive-label.ts b/kb-viz/frontend/src/lib/derive-label.ts index 31719e0..ba287cb 100644 --- a/kb-viz/frontend/src/lib/derive-label.ts +++ b/kb-viz/frontend/src/lib/derive-label.ts @@ -6,42 +6,25 @@ function truncate(str: string, maxLen: number): string { return str.slice(0, cut > 0 ? cut : maxLen) + '…'; } -function rawLabel(node: Node, maxLen: number): string { +export function deriveLabel(node: Node, maxLen = 80): string { const title = node.properties['title']; if (title?.kind === 'categorical') return truncate(title.value, maxLen); + // 'label' is used by document nodes (and other top-level types without a title) const label = node.properties['label']; if (label?.kind === 'categorical') return truncate(label.value, maxLen); const source = node.properties['source']; if (source?.kind === 'categorical') { + // Show just the filename, not a full path/URL const parts = source.value.replace(/\\/g, '/').split('/'); return truncate(parts[parts.length - 1] ?? source.value, maxLen); } - if (node.text) return truncate(node.text.trim().replace(/\s+/g, ' '), maxLen); + if (node.text) { + const trimmed = node.text.trim().replace(/\s+/g, ' '); + return truncate(trimmed, maxLen); + } return node.id; } - -/** - * Derive a display label for a node. - * - * When `nodesById` is provided and the node has a parent (i.e. it is a chunk - * or expression), the label is prefixed with a short parent title so the - * canvas label carries document context: "Parent title · chunk text…" - */ -export function deriveLabel( - node: Node, - maxLen = 80, - nodesById?: Map, -): string { - if (nodesById && node.parent_id) { - const parent = nodesById.get(node.parent_id); - if (parent) { - const parentPrefix = rawLabel(parent, 10); - return `${parentPrefix} · ${rawLabel(node, maxLen)}`; - } - } - return rawLabel(node, maxLen); -} From 05e591771d047530b5f2dffdfe4eef3cee33d668 Mon Sep 17 00:00:00 2001 From: Korede Aderele Date: Tue, 23 Jun 2026 21:56:12 -0700 Subject: [PATCH 7/9] tweaks: longer preview --- kb-viz/frontend/src/frames/MapFrame.tsx | 2 +- kb-viz/frontend/src/frames/SemanticFrame.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 3c5ccb5..6b3eaa5 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -172,7 +172,7 @@ export function MapFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 30 : 20) : d.id.slice(0, 15); }, getPosition: (d) => d.position, getPixelOffset: [0, -14], diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index b3e085e..0af8fe1 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -213,7 +213,7 @@ export function SemanticFrame(_props: FrameProps) { data: points, getText: (d) => { const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 14 : 8) : d.id.slice(0, 8); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 30 : 20) : d.id.slice(0, 15); }, getPosition: (d) => d.position, getPixelOffset: [0, -12], From 091696a27a457e586e208bcfbadf967a55afd5a5 Mon Sep 17 00:00:00 2001 From: Korede Aderele Date: Sat, 27 Jun 2026 13:19:02 -0700 Subject: [PATCH 8/9] Add label declutter, pick disambiguation menu, and adaptive timeline ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid-based spatial thinning prevents label overlap across Map, Semantic, and Timeline frames. PickMenu lets users disambiguate clicks on overlapping nodes. Timeline ticks now adapt granularity (years→months→days) to the visible viewport range. Co-Authored-By: Claude Opus 4.6 (1M context) --- kb-viz/frontend/src/components/PickMenu.tsx | 95 ++++++++++++ kb-viz/frontend/src/frames/MapFrame.tsx | 105 +++++++++----- kb-viz/frontend/src/frames/SemanticFrame.tsx | 27 ++-- kb-viz/frontend/src/frames/TimelineFrame.tsx | 135 +++++++++++++++--- .../frontend/src/lib/pick-visible-labels.ts | 38 +++++ 5 files changed, 334 insertions(+), 66 deletions(-) create mode 100644 kb-viz/frontend/src/components/PickMenu.tsx create mode 100644 kb-viz/frontend/src/lib/pick-visible-labels.ts diff --git a/kb-viz/frontend/src/components/PickMenu.tsx b/kb-viz/frontend/src/components/PickMenu.tsx new file mode 100644 index 0000000..42f98cb --- /dev/null +++ b/kb-viz/frontend/src/components/PickMenu.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; +import { useStore } from '../lib/use-store'; +import { dataStore } from '../state/data-store'; +import { deriveLabel } from '../lib/derive-label'; + +export interface PickMenuState { + x: number; + y: number; + ids: string[]; +} + +interface PickMenuProps { + menu: PickMenuState; + onPick: (id: string, shift: boolean) => void; + onClose: () => void; +} + +export function PickMenu({ menu, onPick, onClose }: PickMenuProps) { + const nodesById = useStore(dataStore, (s) => s.nodes); + const ref = useRef(null); + + useEffect(() => { + const onMouse = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', onMouse); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onMouse); + document.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + return ( +
+
+ {menu.ids.length} overlapping nodes +
+ {menu.ids.map((id) => { + const node = nodesById.get(id); + const label = node ? deriveLabel(node, 50) : id.slice(0, 20); + return ( + + ); + })} +
+ ); +} diff --git a/kb-viz/frontend/src/frames/MapFrame.tsx b/kb-viz/frontend/src/frames/MapFrame.tsx index 6b3eaa5..e196b56 100644 --- a/kb-viz/frontend/src/frames/MapFrame.tsx +++ b/kb-viz/frontend/src/frames/MapFrame.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback, useRef } from 'react'; import DeckGL from '@deck.gl/react'; import { ScatterplotLayer, LineLayer, ArcLayer, TextLayer } from '@deck.gl/layers'; import { HeatmapLayer } from '@deck.gl/aggregation-layers'; @@ -11,6 +11,8 @@ import { viewStore } from '../state/view-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { projectMap } from '../projection/projectors/map'; import { deriveLabel } from '../lib/derive-label'; +import { pickVisibleLabels } from '../lib/pick-visible-labels'; +import { PickMenu, type PickMenuState } from '../components/PickMenu'; import type { FrameProps } from './registry'; interface Point { id: string; position: [number, number]; } @@ -51,6 +53,15 @@ export function MapFrame(_props: FrameProps) { const [zoom, setZoom] = useState(3.5); const LABEL_ZOOM = 6; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deckRef = useRef(null); + const [pickMenu, setPickMenu] = useState(null); + const handlePick = useCallback((id: string, shift: boolean) => { + if (shift) selectionStore.getState().toggle(id); + else selectionStore.getState().selectOnly(id); + setPickMenu(null); + }, []); + const encode = useMemo( () => makeColorEncoder(nodesById, nodeTypes, colorBy), [nodesById, nodeTypes, colorBy], @@ -92,6 +103,13 @@ export function MapFrame(_props: FrameProps) { return arcs; }, [selectedPoints]); + // Grid-thinned set of IDs whose ambient labels should be visible + const visibleLabelIds = useMemo(() => { + if (zoom < LABEL_ZOOM) return new Set(); + const divisions = Math.max(8, Math.floor(8 + (zoom - LABEL_ZOOM) * 8)); + return pickVisibleLabels(points, (p) => p.position, divisions); + }, [points, zoom]); + // Convex hull segments (only when ≥ 3 selected geo points) const hullSegments = useMemo(() => { if (selectedPoints.length < 3) return []; @@ -124,8 +142,9 @@ export function MapFrame(_props: FrameProps) { setZoom((vs as { zoom?: number }).zoom ?? 3)} + onViewStateChange={({ viewState: vs }) => { setZoom((vs as { zoom?: number }).zoom ?? 3); setPickMenu(null); }} controller layers={[ ...(showHeatmap ? [ @@ -167,40 +186,6 @@ export function MapFrame(_props: FrameProps) { widthUnits: 'pixels', }), ] : []), - new TextLayer({ - id: 'map-labels', - data: points, - getText: (d) => { - const n = nodesById.get(d.id); - return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 30 : 20) : d.id.slice(0, 15); - }, - getPosition: (d) => d.position, - getPixelOffset: [0, -14], - getSize: 11, - getColor: (d) => { - const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; - if (selected.has(d.id)) return [240, 80, 40, show ? 230 : 0]; - if (d.id === hovered) return [251, 191, 36, show ? 230 : 0]; - return [220, 225, 220, show ? 180 : 0]; - }, - getTextAnchor: 'middle', - getAlignmentBaseline: 'bottom', - fontFamily: 'system-ui, sans-serif', - background: true, - getBorderColor: [0, 0, 0, 0], - backgroundPadding: [4, 1, 4, 1], - getBackgroundColor: (d) => { - const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; - if (selected.has(d.id)) return [40, 10, 5, show ? 190 : 0]; - return [10, 10, 10, show ? 170 : 0]; - }, - transitions: { getColor: 250, getBackgroundColor: 250 }, - updateTriggers: { - getColor: [selected, hovered, zoom], - getBackgroundColor: [selected, hovered, zoom], - getText: [nodesById, selected, hovered], - }, - }), new ScatterplotLayer({ id: 'map-points', data: points, @@ -219,8 +204,20 @@ export function MapFrame(_props: FrameProps) { lineWidthMinPixels: 1, pickable: true, onClick: (info, event) => { + setPickMenu(null); const id = (info.object as Point | undefined)?.id; if (!id) return; + // Check for overlapping objects under the click + const picks = deckRef.current?.pickMultipleObjects?.({ + x: info.x, y: info.y, layerIds: ['map-points'], + }) ?? []; + const ids = [...new Set( + picks.map((p: { object?: Point }) => p.object?.id).filter(Boolean) as string[], + )]; + if (ids.length > 1) { + setPickMenu({ x: info.x, y: info.y, ids }); + return; + } const shift = (event?.srcEvent as MouseEvent | undefined)?.shiftKey ?? false; if (shift) selectionStore.getState().toggle(id); else selectionStore.getState().selectOnly(id); @@ -235,6 +232,41 @@ export function MapFrame(_props: FrameProps) { }, transitions: { getFillColor: 120 }, }), + new TextLayer({ + id: 'map-labels', + data: points, + getText: (d) => { + const n = nodesById.get(d.id); + return n ? deriveLabel(n, selected.has(d.id) || d.id === hovered ? 30 : 20) : d.id.slice(0, 15); + }, + getPosition: (d) => d.position, + getPixelOffset: [0, -14], + getSize: 11, + getColor: (d) => { + if (selected.has(d.id)) return [240, 80, 40, 230]; + if (d.id === hovered) return [251, 191, 36, 230]; + if (visibleLabelIds.has(d.id)) return [220, 225, 220, 160]; + return [220, 225, 220, 0]; + }, + getTextAnchor: 'middle', + getAlignmentBaseline: 'bottom', + fontFamily: 'system-ui, sans-serif', + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [4, 1, 4, 1], + getBackgroundColor: (d) => { + if (selected.has(d.id)) return [40, 10, 5, 190]; + if (d.id === hovered) return [10, 10, 10, 170]; + if (visibleLabelIds.has(d.id)) return [10, 10, 10, 130]; + return [10, 10, 10, 0]; + }, + transitions: { getColor: 250, getBackgroundColor: 250 }, + updateTriggers: { + getColor: [selected, hovered, visibleLabelIds], + getBackgroundColor: [selected, hovered, visibleLabelIds], + getText: [nodesById, selected, hovered], + }, + }), ]} > + {pickMenu && setPickMenu(null)} />} ); } diff --git a/kb-viz/frontend/src/frames/SemanticFrame.tsx b/kb-viz/frontend/src/frames/SemanticFrame.tsx index 0af8fe1..0189e54 100644 --- a/kb-viz/frontend/src/frames/SemanticFrame.tsx +++ b/kb-viz/frontend/src/frames/SemanticFrame.tsx @@ -10,6 +10,7 @@ import { viewStore } from '../state/view-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { useBoxSelect, BoxSelectOverlay } from '../lib/use-box-select'; import { deriveLabel } from '../lib/derive-label'; +import { pickVisibleLabels } from '../lib/pick-visible-labels'; import type { SemanticFrameConfig } from '../state/view-store'; import type { UmapRequest, UmapResponse, UmapError } from '../workers/umap.worker'; import type { FrameProps } from './registry'; @@ -53,6 +54,13 @@ export function SemanticFrame(_props: FrameProps) { [nodesById, nodeTypes, colorBy], ); + // Grid-thinned set of IDs whose ambient labels should be visible + const visibleLabelIds = useMemo(() => { + if (zoom < LABEL_ZOOM) return new Set(); + const divisions = Math.max(8, Math.floor(8 + (zoom - LABEL_ZOOM) * 8)); + return pickVisibleLabels(points, (p) => [p.position[0], p.position[1]], divisions); + }, [points, zoom]); + const embInput = useMemo(() => { const ids: string[] = []; const embeddings: number[][] = []; @@ -219,10 +227,10 @@ export function SemanticFrame(_props: FrameProps) { getPixelOffset: [0, -12], getSize: 11, getColor: (d) => { - const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; - if (selected.has(d.id)) return [240, 80, 40, show ? 220 : 0]; - if (d.id === hovered) return [251, 191, 36, show ? 220 : 0]; - return [200, 205, 200, show ? 140 : 0]; + if (selected.has(d.id)) return [240, 80, 40, 220]; + if (d.id === hovered) return [251, 191, 36, 220]; + if (visibleLabelIds.has(d.id)) return [200, 205, 200, 140]; + return [200, 205, 200, 0]; }, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', @@ -231,14 +239,15 @@ export function SemanticFrame(_props: FrameProps) { getBorderColor: [0, 0, 0, 0], backgroundPadding: [3, 1, 3, 1], getBackgroundColor: (d) => { - const show = selected.has(d.id) || d.id === hovered || zoom >= LABEL_ZOOM; - if (selected.has(d.id)) return [30, 10, 8, show ? 170 : 0]; - return [14, 22, 12, show ? 140 : 0]; + if (selected.has(d.id)) return [30, 10, 8, 170]; + if (d.id === hovered) return [14, 22, 12, 140]; + if (visibleLabelIds.has(d.id)) return [14, 22, 12, 120]; + return [14, 22, 12, 0]; }, transitions: { getColor: 250, getBackgroundColor: 250 }, updateTriggers: { - getColor: [selected, hovered, zoom], - getBackgroundColor: [selected, hovered, zoom], + getColor: [selected, hovered, visibleLabelIds], + getBackgroundColor: [selected, hovered, visibleLabelIds], getText: [nodesById, selected, hovered], }, }), diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 99f6b70..21e4912 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -11,6 +11,8 @@ import { filterStore } from '../state/filter-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { projectTimeline } from '../projection/projectors/timeline'; import { deriveLabel } from '../lib/derive-label'; +import { pickVisibleLabels } from '../lib/pick-visible-labels'; +import { PickMenu, type PickMenuState } from '../components/PickMenu'; import type { FrameProps } from './registry'; interface Point { id: string; x: number; y: number; } @@ -21,15 +23,55 @@ function hashId(id: string): number { return 0.15 + (((h >>> 0) % 1000) / 1000) * 0.7; } -function yearTicks(minMs: number, maxMs: number): { x: number; label: string }[] { - const minYear = new Date(minMs).getUTCFullYear(); - const maxYear = new Date(maxMs).getUTCFullYear(); - const span = maxYear - minYear; - const step = span <= 10 ? 1 : span <= 50 ? 5 : span <= 200 ? 20 : 50; - const ticks = []; - for (let y = Math.ceil(minYear / step) * step; y <= maxYear; y += step) { - ticks.push({ x: Date.UTC(y, 0, 1), label: String(y) }); +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +/** Generate tick marks adaptive to the visible time span. */ +function adaptiveTicks(visMinMs: number, visMaxMs: number): { x: number; label: string }[] { + const spanMs = visMaxMs - visMinMs; + const spanYears = spanMs / (365.25 * 24 * 3600_000); + const spanDays = spanMs / (24 * 3600_000); + const ticks: { x: number; label: string }[] = []; + + if (spanYears > 2) { + // Year-level ticks + const step = spanYears > 200 ? 50 : spanYears > 50 ? 20 : spanYears > 10 ? 5 : 1; + const minYear = new Date(visMinMs).getUTCFullYear(); + const maxYear = new Date(visMaxMs).getUTCFullYear(); + for (let y = Math.ceil(minYear / step) * step; y <= maxYear; y += step) { + ticks.push({ x: Date.UTC(y, 0, 1), label: String(y) }); + } + } else if (spanDays > 60) { + // Month / quarter ticks + const step = spanDays > 180 ? 3 : 1; + const start = new Date(visMinMs); + let y = start.getUTCFullYear(); + let m = Math.floor(start.getUTCMonth() / step) * step; + for (;;) { + const ms = Date.UTC(y, m, 1); + if (ms > visMaxMs) break; + if (ms >= visMinMs) { + ticks.push({ x: ms, label: m === 0 ? String(y) : `${MONTHS[m]} ${y}` }); + } + m += step; + if (m >= 12) { m = 0; y++; } + } + } else { + // Day / week ticks + const step = spanDays > 14 ? 7 : 1; + const DAY = 24 * 3600_000; + let t = Math.ceil(visMinMs / (DAY * step)) * DAY * step; + while (t <= visMaxMs) { + const d = new Date(t); + ticks.push({ + x: t, + label: step >= 7 + ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` + : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`, + }); + t += DAY * step; + } } + return ticks; } @@ -47,8 +89,17 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { const [brush, setBrush] = useState<[number, number] | null>(null); const brushShiftRef = useRef(false); const [zoom, setZoom] = useState(null); + const [viewCenter, setViewCenter] = useState(null); // Store the initial zoom so we can use it as a relative threshold const initialZoomRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deckRef = useRef(null); + const [pickMenu, setPickMenu] = useState(null); + const handlePick = useCallback((id: string, shift: boolean) => { + if (shift) selectionStore.getState().toggle(id); + else selectionStore.getState().selectOnly(id); + setPickMenu(null); + }, []); const encode = useMemo( () => makeColorEncoder(nodesById, nodeTypes, colorBy), @@ -69,14 +120,30 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { return { minX: Math.min(...xs), maxX: Math.max(...xs) }; }, [points]); - const ticks = useMemo(() => yearTicks(minX, maxX), [minX, maxX]); - const padding = (maxX - minX) * 0.05 || 1e9; const viewState = useMemo(() => ({ target: [(minX + maxX) / 2, 0.5, 0] as [number, number, number], zoom: Math.log2((_w || 800) / ((maxX - minX + padding * 2) || 1)) - 1, }), [minX, maxX, padding, _w]); + // Compute the visible x-range from viewport state for adaptive ticks + const ticks = useMemo(() => { + const w = _w || 800; + const z = zoom ?? viewState.zoom; + const cx = viewCenter ?? (minX + maxX) / 2; + const halfWidth = (w / 2) / Math.pow(2, z); + return adaptiveTicks(cx - halfWidth, cx + halfWidth); + }, [zoom, viewCenter, minX, maxX, _w, viewState.zoom]); + + // Grid-thinned set of IDs whose ambient labels should be visible + const visibleLabelIds = useMemo(() => { + const baseZoom = initialZoomRef.current; + if (zoom === null || baseZoom === null || zoom < baseZoom + 3) return new Set(); + const zoomDelta = zoom - (baseZoom + 3); + const divisions = Math.max(8, Math.floor(8 + zoomDelta * 8)); + return pickVisibleLabels(points, (p) => [p.x, p.y], divisions); + }, [points, zoom]); + const getColor = useCallback((d: Point): [number, number, number, number] => { if (selected.has(d.id)) return [240, 80, 40, 255]; if (hovered === d.id) return [251, 191, 36, 255]; @@ -117,12 +184,17 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { return (
{ const z = (vs as { zoom?: number }).zoom ?? 0; if (initialZoomRef.current === null) initialZoomRef.current = z; setZoom(z); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const target = (vs as any).target; + if (target) setViewCenter(target[0]); + setPickMenu(null); }} controller layers={[ @@ -138,6 +210,16 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { getWidth: 1, widthUnits: 'pixels', }), + // Tick marks + new LineLayer({ + id: 'tick-lines', + data: ticks, + getSourcePosition: (d: { x: number }) => [d.x, 0.05, 0], + getTargetPosition: (d: { x: number }) => [d.x, 0.07, 0], + getColor: [64, 68, 96, 160], + getWidth: 1, + widthUnits: 'pixels', + }), // Tick labels new TextLayer({ id: 'ticks', @@ -163,8 +245,19 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { lineWidthMinPixels: 1, pickable: true, onClick: (info, event) => { + setPickMenu(null); const id = (info.object as Point | undefined)?.id; if (!id) return; + const picks = deckRef.current?.pickMultipleObjects?.({ + x: info.x, y: info.y, layerIds: ['timeline-points'], + }) ?? []; + const ids = [...new Set( + picks.map((p: { object?: Point }) => p.object?.id).filter(Boolean) as string[], + )]; + if (ids.length > 1) { + setPickMenu({ x: info.x, y: info.y, ids }); + return; + } const shift = (event?.srcEvent as MouseEvent | undefined)?.shiftKey ?? false; if (shift) selectionStore.getState().toggle(id); else selectionStore.getState().selectOnly(id); @@ -186,11 +279,10 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { getPixelOffset: [0, -11], getSize: 10, getColor: (d) => { - const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; - const show = selected.has(d.id) || d.id === hovered || zoomed; - if (selected.has(d.id)) return [240, 80, 40, show ? 220 : 0]; - if (d.id === hovered) return [251, 191, 36, show ? 220 : 0]; - return [190, 195, 190, show ? 130 : 0]; + if (selected.has(d.id)) return [240, 80, 40, 220]; + if (d.id === hovered) return [251, 191, 36, 220]; + if (visibleLabelIds.has(d.id)) return [190, 195, 190, 130]; + return [190, 195, 190, 0]; }, getTextAnchor: 'middle', getAlignmentBaseline: 'bottom', @@ -199,15 +291,15 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { getBorderColor: [0, 0, 0, 0], backgroundPadding: [3, 1, 3, 1], getBackgroundColor: (d) => { - const zoomed = zoom !== null && initialZoomRef.current !== null && zoom >= initialZoomRef.current + 3; - const show = selected.has(d.id) || d.id === hovered || zoomed; - if (selected.has(d.id)) return [30, 8, 5, show ? 170 : 0]; - return [14, 22, 12, show ? 140 : 0]; + if (selected.has(d.id)) return [30, 8, 5, 170]; + if (d.id === hovered) return [14, 22, 12, 140]; + if (visibleLabelIds.has(d.id)) return [14, 22, 12, 120]; + return [14, 22, 12, 0]; }, transitions: { getColor: 250, getBackgroundColor: 250 }, updateTriggers: { - getColor: [selected, hovered, zoom], - getBackgroundColor: [selected, hovered, zoom], + getColor: [selected, hovered, visibleLabelIds], + getBackgroundColor: [selected, hovered, visibleLabelIds], getText: [nodesById, selected, hovered], }, }), @@ -234,6 +326,7 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { setBrush(null); }} /> + {pickMenu && setPickMenu(null)} />}
); } diff --git a/kb-viz/frontend/src/lib/pick-visible-labels.ts b/kb-viz/frontend/src/lib/pick-visible-labels.ts new file mode 100644 index 0000000..c45afa2 --- /dev/null +++ b/kb-viz/frontend/src/lib/pick-visible-labels.ts @@ -0,0 +1,38 @@ +/** + * Grid-based spatial thinning: returns a Set of item IDs that should show labels. + * Divides the data extent into a grid of `divisions × divisions` cells and keeps + * at most one label per cell. As `divisions` increases (e.g. with zoom), more labels + * are revealed — giving progressive disclosure without overlap. + */ +export function pickVisibleLabels( + items: T[], + getXY: (item: T) => [number, number], + divisions: number, +): Set { + if (items.length === 0) return new Set(); + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const item of items) { + const [x, y] = getXY(item); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + const cellW = (maxX - minX) / divisions || 1; + const cellH = (maxY - minY) / divisions || 1; + const occupied = new Set(); + const result = new Set(); + + for (const item of items) { + const [x, y] = getXY(item); + const key = `${Math.floor((x - minX) / cellW)},${Math.floor((y - minY) / cellH)}`; + if (!occupied.has(key)) { + occupied.add(key); + result.add(item.id); + } + } + + return result; +} From c0e33e8dfe5832abd4731f7e25df1b696d4b51de Mon Sep 17 00:00:00 2001 From: Korede Aderele Date: Mon, 6 Jul 2026 16:03:21 -0700 Subject: [PATCH 9/9] Adaptive timeline axis ticks with viewport-aware granularity Replaces static yearTicks with a step table (5000y down to 1 day) that picks the finest interval fitting a per-viewport-width tick budget. Axis markers now thin out at wide zoom and fill in as you zoom into narrower time ranges. Co-Authored-By: Claude Opus 4.6 (1M context) --- kb-viz/frontend/src/frames/TimelineFrame.tsx | 122 +++++++++++-------- 1 file changed, 70 insertions(+), 52 deletions(-) diff --git a/kb-viz/frontend/src/frames/TimelineFrame.tsx b/kb-viz/frontend/src/frames/TimelineFrame.tsx index 21e4912..f527a85 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -11,7 +11,6 @@ import { filterStore } from '../state/filter-store'; import { makeColorEncoder } from '../lib/color-encoder'; import { projectTimeline } from '../projection/projectors/timeline'; import { deriveLabel } from '../lib/derive-label'; -import { pickVisibleLabels } from '../lib/pick-visible-labels'; import { PickMenu, type PickMenuState } from '../components/PickMenu'; import type { FrameProps } from './registry'; @@ -25,53 +24,52 @@ function hashId(id: string): number { const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; -/** Generate tick marks adaptive to the visible time span. */ -function adaptiveTicks(visMinMs: number, visMaxMs: number): { x: number; label: string }[] { - const spanMs = visMaxMs - visMinMs; - const spanYears = spanMs / (365.25 * 24 * 3600_000); - const spanDays = spanMs / (24 * 3600_000); - const ticks: { x: number; label: string }[] = []; +// Candidate step sizes ordered coarse → fine. Each has a human-friendly +// millisecond interval and a label formatter. +const YEAR = 365.25 * 24 * 3600_000; +const DAY = 24 * 3600_000; +const STEPS: { ms: number; label: (d: Date) => string; align: (t: number) => number }[] = [ + { ms: 5000 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 5000) * 5000, 0, 1) }, + { ms: 2000 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 2000) * 2000, 0, 1) }, + { ms: 1000 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 1000) * 1000, 0, 1) }, + { ms: 500 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 500) * 500, 0, 1) }, + { ms: 200 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 200) * 200, 0, 1) }, + { ms: 100 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 100) * 100, 0, 1) }, + { ms: 50 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 50) * 50, 0, 1) }, + { ms: 20 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 20) * 20, 0, 1) }, + { ms: 10 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 10) * 10, 0, 1) }, + { ms: 5 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 5) * 5, 0, 1) }, + { ms: 2 * YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(Math.ceil(new Date(t).getUTCFullYear() / 2) * 2, 0, 1) }, + { ms: YEAR, label: (d) => String(d.getUTCFullYear()), align: (t) => Date.UTC(new Date(t).getUTCFullYear() + 1, 0, 1) }, + { ms: 3 * 30 * DAY, label: (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`, align: (t) => { const d = new Date(t); const q = Math.ceil((d.getUTCMonth() + 1) / 3) * 3; return q >= 12 ? Date.UTC(d.getUTCFullYear() + 1, 0, 1) : Date.UTC(d.getUTCFullYear(), q, 1); } }, + { ms: 30 * DAY, label: (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`, align: (t) => { const d = new Date(t); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); } }, + { ms: 7 * DAY, label: (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`, align: (t) => Math.ceil(t / (7 * DAY)) * 7 * DAY }, + { ms: DAY, label: (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`, align: (t) => Math.ceil(t / DAY) * DAY }, +]; - if (spanYears > 2) { - // Year-level ticks - const step = spanYears > 200 ? 50 : spanYears > 50 ? 20 : spanYears > 10 ? 5 : 1; - const minYear = new Date(visMinMs).getUTCFullYear(); - const maxYear = new Date(visMaxMs).getUTCFullYear(); - for (let y = Math.ceil(minYear / step) * step; y <= maxYear; y += step) { - ticks.push({ x: Date.UTC(y, 0, 1), label: String(y) }); - } - } else if (spanDays > 60) { - // Month / quarter ticks - const step = spanDays > 180 ? 3 : 1; - const start = new Date(visMinMs); - let y = start.getUTCFullYear(); - let m = Math.floor(start.getUTCMonth() / step) * step; - for (;;) { - const ms = Date.UTC(y, m, 1); - if (ms > visMaxMs) break; - if (ms >= visMinMs) { - ticks.push({ x: ms, label: m === 0 ? String(y) : `${MONTHS[m]} ${y}` }); - } - m += step; - if (m >= 12) { m = 0; y++; } - } - } else { - // Day / week ticks - const step = spanDays > 14 ? 7 : 1; - const DAY = 24 * 3600_000; - let t = Math.ceil(visMinMs / (DAY * step)) * DAY * step; - while (t <= visMaxMs) { - const d = new Date(t); - ticks.push({ - x: t, - label: step >= 7 - ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` - : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`, - }); - t += DAY * step; - } +/** + * Generate axis ticks for the visible range, targeting ≤ maxTicks labels. + * Picks the coarsest step that fits within the budget. + */ +function adaptiveTicks(visMinMs: number, visMaxMs: number, maxTicks: number): { x: number; label: string }[] { + const span = visMaxMs - visMinMs; + if (span <= 0) return []; + + // Find the finest step that stays within budget + let step = STEPS[0]; + for (const s of STEPS) { + if (span / s.ms <= maxTicks) { step = s; } + else break; } + const ticks: { x: number; label: string }[] = []; + let t = step.align(visMinMs); + while (t <= visMaxMs) { + if (t >= visMinMs) { + ticks.push({ x: t, label: step.label(new Date(t)) }); + } + t += step.ms; + } return ticks; } @@ -126,23 +124,43 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { zoom: Math.log2((_w || 800) / ((maxX - minX + padding * 2) || 1)) - 1, }), [minX, maxX, padding, _w]); - // Compute the visible x-range from viewport state for adaptive ticks + // Compute the visible x-range from viewport state for adaptive ticks. + // Budget: ~1 tick per 100px so labels never overlap. const ticks = useMemo(() => { const w = _w || 800; const z = zoom ?? viewState.zoom; const cx = viewCenter ?? (minX + maxX) / 2; const halfWidth = (w / 2) / Math.pow(2, z); - return adaptiveTicks(cx - halfWidth, cx + halfWidth); + const maxTicks = Math.max(4, Math.floor(w / 100)); + return adaptiveTicks(cx - halfWidth, cx + halfWidth, maxTicks); }, [zoom, viewCenter, minX, maxX, _w, viewState.zoom]); - // Grid-thinned set of IDs whose ambient labels should be visible + // Viewport-aware 1D thinning: keep ≤ N labels across the visible x-range. + // Timeline label overlap is horizontal, so we bucket by x only. const visibleLabelIds = useMemo(() => { const baseZoom = initialZoomRef.current; if (zoom === null || baseZoom === null || zoom < baseZoom + 3) return new Set(); - const zoomDelta = zoom - (baseZoom + 3); - const divisions = Math.max(8, Math.floor(8 + zoomDelta * 8)); - return pickVisibleLabels(points, (p) => [p.x, p.y], divisions); - }, [points, zoom]); + + const w = _w || 800; + const cx = viewCenter ?? (minX + maxX) / 2; + const halfWidth = (w / 2) / Math.pow(2, zoom); + const visMinX = cx - halfWidth; + const visMaxX = cx + halfWidth; + const bucketWidth = (visMaxX - visMinX) / 12; + if (bucketWidth <= 0) return new Set(); + + const occupied = new Set(); + const result = new Set(); + for (const p of points) { + if (p.x < visMinX || p.x > visMaxX) continue; + const bucket = Math.floor((p.x - visMinX) / bucketWidth); + if (!occupied.has(bucket)) { + occupied.add(bucket); + result.add(p.id); + } + } + return result; + }, [points, zoom, viewCenter, minX, maxX, _w]); const getColor = useCallback((d: Point): [number, number, number, number] => { if (selected.has(d.id)) return [240, 80, 40, 255];