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 07c27cb..e196b56 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 { useMemo, useState, useCallback, useRef } 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,9 @@ 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 { 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]; } @@ -47,6 +50,17 @@ 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; + + // 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), @@ -89,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 []; @@ -121,7 +142,9 @@ export function MapFrame(_props: FrameProps) { { setZoom((vs as { zoom?: number }).zoom ?? 3); setPickMenu(null); }} controller layers={[ ...(showHeatmap ? [ @@ -181,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); @@ -197,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 9c80922..0189e54 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,8 @@ 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 { 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'; @@ -35,6 +37,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, @@ -49,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[][] = []; @@ -167,6 +179,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 +216,41 @@ export function SemanticFrame(_props: FrameProps) { }, transitions: { getFillColor: 120 }, }), + new TextLayer({ + id: 'semantic-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, -12], + getSize: 11, + getColor: (d) => { + 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', + fontFamily: 'system-ui, sans-serif', + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [3, 1, 3, 1], + getBackgroundColor: (d) => { + 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, 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 9485983..f527a85 100644 --- a/kb-viz/frontend/src/frames/TimelineFrame.tsx +++ b/kb-viz/frontend/src/frames/TimelineFrame.tsx @@ -10,6 +10,8 @@ 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 { PickMenu, type PickMenuState } from '../components/PickMenu'; import type { FrameProps } from './registry'; interface Point { id: string; x: number; y: number; } @@ -20,14 +22,53 @@ 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']; + +// 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 }, +]; + +/** + * 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; } @@ -45,6 +86,18 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { // 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 [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), @@ -65,14 +118,50 @@ 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. + // 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); + const maxTicks = Math.max(4, Math.floor(w / 100)); + return adaptiveTicks(cx - halfWidth, cx + halfWidth, maxTicks); + }, [zoom, viewCenter, minX, maxX, _w, viewState.zoom]); + + // 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 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]; if (hovered === d.id) return [251, 191, 36, 255]; @@ -113,8 +202,18 @@ 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={[ ...rangeBand, @@ -129,6 +228,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', @@ -154,8 +263,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); @@ -166,6 +286,41 @@ 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: 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); + }, + getPosition: (d) => [d.x, d.y, 0], + getPixelOffset: [0, -11], + getSize: 10, + getColor: (d) => { + 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', + fontFamily: 'ui-monospace, monospace', + background: true, + getBorderColor: [0, 0, 0, 0], + backgroundPadding: [3, 1, 3, 1], + getBackgroundColor: (d) => { + 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, visibleLabelIds], + getBackgroundColor: [selected, hovered, visibleLabelIds], + getText: [nodesById, selected, hovered], + }, + }), ]} onDragStart={(info, event) => { if (!info.coordinate) return; @@ -189,6 +344,7 @@ export function TimelineFrame({ width: _w, height: _h }: FrameProps) { setBrush(null); }} /> + {pickMenu && setPickMenu(null)} />}
); } 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; 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; +}