Skip to content
Merged
95 changes: 95 additions & 0 deletions kb-viz/frontend/src/components/PickMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
style={{
position: 'absolute',
left: menu.x,
top: menu.y,
zIndex: 20,
background: 'var(--surface-elevated, #1a1a2e)',
border: '1px solid var(--border, #333)',
borderRadius: 6,
padding: '4px 0',
minWidth: 160,
maxWidth: 300,
maxHeight: 220,
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
fontSize: 12,
}}
>
<div style={{
padding: '2px 8px 4px',
color: 'var(--text-dim, #888)',
fontSize: 10,
borderBottom: '1px solid var(--border, #333)',
}}>
{menu.ids.length} overlapping nodes
</div>
{menu.ids.map((id) => {
const node = nodesById.get(id);
const label = node ? deriveLabel(node, 50) : id.slice(0, 20);
return (
<button
key={id}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '4px 8px',
background: 'none',
border: 'none',
color: 'var(--text, #e0e0e0)',
cursor: 'pointer',
fontSize: 12,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onClick={(e) => onPick(id, e.shiftKey)}
onMouseOver={(e) => (e.currentTarget.style.background = 'var(--surface-hover, #2a2a4e)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'none')}
>
{label}
</button>
);
})}
</div>
);
}
75 changes: 73 additions & 2 deletions kb-viz/frontend/src/frames/MapFrame.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]; }
Expand Down Expand Up @@ -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<any>(null);
const [pickMenu, setPickMenu] = useState<PickMenuState | null>(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),
Expand Down Expand Up @@ -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<string>();
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 [];
Expand Down Expand Up @@ -121,7 +142,9 @@ export function MapFrame(_props: FrameProps) {
</div>

<DeckGL
ref={deckRef}
initialViewState={initialViewState}
onViewStateChange={({ viewState: vs }) => { setZoom((vs as { zoom?: number }).zoom ?? 3); setPickMenu(null); }}
controller
layers={[
...(showHeatmap ? [
Expand Down Expand Up @@ -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);
Expand All @@ -197,13 +232,49 @@ export function MapFrame(_props: FrameProps) {
},
transitions: { getFillColor: 120 },
}),
new TextLayer<Point>({
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],
},
}),
]}
>
<MapGL
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
attributionControl={false}
/>
</DeckGL>
{pickMenu && <PickMenu menu={pickMenu} onPick={handlePick} onClose={() => setPickMenu(null)} />}
</div>
);
}
50 changes: 49 additions & 1 deletion kb-viz/frontend/src/frames/SemanticFrame.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -35,6 +37,9 @@ export function SemanticFrame(_props: FrameProps) {
const [points, setPoints] = useState<Point[]>([]);
const [computing, setComputing] = useState(false);
const workerRef = useRef<Worker | null>(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<Point>({
extractId: (obj) => obj?.id,
Expand All @@ -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<string>();
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[][] = [];
Expand Down Expand Up @@ -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<Point>({
Expand Down Expand Up @@ -203,6 +216,41 @@ export function SemanticFrame(_props: FrameProps) {
},
transitions: { getFillColor: 120 },
}),
new TextLayer<Point>({
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],
},
}),
]}
/>
</div>
Expand Down
Loading
Loading