Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
903 changes: 75 additions & 828 deletions app/src/pages/MapPage.tsx

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions app/src/sections/map/MapForceGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { type MutableRefObject, type RefObject } from 'react';

import ForceGraph2D from 'react-force-graph-2d';

export interface ForceGraphInstance {
refresh?: () => void;
centerAt?: (x: number, y: number, z: number) => void;
zoom?: ((z: number, ms: number) => void) & (() => number);
graph2ScreenCoords?: (x: number, y: number) => { x: number; y: number };
d3Force: (force: string, args?: unknown) => unknown;
d3ReheatSimulation?: () => void;
__forcesWired?: boolean;
}

import {
colorFor,
type MapForceConfig,
type MapGraphData,
NODE_SIZE,
} from 'src/hooks/useForceGraphSimulation';
import {
ensureNodeTier,
fitToBox,
type MapLink,
type MapNode,
nodeAspectRatio,
pickBestLoadedTier,
pickTier,
type ResolutionTier,
} from 'src/pages/MapPage.helpers';
import { linkEndId, strokeFor } from 'src/sections/map/MapPage.utils';
import { colors } from 'src/theme';

type WithCoords = MapNode & { x?: number; y?: number };

interface MapForceGraphProps {
fgRef: RefObject<ForceGraphInstance | null>;
graphData: MapGraphData;
size: { w: number; h: number };
forceConfig: MapForceConfig;
hoverId: string | null;
hoverType: string | null;
neighbors: Map<string, Set<string>>;
nodeById: Map<string, MapNode>;
isDark: boolean;
onNodeClick: (node: MapNode) => void;
setPinnedId: (id: string | null) => void;
setHoverId: (id: string | null) => void;
hoverTimerRef: MutableRefObject<number | null>;
markSettled: () => void;
handleEngineTick: () => void;
}

export function MapForceGraph({
fgRef,
graphData,
size,
forceConfig,
hoverId,
hoverType,
neighbors,
nodeById,
isDark,
onNodeClick,
setPinnedId,
setHoverId,
hoverTimerRef,
markSettled,
handleEngineTick,
}: MapForceGraphProps) {
return (
<ForceGraph2D
// @ts-expect-error type library mismatch
ref={fgRef}
graphData={graphData}
width={size.w}
height={size.h}
backgroundColor={'transparent'}
nodeLabel={(n: unknown) => (n as MapNode).title}
d3VelocityDecay={forceConfig.velocityDecay}
d3AlphaDecay={forceConfig.alphaDecay}
d3AlphaMin={forceConfig.alphaMin}
nodeCanvasObject={(node, ctx, globalScale) => {
const n = node as WithCoords;
if (n.x == null || n.y == null) return;
const isHover = hoverId === n.id;
const isNeighbor = !isHover && hoverId != null && neighbors.get(hoverId)?.has(n.id);
const matchesType = hoverType == null || n.colorBucket === hoverType;
const dim =
(hoverId != null && !isHover && !isNeighbor) || (hoverType != null && !matchesType);
const baseSize = NODE_SIZE * (isNeighbor ? 1.2 : 1);

const screenPx = baseSize * (globalScale ?? 1);
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
const desired: ResolutionTier = pickTier(screenPx * dpr);
if (n.imgs && !n.imgs.has(desired) && !n.pendingTiers?.has(desired)) {
ensureNodeTier(n, desired, () => fgRef.current?.refresh?.());
}
const img = n.imgs ? pickBestLoadedTier(n.imgs, desired) : null;

const { w, h } = fitToBox(baseSize, nodeAspectRatio(n));
const x = n.x - w / 2;
const y = n.y - h / 2;

ctx.save();
if (dim) ctx.globalAlpha = 0.18;
if (img) {
ctx.drawImage(img, x, y, w, h);
} else {
ctx.fillStyle = isDark ? '#242420' : '#FFFDF6';
ctx.fillRect(x, y, w, h);
}
ctx.lineWidth = isHover ? 2 : n.colorBucket ? 1.5 : 1;
ctx.strokeStyle = strokeFor(isDark, !!isHover, colorFor(n.colorBucket, graphData.topTypes));
ctx.strokeRect(x, y, w, h);

ctx.restore();
}}
nodePointerAreaPaint={(node, color, ctx) => {
const n = node as WithCoords;
if (n.x == null || n.y == null) return;
const { w, h } = fitToBox(NODE_SIZE, nodeAspectRatio(n));
ctx.fillStyle = color;
ctx.fillRect(n.x - w / 2, n.y - h / 2, w, h);
}}
linkColor={(l: MapLink) => {
const sId = linkEndId(l.source);
const tId = linkEndId(l.target);
const involved = hoverId && (sId === hoverId || tId === hoverId);
if (involved) {
const hoverNode = nodeById.get(hoverId);
return colorFor(hoverNode?.colorBucket ?? null, graphData.topTypes) ?? colors.primary;
}
if (hoverType) {
const sBucket = sId ? nodeById.get(sId)?.colorBucket : undefined;
const tBucket = tId ? nodeById.get(tId)?.colorBucket : undefined;
const intra = sBucket === hoverType && tBucket === hoverType;
if (intra) return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.22)';
return isDark ? 'rgba(255,255,255,0.012)' : 'rgba(0,0,0,0.015)';
}
if (hoverId) return isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
return isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.13)';
}}
linkWidth={(l: MapLink) => {
const sId = linkEndId(l.source);
const tId = linkEndId(l.target);
const involved = hoverId && (sId === hoverId || tId === hoverId);
if (involved) return Math.max(1.5, (l.weight ?? 0.3) * 3);
return Math.max(0.4, (l.weight ?? 0.3) * 1.5);
}}
onNodeClick={onNodeClick as (node: object) => void}
onBackgroundClick={() => {
setPinnedId(null);
setHoverId(null);
}}
onNodeHover={(n: object | null) => {
const node = n as MapNode | null;
if (hoverTimerRef.current != null) {
window.clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
if (node) {
hoverTimerRef.current = window.setTimeout(() => {
setHoverId(node.id);
hoverTimerRef.current = null;
}, 100) as unknown as number;
} else {
setHoverId(null);
}
}}
cooldownTicks={forceConfig.cooldownTicks}
onEngineStop={() => {
const fg = fgRef.current;
if (fg) {
const xs: number[] = [];
const ys: number[] = [];
for (const n of graphData.nodes as Array<MapNode & { x?: number; y?: number }>) {
if (n.x != null && n.y != null) {
xs.push(n.x);
ys.push(n.y);
}
}
if (xs.length > 0) {
xs.sort((a, b) => a - b);
ys.sort((a, b) => a - b);
const trim = 0.05;
const lo = Math.floor(xs.length * trim);
const hi = Math.floor(xs.length * (1 - trim));
const minX = xs[lo],
maxX = xs[hi],
minY = ys[lo],
maxY = ys[hi];
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const bboxW = Math.max(1, maxX - minX);
const bboxH = Math.max(1, maxY - minY);
const padding = Math.round(Math.min(size.w, size.h) * 0.1);
const fitZoom = Math.min(
(size.w - 2 * padding) / bboxW,
(size.h - 2 * padding) / bboxH
);
fg.centerAt?.(cx, cy, 0);
fg.zoom?.(fitZoom, 0);
}
}
markSettled();
}}
onRenderFramePre={() => {
const fg = fgRef.current;
if (!fg || fg.__forcesWired) return;
// @ts-expect-error D3 force types are missing
fg.d3Force('charge')?.strength(forceConfig.chargeStrength);
const linkForce = fg.d3Force('link') as {
distance: (d: unknown) => void;
strength: (s: unknown) => void;
};
if (linkForce) {
linkForce.distance(forceConfig.linkDistance);
linkForce.strength(forceConfig.linkStrength);
}
fg.d3Force('collide', forceConfig.createCollideForce());
const centerForce = fg.d3Force('center') as { strength?: (s: unknown) => void } | undefined;
centerForce?.strength?.(forceConfig.centerGravity);
fg.d3Force('outlier-squash', forceConfig.createOutlierSquashForce());
fg.__forcesWired = true;
fg.d3ReheatSimulation?.();
}}
onEngineTick={handleEngineTick}
/>
);
}
113 changes: 113 additions & 0 deletions app/src/sections/map/MapHoverPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Box from '@mui/material/Box';

