From 5b750a8bc321a40f26ce18c8d3d8fb8f4106359a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Sun, 8 Aug 2021 23:48:37 +0200 Subject: feat: much smoother local mode --- pages/index.tsx | 260 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 138 insertions(+), 122 deletions(-) (limited to 'pages/index.tsx') diff --git a/pages/index.tsx b/pages/index.tsx index d3d6a44..12c42a1 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -84,6 +84,10 @@ function normalizeLinkEnds(link: OrgRoamLink | LinkObject): [string, string] { } export function GraphPage() { + const [threeDim, setThreeDim] = usePersistantState('3d', false) + const [tagColors, setTagColors] = usePersistantState('tagCols', {}) + const [scope, setScope] = useState({ nodeIds: [] }) + const [physics, setPhysics] = usePersistantState('physics', initialPhysics) const [filter, setFilter] = usePersistantState('filter', initialFilter) const [visuals, setVisuals] = usePersistantState('visuals', initialVisuals) @@ -102,7 +106,6 @@ export function GraphPage() { const updateGraphData = (orgRoamGraphData: OrgRoamGraphReponse) => { const currentGraphData = currentGraphDataRef.current const oldNodeById = nodeByIdRef.current - const oldLinksByNodeId = linksByNodeIdRef.current tagsRef.current = orgRoamGraphData.tags ?? [] const nodesByFile = orgRoamGraphData.nodes.reduce((acc, node) => { return { @@ -186,7 +189,6 @@ export function GraphPage() { const orgRoamGraphDataClone = JSON.parse( JSON.stringify(orgRoamGraphDataWithFileLinksAndBadNdes), ) - console.log(orgRoamGraphDataClone) currentGraphDataRef.current = orgRoamGraphDataClone setGraphData(orgRoamGraphDataClone) return @@ -203,7 +205,6 @@ export function GraphPage() { ...Object.keys(nodeByIdRef.current) .filter((id) => !oldNodeById[id]) .map((id) => { - console.log(id) return nodeByIdRef.current[id] as NodeObject }), ] @@ -215,49 +216,7 @@ export function GraphPage() { [id]: index, } }, {}) - console.log(newNodes) - console.log(nodeIndex) - /* const currentGraphIndexByLink = currentGraphData.links.reduce<{[key: string]: number}>((acc, link, index) => { -* const [source, target] = normalizeLinkEnds(link) -* const sourceTarget=source+target -* return { -* ...acc, -* [sourceTarget]: index -* } -},{}) */ - /* const newLinks = [ - * ...currentGraphData!.links.filter((link) => { - * const [source, target] = normalizeLinkEnds(link) - * if (!nodeByIdRef.current[source] || !nodeByIdRef.current[target]) { - * return false - * } - * if ( - * !linksByNodeIdRef.current[source]!.some( - * (link) => link.target === target || link.source === target, - * ) && - * !linksByNodeIdRef.current[target]!.some( - * (link) => link.target === source || link.source === source, - * ) - * ) { - * return false - * } - * return true - * }), - * ...Object.keys(linksByNodeIdRef.current).flatMap((id) => { - * if (!oldLinksByNodeId[id]!) { - * return linksByNodeIdRef.current![id!]! - * } - * return ( - * linksByNodeIdRef.current![id]!.filter((link) => { - * const [source, target] = normalizeLinkEnds(link) - * return !oldLinksByNodeId[id]!.some( - * (oldLink) => oldLink.source === source && oldLink.target === target, - * )! - * }) ?? [] - * ) - * }), - * ] - */ + const newerLinks = links.map((link) => { const [source, target] = normalizeLinkEnds(link) return { @@ -278,9 +237,6 @@ export function GraphPage() { const { setEmacsTheme } = useContext(ThemeContext) - const [threeDim, setThreeDim] = usePersistantState('3d', false) - const [tagColors, setTagColors] = usePersistantState('tagCols', {}) - const [scope, setScope] = useState({ nodeIds: [] }) const scopeRef = useRef({ nodeIds: [] }) const behaviorRef = useRef(initialBehavior) behaviorRef.current = behavior @@ -306,28 +262,28 @@ export function GraphPage() { ), ) if (command === 'zoom') { - console.log(sr) if (sr.nodeIds.length) { - console.log('emptying') - console.log('scope ' + sr.nodeIds) setScope({ nodeIds: [] }) } - setTimeout(() => fg.zoomToFit(speed, padding, (node: OrgRoamNode) => nodes[node.id!]), 50) + setTimeout( + () => fg.zoomToFit(speed, padding, (node: NodeObject) => nodes[node.id as string]), + 50, + ) return } if (!sr.nodeIds.length) { setScope({ nodeIds: [emacsNode] }) setTimeout(() => { - /* fg.zoomToFit(speed, padding, (node: OrgRoamNode) => nodes[node.id!]) */ - fg.centerAt(0, 0, speed) + fg.centerAt(0, 0, 10) + fg.zoomToFit(1, padding) }, 50) return } if (bh.localSame !== 'add') { setScope({ nodeIds: [emacsNode] }) setTimeout(() => { - /* fg.zoomToFit(speed, padding, (node: OrgRoamNode) => nodes[node.id!]) */ - fg.centerAt(0, 0, speed) + fg.centerAt(0, 0, 10) + fg.zoomToFit(1, padding) }, 50) return } @@ -341,8 +297,8 @@ export function GraphPage() { ) { setScope({ nodeIds: [emacsNode] }) setTimeout(() => { - /* fg.zoomToFit(speed, padding, (node: OrgRoamNode) => nodes[node.id!]) */ - fg.centerAt(0, 0, speed) + fg.centerAt(0, 0, 10) + fg.zoomToFit(1, padding) }, 50) return } @@ -350,10 +306,14 @@ export function GraphPage() { ...currentScope, nodeIds: [...currentScope.nodeIds, emacsNode as string], })) - setTimeout(() => fg.zoomToFit(speed, padding, (node: OrgRoamNode) => nodes[node.id!]), 50) + setTimeout(() => { + fg.centerAt(0, 0, 10) + fg.zoomToFit(1, padding) + }, 50) } useEffect(() => { + // initialize websocket WebSocketRef.current = new ReconnectingWebSocket('ws://localhost:35903') WebSocketRef.current.addEventListener('open', (event: any) => { console.log('Connection with Emacs established') @@ -394,6 +354,19 @@ export function GraphPage() { }) }, []) + useEffect(() => { + const fg = graphRef.current + if (!fg || scope.nodeIds.length > 1) { + return + } + if (!scope.nodeIds.length) { + return + } + setTimeout(() => { + fg.zoomToFit(5, 200) + }, 50) + }, [scope.nodeIds]) + if (!graphData) { return null } @@ -486,18 +459,21 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const [hoverNode, setHoverNode] = useState(null) + const [rightClickedNode, setRightClickedNode] = useState(null) + const [contextPos, setContextPos] = useState([0, 0]) + const theme = useTheme() const { emacsTheme } = useContext(ThemeContext) const handleLocal = (node: OrgRoamNode, add: string) => { - if (scope.nodeIds.includes(node.id as string)) { - return - } if (add === 'replace') { setScope({ nodeIds: [node.id] }) return } + if (scope.nodeIds.includes(node.id as string)) { + return + } setScope((currentScope: Scope) => ({ ...currentScope, nodeIds: [...currentScope.nodeIds, node.id as string], @@ -523,7 +499,13 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { sendMessageToEmacs('create', { id: node.id, title: node.title, ref: node.properties.ROAM_REFS }) } - const handleClick = (click: string, node: OrgRoamNode) => { + const openContextMenu = (node: OrgRoamNode, event: any) => { + setContextPos([event.pageX, event.pageY]) + setRightClickedNode(node) + onOpen() + } + + const handleClick = (click: string, node: OrgRoamNode, event: any) => { switch (click) { //mouse.highlight: case mouse.local: { @@ -534,18 +516,38 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { openNodeInEmacs(node) break } + case mouse.context: { + openContextMenu(node, event) + } default: break } } - const getNeighborNodes = (id: string) => { - const links = linksByNodeId[id]! ?? [] - return Object.fromEntries( - [id as string, ...links.flatMap((link) => [link.source, link.target])].map((nodeId) => [ - nodeId, - {}, - ]), - ) + const findNthNeighbors = (ids: string[], n: number) => { + let queue = [ids[0]] + let todo: string[] = [] + const completed = [ids[0]] + Array.from({ length: n }, () => { + queue.forEach((node) => { + const links = linksByNodeId[node as string] ?? [] + links.forEach((link) => { + const [sourceId, targetId] = normalizeLinkEnds(link) + if (!completed.includes(sourceId)) { + todo.push(sourceId) + return + } + if (!completed.includes(targetId)) { + todo.push(targetId) + return + } + return + }) + }) + queue = todo + todo.forEach((neighbor) => neighbor && completed.push(neighbor)) + todo = [] + }) + return completed } const centralHighlightedNode = useRef(null) @@ -577,7 +579,6 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { }, [centralHighlightedNode.current, linksByNodeId]) const hiddenNodeIdsRef = useRef({}) - const filteredLinksByNodeId = useRef({}) const filteredGraphData = useMemo(() => { hiddenNodeIdsRef.current = {} const filteredNodes = graphData?.nodes @@ -638,57 +639,79 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const filteredLinks = graphData.links.filter((link) => { const [sourceId, targetId] = normalizeLinkEnds(link) if ( - filter.bad || - filter.tagsBlacklist.length || - filter.tagsWhitelist.length || - filter.filelessCites + !filteredNodeIds.includes(sourceId as string) || + !filteredNodeIds.includes(targetId as string) ) { - return ( - filteredNodeIds.includes(sourceId as string) && - filteredNodeIds.includes(targetId as string) - ) + return false } const linkRoam = link as OrgRoamLink return filter.parents || linkRoam.type !== 'parent' }) - return { filteredNodes, filteredLinks } + return { nodes: filteredNodes, links: filteredLinks } }, [filter, graphData]) - const scopedGraphData = useMemo(() => { - const scopedNodes = filteredGraphData.filteredNodes.filter((node) => { - const links = linksByNodeId[node.id as string] ?? [] - return ( - scope.nodeIds.includes(node.id as string) || - links.some((link) => { - return scope.nodeIds.includes(link.source) || scope.nodeIds.includes(link.target) - }) - ) - }) + const [scopedGraphData, setScopedGraphData] = useState({ nodes: [], links: [] }) + useEffect(() => { + if (!scope.nodeIds.length) { + return + } + const oldScopedNodes = scope.nodeIds.length > 1 ? scopedGraphData.nodes : [] + const oldScopedNodeIds = oldScopedNodes.map((node) => node.id as string) + const neighbs = findNthNeighbors(scope.nodeIds, 1) + const newScopedNodes = filteredGraphData.nodes + .filter((node) => { + if (oldScopedNodes.length) { + if (oldScopedNodeIds.includes(node.id as string)) { + return false + } + const links = linksByNodeId[node.id as string] ?? [] + return links.some((link) => { + return scope.nodeIds.includes(link.source) || scope.nodeIds.includes(link.target) + }) + } + return neighbs.includes(node.id as string) + // this creates new nodes, to separate them from the nodes in the global graph + // and positions them in the center, so that the camera is not so jumpy + }) + .map((node) => { + return { ...node, x: 0, y: 0, vy: 0, vx: 0 } + }) + const scopedNodes = [...oldScopedNodes, ...newScopedNodes] const scopedNodeIds = scopedNodes.map((node) => node.id as string) - const scopedLinks = filteredGraphData.filteredLinks.filter((link) => { - // we need to cover both because force-graph modifies the original data - // but if we supply the original data on each render, the graph will re-render sporadically - const [sourceId, targetId] = normalizeLinkEnds(link) - return ( - scopedNodeIds.includes(sourceId as string) && scopedNodeIds.includes(targetId as string) - ) - }) - - return scope.nodeIds.length === 0 - ? { nodes: filteredGraphData.filteredNodes, links: filteredGraphData.filteredLinks } - : { - nodes: scopedNodes, - links: scopedLinks, + const oldScopedLinks = scope.nodeIds.length > 1 ? scopedGraphData.links : [] + const newScopedLinks = filteredGraphData.links + .filter((link) => { + // we need to cover both because force-graph modifies the original data + // but if we supply the original data on each render, the graph will re-render sporadically + const [sourceId, targetId] = normalizeLinkEnds(link) + if ( + oldScopedLinks.length && + oldScopedNodeIds.includes(targetId) && + oldScopedNodeIds.includes(sourceId) + ) { + return false } + return ( + scopedNodeIds.includes(sourceId as string) && scopedNodeIds.includes(targetId as string) + ) + }) + .map((link) => { + const [sourceId, targetId] = normalizeLinkEnds(link) + return { source: sourceId, target: targetId } + }) + + const scopedLinks = [...oldScopedLinks, ...newScopedLinks] + + setScopedGraphData({ nodes: scopedNodes, links: scopedLinks }) }, [filter, scope, graphData]) useEffect(() => { ;(async () => { const fg = graphRef.current const d3 = await d3promise - if (physics.gravityOn) { + if (physics.gravityOn && !(scope.nodeIds.length && !physics.gravityLocal)) { fg.d3Force('x', d3.forceX().strength(physics.gravity)) fg.d3Force('y', d3.forceY().strength(physics.gravity)) threeDim && fg.d3Force('z', d3.forceZ().strength(physics.gravity)) @@ -708,18 +731,18 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { physics.collision ? d3.forceCollide().radius(physics.collisionStrength) : null, ) })() - }, [physics]) + }, [physics, threeDim, scope]) // Normally the graph doesn't update when you just change the physics parameters // This forces the graph to make a small update when you do useEffect(() => { graphRef.current?.d3ReheatSimulation() - }, [physics]) + }, [physics, scope.nodeIds.length]) //shitty handler to check for doubleClicks const lastNodeClickRef = useRef(0) - const [opacity, setOpacity] = useState(1) + const [opacity, setOpacity] = useState(1) const [fadeIn, cancel] = useAnimation((x) => setOpacity(x), { duration: visuals.animationSpeed, algorithm: algos[visuals.algorithmName], @@ -907,19 +930,13 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const [dragging, setDragging] = useState(false) const { isOpen, onOpen, onClose } = useDisclosure() - const [rightClickedNode, setRightClickedNode] = useState(null) - const [contextPos, setContextPos] = useState([0, 0]) - const openContextMenu = (node: OrgRoamNode, event: any) => { - setContextPos([event.pageX, event.pageY]) - setRightClickedNode(node) - onOpen() - } const graphCommonProps: ComponentPropsWithoutRef = { - graphData: scopedGraphData, + graphData: scope.nodeIds.length ? scopedGraphData : filteredGraphData, width: windowWidth, height: windowHeight, backgroundColor: theme.colors.gray[visuals.backgroundColor], + warmupTicks: scope.nodeIds.length === 1 ? 100 : 0, nodeLabel: (node) => (node as OrgRoamNode).title, nodeColor: (node) => { return getNodeColor(node as OrgRoamNode) @@ -1057,9 +1074,9 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { const isDoubleClick = event.timeStamp - lastNodeClickRef.current < 400 lastNodeClickRef.current = event.timeStamp if (isDoubleClick) { - return handleClick('double', node) + return handleClick('double', node, event) } - return handleClick('click', node) + return handleClick('click', node, event) }, onBackgroundClick: () => { onClose() @@ -1085,9 +1102,8 @@ export const Graph = forwardRef(function (props: GraphProps, graphRef: any) { }, onNodeRightClick: (nodeArg, event) => { const node = nodeArg as OrgRoamNode - openContextMenu(node, event) - //handleClick('right', node) + handleClick('right', node, event) }, onNodeDrag: (node) => { onClose() -- cgit v1.2.3