import { fontSize, typography } from 'src/theme';

interface MapHoverPanelProps {
hoverId: string | null;
pinnedId: string | null;
panelData: {
title: string;
previewUrl: string | null;
plotType: string | null;
tags: string[];
chipColor?: string | null;
} | null;
isDark: boolean;
}

export function MapHoverPanel({ hoverId, pinnedId, panelData, isDark }: MapHoverPanelProps) {
return (
<Box
component="aside"
aria-hidden={!(hoverId || pinnedId)}
sx={{
position: 'absolute',
bottom: { xs: 8, sm: 16 },
right: { xs: 16, sm: 32, md: 64, lg: 96 },
zIndex: 3,
width: { xs: 160, md: 280 },
maxWidth: { xs: 'calc(60vw - 32px)', md: 'calc(100vw - 64px)' },
border: '1px solid var(--rule)',
borderRadius: '4px',
bgcolor: 'var(--bg-surface)',
overflow: 'hidden',
pointerEvents: 'none',
opacity: (hoverId || pinnedId) && panelData ? 1 : 0,
transform: (hoverId || pinnedId) && panelData ? 'translateY(0)' : 'translateY(4px)',
transition: 'opacity 120ms ease, transform 120ms ease',
fontFamily: typography.mono,
}}
>
{panelData && (
<>
{panelData.previewUrl ? (
<Box
component="img"
src={panelData.previewUrl}
alt=""
sx={{
display: 'block',
width: '100%',
height: 'auto',
maxHeight: { xs: 110, md: 200 },
objectFit: 'contain',
bgcolor: isDark ? '#0a0a08' : '#FFFDF6',
}}
/>
) : (
<Box sx={{ height: { xs: 90, md: 158 }, bgcolor: isDark ? '#0a0a08' : '#FFFDF6' }} />
)}
<Box
sx={{ p: { xs: 0.75, md: 1.25 }, display: 'flex', flexDirection: 'column', gap: 0.5 }}
>
<Box
sx={{
fontSize: fontSize.sm,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{panelData.title}
</Box>
{panelData.plotType && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '2px',
bgcolor: panelData.chipColor ?? 'var(--ink-soft)',
flex: 'none',
}}
/>
<Box sx={{ fontSize: fontSize.xs, color: 'var(--ink-soft)' }}>
{panelData.plotType}
</Box>
</Box>
)}
{panelData.tags.length > 0 && (
<Box
sx={{
display: { xs: 'none', md: 'flex' },
flexWrap: 'wrap',
gap: 0.75,
fontSize: fontSize.xs,
color: 'var(--ink-soft)',
opacity: 0.85,
}}
>
{panelData.tags.map(t => (
<Box key={t} component="span">
{t}
</Box>
))}
</Box>
)}
</Box>
</>
)}
</Box>
);
}
Loading
Loading